From 5f9d387084db96b66cd553a57b629f0d51e39e9f Mon Sep 17 00:00:00 2001 From: ayomidearegbeshola29-dev Date: Fri, 26 Jun 2026 14:57:39 +0000 Subject: [PATCH] feat(soroban): add contract upgrade orchestration with state migration safety verification Co-authored-by: opencode --- packages/stellar/src/index.ts | 1 + .../stellar/src/upgrade-orchestrator.test.ts | 286 ++++++++++++++++++ packages/stellar/src/upgrade-orchestrator.ts | 230 ++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 packages/stellar/src/upgrade-orchestrator.test.ts create mode 100644 packages/stellar/src/upgrade-orchestrator.ts diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index 718f9fe..dbb954a 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -11,3 +11,4 @@ export * from './soroban-budget-monitor'; export * from './soroban-xdr-deserializer'; export * from './dex-price-feed'; export * from './soroban-ttl-manager'; +export * from './upgrade-orchestrator'; diff --git a/packages/stellar/src/upgrade-orchestrator.test.ts b/packages/stellar/src/upgrade-orchestrator.test.ts new file mode 100644 index 0000000..039c17e --- /dev/null +++ b/packages/stellar/src/upgrade-orchestrator.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ContractAbiSchema } from './upgrade-orchestrator'; + +const mocks = vi.hoisted(() => ({ + mockSimulate: vi.fn(), + mockFromXDR: vi.fn(), +})); + +vi.mock('stellar-sdk', () => ({ + SorobanRpc: { + Api: { + isSimulationError: vi.fn(), + }, + Server: vi.fn(), + }, + TransactionBuilder: { + fromXDR: mocks.mockFromXDR, + }, + Networks: { + TESTNET: 'Test SDF Network ; September 2015', + PUBLIC: 'Public Global Stellar Network ; September 2015', + }, + BASE_FEE: '100', +})); + +vi.mock('./soroban', () => ({ + createSorobanClient: vi.fn(() => ({ + simulateTransaction: mocks.mockSimulate, + })), +})); + +const { diffAbiSchemas, orchestrateContractUpgrade } = await import('./upgrade-orchestrator'); + +function makeSchema(overrides?: Partial): ContractAbiSchema { + return { + version: '1.0.0', + storageKeys: [ + { key: 'admin', type: 'instance', required: true }, + { key: 'balance', type: 'persistent', required: true }, + { key: 'metadata', type: 'instance', required: false }, + ], + ...overrides, + }; +} + +const currentSchema = makeSchema(); + +// --------------------------------------------------------------------------- +// diffAbiSchemas (pure, no mocking needed) +// --------------------------------------------------------------------------- + +describe('diffAbiSchemas', () => { + it('returns safe=true with no changes when schemas are identical', () => { + const report = diffAbiSchemas(currentSchema, makeSchema()); + expect(report.safe).toBe(true); + expect(report.changes).toHaveLength(0); + expect(report.breakingChanges).toHaveLength(0); + }); + + it('detects added keys as non-breaking', () => { + const next = makeSchema({ + storageKeys: [ + ...currentSchema.storageKeys, + { key: 'new_key', type: 'temporary', required: false }, + ], + }); + const report = diffAbiSchemas(currentSchema, next); + expect(report.safe).toBe(true); + expect(report.changes).toHaveLength(1); + expect(report.changes[0].type).toBe('added'); + expect(report.changes[0].key).toBe('new_key'); + }); + + it('detects removed optional keys as non-breaking', () => { + const next = makeSchema({ + storageKeys: currentSchema.storageKeys.filter( + (k) => k.key !== 'metadata', + ), + }); + const report = diffAbiSchemas(currentSchema, next); + expect(report.safe).toBe(true); + expect(report.changes).toHaveLength(1); + expect(report.changes[0].type).toBe('removed'); + expect(report.changes[0].key).toBe('metadata'); + }); + + it('flags removed required keys as breaking', () => { + const next = makeSchema({ + storageKeys: currentSchema.storageKeys.filter((k) => k.key !== 'admin'), + }); + const report = diffAbiSchemas(currentSchema, next); + expect(report.safe).toBe(false); + expect(report.breakingChanges).toHaveLength(1); + expect(report.breakingChanges[0].type).toBe('removed'); + expect(report.breakingChanges[0].key).toBe('admin'); + }); + + it('flags storage type changes as breaking', () => { + const next = makeSchema({ + storageKeys: currentSchema.storageKeys.map((k) => + k.key === 'balance' ? { ...k, type: 'temporary' as const } : k, + ), + }); + const report = diffAbiSchemas(currentSchema, next); + expect(report.safe).toBe(false); + expect(report.breakingChanges).toHaveLength(1); + expect(report.breakingChanges[0].type).toBe('type_changed'); + expect(report.breakingChanges[0].key).toBe('balance'); + }); + + it('reports multiple breaking changes together', () => { + const next = makeSchema({ + storageKeys: [ + { key: 'balance', type: 'temporary', required: true }, + { key: 'metadata', type: 'instance', required: false }, + ], + }); + const report = diffAbiSchemas(currentSchema, next); + expect(report.safe).toBe(false); + expect(report.breakingChanges).toHaveLength(2); + const breakTypes = report.breakingChanges.map((c) => c.type).sort(); + expect(breakTypes).toEqual(['removed', 'type_changed']); + }); + + it('produces a human-readable summary', () => { + const next = makeSchema({ + version: '2.0.0', + storageKeys: [ + ...currentSchema.storageKeys, + { key: 'fee', type: 'instance', required: true }, + ], + }); + const report = diffAbiSchemas(currentSchema, next); + expect(report.summary).toContain('1.0.0'); + expect(report.summary).toContain('2.0.0'); + expect(report.summary).toContain('Added'); + expect(report.summary).toContain('SAFE'); + }); + + it('includes breaking change details in summary when unsafe', () => { + const next = makeSchema({ + version: '2.0.0', + storageKeys: currentSchema.storageKeys.filter((k) => k.key !== 'admin'), + }); + const report = diffAbiSchemas(currentSchema, next); + expect(report.summary).toContain('UNSAFE'); + expect(report.summary).toContain('admin'); + }); + + it('handles empty storage keys', () => { + const empty: ContractAbiSchema = { version: '1.0.0', storageKeys: [] }; + const report = diffAbiSchemas(empty, empty); + expect(report.safe).toBe(true); + expect(report.changes).toHaveLength(0); + }); + + it('detects all additions, removals, and changes in one diff', () => { + const next: ContractAbiSchema = { + version: '2.0.0', + storageKeys: [ + { key: 'balance', type: 'instance', required: true }, + { key: 'metadata', type: 'instance', required: false }, + { key: 'new_flag', type: 'temporary', required: true }, + ], + }; + const report = diffAbiSchemas(currentSchema, next); + expect(report.changes).toHaveLength(3); + expect(report.changes.find((c) => c.type === 'added')?.key).toBe('new_flag'); + expect(report.changes.find((c) => c.type === 'removed')?.key).toBe('admin'); + expect(report.changes.find((c) => c.type === 'type_changed')?.key).toBe('balance'); + expect(report.breakingChanges).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// orchestrateContractUpgrade +// --------------------------------------------------------------------------- + +describe('orchestrateContractUpgrade', () => { + const mockTx = { mockTx: true }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockFromXDR.mockReturnValue(mockTx); + }); + + it('returns success with diff report when upgrade is safe (no dry-run)', async () => { + const result = await orchestrateContractUpgrade({ + currentSchema, + newSchema: makeSchema(), + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dryRun).toBe(false); + expect(result.diffReport.safe).toBe(true); + } + }); + + it('blocks upgrade when breaking changes are detected', async () => { + const badNext = makeSchema({ + storageKeys: currentSchema.storageKeys.filter((k) => k.key !== 'admin'), + }); + const result = await orchestrateContractUpgrade({ + currentSchema, + newSchema: badNext, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('blocked'); + expect(result.error).toContain('breaking'); + expect(result.diffReport).toBeDefined(); + } + }); + + it('returns error when dryRun=true but no upgradeTransactionXdr', async () => { + const result = await orchestrateContractUpgrade({ + currentSchema, + newSchema: makeSchema(), + dryRun: true, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('dryRun'); + } + }); + + it('performs simulation when dryRun=true with valid XDR', async () => { + mocks.mockSimulate.mockResolvedValue({}); + + const result = await orchestrateContractUpgrade({ + currentSchema, + newSchema: makeSchema(), + dryRun: true, + upgradeTransactionXdr: 'AAAAAgAAAABb8PsSeJ2XH7dDrHV6I90DH2eDBFezq92rLvdUesFGzgAAAGQADKQ7', + networkPassphrase: 'Test SDF Network ; September 2015', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dryRun).toBe(true); + expect(result.simulationResult).toBeDefined(); + } + expect(mocks.mockFromXDR).toHaveBeenCalledWith( + 'AAAAAgAAAABb8PsSeJ2XH7dDrHV6I90DH2eDBFezq92rLvdUesFGzgAAAGQADKQ7', + 'Test SDF Network ; September 2015', + ); + expect(mocks.mockSimulate).toHaveBeenCalledWith(mockTx); + }); + + it('handles simulation errors gracefully', async () => { + mocks.mockSimulate.mockRejectedValue(new Error('RPC timeout')); + + const result = await orchestrateContractUpgrade({ + currentSchema, + newSchema: makeSchema(), + dryRun: true, + upgradeTransactionXdr: 'AAAAAgAAAABb8PsSeJ2XH7dDrHV6I90DH2eDBFezq92rLvdUesFGzgAAAGQADKQ7', + networkPassphrase: 'Test SDF Network ; September 2015', + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('Simulation failed'); + expect(result.error).toContain('RPC timeout'); + expect(result.diffReport).toBeDefined(); + } + }); + + it('returns simulation error when simulate returns an error response', async () => { + const errorResult = { error: 'Contract error: out of bounds' }; + mocks.mockSimulate.mockResolvedValue(errorResult); + + const { SorobanRpc } = await import('stellar-sdk'); + vi.mocked(SorobanRpc.Api.isSimulationError).mockReturnValueOnce(true); + + const result = await orchestrateContractUpgrade({ + currentSchema, + newSchema: makeSchema(), + dryRun: true, + upgradeTransactionXdr: 'AAAAAgAAAABb8PsSeJ2XH7dDrHV6I90DH2eDBFezq92rLvdUesFGzgAAAGQADKQ7', + networkPassphrase: 'Test SDF Network ; September 2015', + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('Simulation error'); + } + }); +}); diff --git a/packages/stellar/src/upgrade-orchestrator.ts b/packages/stellar/src/upgrade-orchestrator.ts new file mode 100644 index 0000000..c8627e2 --- /dev/null +++ b/packages/stellar/src/upgrade-orchestrator.ts @@ -0,0 +1,230 @@ +/** + * Soroban Contract Upgrade Orchestration (#770) + * + * Validates ABI schema compatibility between old and new contract versions + * before submitting an upgrade transaction. Supports dry-run mode to simulate + * the upgrade without broadcasting, and emits a detailed schema diff report. + */ + +import { SorobanRpc, TransactionBuilder, Networks, BASE_FEE, xdr } from 'stellar-sdk'; +import { createSorobanClient } from './soroban'; + +// --------------------------------------------------------------------------- +// ABI Schema types +// --------------------------------------------------------------------------- + +export type StorageKeyType = 'instance' | 'persistent' | 'temporary'; + +export interface AbiStorageEntry { + key: string; + type: StorageKeyType; + /** Whether this key is required (non-optional) in the contract storage. */ + required: boolean; +} + +/** Minimal contract ABI schema covering storage keys and their types. */ +export interface ContractAbiSchema { + /** Contract version string (semver or arbitrary label). */ + version: string; + /** All storage keys declared by the contract. */ + storageKeys: AbiStorageEntry[]; +} + +// --------------------------------------------------------------------------- +// Diff / report types +// --------------------------------------------------------------------------- + +export interface SchemaChange { + type: 'added' | 'removed' | 'type_changed'; + key: string; + /** Present for 'removed' and 'type_changed'. */ + oldEntry?: AbiStorageEntry; + /** Present for 'added' and 'type_changed'. */ + newEntry?: AbiStorageEntry; +} + +export interface SchemaDiffReport { + /** True when no breaking changes are detected. */ + safe: boolean; + /** Human-readable summary of the diff. */ + summary: string; + changes: SchemaChange[]; + /** Breaking changes only (removed required keys, changed storage type). */ + breakingChanges: SchemaChange[]; +} + +// --------------------------------------------------------------------------- +// Orchestrator input / output types +// --------------------------------------------------------------------------- + +export interface UpgradeOrchestratorOptions { + /** Current (deployed) contract ABI schema. */ + currentSchema: ContractAbiSchema; + /** New contract ABI schema to upgrade to. */ + newSchema: ContractAbiSchema; + /** + * When true, simulate the upgrade via `simulateTransaction` but do NOT + * broadcast the transaction to the network. + */ + dryRun?: boolean; + /** + * Serialised upgrade transaction XDR (base64). Required when `dryRun` is + * true so the orchestrator can call `simulateTransaction`. + */ + upgradeTransactionXdr?: string; + /** Source account public key — used to build the simulation envelope. */ + sourcePublicKey?: string; + /** Network passphrase — defaults to TESTNET when omitted. */ + networkPassphrase?: string; +} + +export type UpgradeOrchestratorResult = + | { ok: true; dryRun: boolean; diffReport: SchemaDiffReport; simulationResult?: SorobanRpc.Api.SimulateTransactionResponse } + | { ok: false; error: string; diffReport?: SchemaDiffReport }; + +// --------------------------------------------------------------------------- +// Core: ABI schema diff +// --------------------------------------------------------------------------- + +/** + * Compares two ABI schemas and returns a detailed diff report. + * + * Breaking changes are: + * - A required storage key that exists in `current` is absent in `next`. + * - A storage key's `type` field changes (different durability semantics). + */ +export function diffAbiSchemas( + current: ContractAbiSchema, + next: ContractAbiSchema, +): SchemaDiffReport { + const currentMap = new Map(current.storageKeys.map((e) => [e.key, e])); + const nextMap = new Map(next.storageKeys.map((e) => [e.key, e])); + + const changes: SchemaChange[] = []; + + // Detect removed / type-changed keys + for (const [key, oldEntry] of currentMap) { + const newEntry = nextMap.get(key); + if (!newEntry) { + changes.push({ type: 'removed', key, oldEntry }); + } else if (oldEntry.type !== newEntry.type) { + changes.push({ type: 'type_changed', key, oldEntry, newEntry }); + } + } + + // Detect added keys + for (const [key, newEntry] of nextMap) { + if (!currentMap.has(key)) { + changes.push({ type: 'added', key, newEntry }); + } + } + + const breakingChanges = changes.filter((c) => { + if (c.type === 'removed' && c.oldEntry?.required) return true; + if (c.type === 'type_changed') return true; + return false; + }); + + const safe = breakingChanges.length === 0; + + const summaryLines: string[] = [ + `Schema diff: ${current.version} → ${next.version}`, + ` Added : ${changes.filter((c) => c.type === 'added').length} key(s)`, + ` Removed : ${changes.filter((c) => c.type === 'removed').length} key(s)`, + ` Changed : ${changes.filter((c) => c.type === 'type_changed').length} key(s)`, + ` Breaking: ${breakingChanges.length} change(s)`, + safe ? ' Result : SAFE to upgrade' : ' Result : UNSAFE — breaking changes detected', + ]; + + if (breakingChanges.length > 0) { + summaryLines.push(' Breaking change details:'); + for (const bc of breakingChanges) { + if (bc.type === 'removed') { + summaryLines.push(` - Key "${bc.key}" (${bc.oldEntry?.type}) was REMOVED`); + } else if (bc.type === 'type_changed') { + summaryLines.push( + ` - Key "${bc.key}" storage type changed: ${bc.oldEntry?.type} → ${bc.newEntry?.type}`, + ); + } + } + } + + return { safe, summary: summaryLines.join('\n'), changes, breakingChanges }; +} + +// --------------------------------------------------------------------------- +// Core: orchestrate upgrade +// --------------------------------------------------------------------------- + +/** + * Orchestrates a Soroban contract upgrade with schema safety verification. + * + * Steps: + * 1. Diff the old and new ABI schemas. + * 2. Block if breaking changes are detected (unless all removed keys are optional). + * 3. If `dryRun` is true, simulate the transaction via Soroban RPC and return + * the simulation result without broadcasting. + * 4. Otherwise, confirm the upgrade is safe and return success. + * + * The caller is responsible for constructing and broadcasting the actual + * upgrade transaction — this keeps the orchestrator pure and testable. + */ +export async function orchestrateContractUpgrade( + options: UpgradeOrchestratorOptions, +): Promise { + const { + currentSchema, + newSchema, + dryRun = false, + upgradeTransactionXdr, + networkPassphrase = Networks.TESTNET, + } = options; + + // Step 1: Compare schemas + const diffReport = diffAbiSchemas(currentSchema, newSchema); + + // Step 2: Block on breaking changes + if (!diffReport.safe) { + return { + ok: false, + error: + `Upgrade blocked: breaking schema changes detected.\n${diffReport.summary}\n` + + 'Provide a migration path for removed/changed storage keys before upgrading.', + diffReport, + }; + } + + // Step 3: Dry-run simulation + if (dryRun) { + if (!upgradeTransactionXdr) { + return { + ok: false, + error: 'dryRun requires upgradeTransactionXdr to simulate the transaction.', + diffReport, + }; + } + + let simulationResult: SorobanRpc.Api.SimulateTransactionResponse; + try { + const client = createSorobanClient(); + const tx = TransactionBuilder.fromXDR(upgradeTransactionXdr, networkPassphrase); + simulationResult = await client.simulateTransaction(tx as Parameters[0]); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Simulation failed: ${message}`, diffReport }; + } + + if (SorobanRpc.Api.isSimulationError(simulationResult)) { + return { + ok: false, + error: `Simulation error: ${simulationResult.error}`, + diffReport, + }; + } + + return { ok: true, dryRun: true, diffReport, simulationResult }; + } + + // Step 4: Schema is safe, no dry-run — confirm readiness + return { ok: true, dryRun: false, diffReport }; +}