diff --git a/docs/development.md b/docs/development.md index 0e0704b..f50794b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -333,6 +333,20 @@ cargo fmt cargo clippy -- -D warnings ``` +### Vault error code sync + +`packages/orchestrator/src/vault-errors.ts` mirrors the contract's +`#[contracterror] VaultError` enum (`contracts/agent-vault/src/lib.rs`) as a +TypeScript `VaultErrorCode` enum, so a failed vault call can be surfaced to +callers as a typed `VaultContractError` (`code`, `codeName`, `known`, `raw`) +instead of an opaque string. + +`packages/orchestrator/src/vault-errors.test.ts` parses `lib.rs` directly and +asserts every variant name and discriminant matches `VaultErrorCode` exactly, +this runs as part of `npm test` and fails CI if the two drift apart. When you +add or renumber a `VaultError` variant in the contract, update +`VaultErrorCode` in the same PR or this test will fail. + ## Linting and formatting ESLint (TypeScript) and Prettier are configured at the repo root and apply to diff --git a/packages/orchestrator/src/agent-vault-client.ts b/packages/orchestrator/src/agent-vault-client.ts index fc08c0a..2833ad2 100644 --- a/packages/orchestrator/src/agent-vault-client.ts +++ b/packages/orchestrator/src/agent-vault-client.ts @@ -19,6 +19,14 @@ import { scValToNative, xdr, } from '@stellar/stellar-sdk'; +import { + errorFromSimulation, + errorFromSendResponse, + errorFromFailedTransaction, + VaultContractError, +} from './vault-errors.js'; + +export { VaultErrorCode, VaultContractError } from './vault-errors.js'; const CONTRACT_ID = process.env.AGENT_VAULT_CONTRACT_ID ?? ''; const RPC_URL = process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org'; @@ -72,7 +80,7 @@ async function buildUnsignedXdr( const simulated = await server.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simulated)) { - throw new Error(`Simulation failed: ${simulated.error}`); + throw errorFromSimulation(simulated); } return SorobanRpc.assembleTransaction(tx, simulated).build().toXDR(); @@ -97,7 +105,7 @@ async function signAndSubmit(keypair: Keypair, method: string, args: xdr.ScVal[] const simulated = await server.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simulated)) { - throw new Error(`Simulation failed: ${simulated.error}`); + throw errorFromSimulation(simulated); } tx = SorobanRpc.assembleTransaction(tx, simulated).build(); @@ -105,7 +113,7 @@ async function signAndSubmit(keypair: Keypair, method: string, args: xdr.ScVal[] const response = await server.sendTransaction(tx); if (response.status === 'ERROR') { - throw new Error(`Send failed: ${JSON.stringify(response.errorResult)}`); + throw errorFromSendResponse(response); } return pollForConfirmation(server, response.hash); @@ -119,7 +127,7 @@ async function pollForConfirmation(server: SorobanRpc.Server, hash: string): Pro return hash; } if (result.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { - throw new Error(`Transaction failed: ${hash}`); + throw errorFromFailedTransaction(hash, result); } } throw new Error(`Transaction timed out: ${hash}`); @@ -132,7 +140,7 @@ export async function submitSignedXdr(signedXdr: string): Promise { const tx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); const response = await server.sendTransaction(tx); if (response.status === 'ERROR') { - throw new Error(`Send failed: ${JSON.stringify(response.errorResult)}`); + throw errorFromSendResponse(response); } return pollForConfirmation(server, response.hash); } @@ -226,14 +234,14 @@ export async function createTask( const simulated = await server.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simulated)) { - throw new Error(`Simulation failed: ${simulated.error}`); + throw errorFromSimulation(simulated); } tx = SorobanRpc.assembleTransaction(tx, simulated).build(); tx.sign(orchestratorKeypair); const response = await server.sendTransaction(tx); - if (response.status === 'ERROR') throw new Error(`Send failed`); + if (response.status === 'ERROR') throw errorFromSendResponse(response); await pollForConfirmation(server, response.hash); @@ -249,7 +257,16 @@ export async function createTask( } } -/** Returns tx hash on success, null on failure (or if vault inactive). */ +/** + * Returns tx hash on success, null on failure (or if vault inactive). + * + * Re-throws `VaultContractError` (a genuine contract revert — e.g. + * `TaskAlreadyCompleted`, `ExceedsPlanCost`) so the caller can branch on + * `err.code`; its only caller (`executor.ts`) already treats a failed + * release as fatal to the step either way, so this only sharpens the + * failure reason, it doesn't change control flow. Non-contract failures + * (RPC hiccups, etc.) are still swallowed to `null`. + */ export async function releasePayment( orchestratorKeypair: Keypair, taskId: bigint, @@ -268,6 +285,7 @@ export async function releasePayment( return hash; } catch (err: any) { console.error('[AgentVault] releasePayment error:', err.message); + if (err instanceof VaultContractError) throw err; return null; } } diff --git a/packages/orchestrator/src/vault-errors.test.ts b/packages/orchestrator/src/vault-errors.test.ts new file mode 100644 index 0000000..80fe38a --- /dev/null +++ b/packages/orchestrator/src/vault-errors.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { xdr, Address, Keypair } from '@stellar/stellar-sdk'; +import { + VaultErrorCode, + VaultContractError, + extractContractErrorCode, + errorFromSimulation, + errorFromSendResponse, + errorFromFailedTransaction, +} from './vault-errors.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONTRACT_LIB_RS = path.join( + __dirname, + '..', + '..', + '..', + 'contracts', + 'agent-vault', + 'src', + 'lib.rs', +); + +function diagnosticEventWithContractCode(code: number): xdr.DiagnosticEvent { + const errorScVal = xdr.ScVal.scvError(xdr.ScError.sceContract(code)); + const body = new xdr.ContractEventBody( + 0, + new xdr.ContractEventV0({ topics: [], data: errorScVal }), + ); + const event = new xdr.ContractEvent({ + ext: new xdr.ExtensionPoint(0), + contractId: null, + type: xdr.ContractEventType.diagnostic(), + body, + }); + return new xdr.DiagnosticEvent({ inSuccessfulContractCall: false, event }); +} + +describe('VaultErrorCode mirrors the Rust VaultError enum', () => { + it('matches every variant and discriminant in contracts/agent-vault/src/lib.rs exactly', () => { + const rustSrc = readFileSync(CONTRACT_LIB_RS, 'utf-8'); + const enumMatch = rustSrc.match(/pub enum VaultError\s*\{([\s\S]*?)\n\}/); + expect(enumMatch, 'could not find `pub enum VaultError { ... }` in lib.rs').not.toBeNull(); + + const body = enumMatch![1]; + const variantPattern = /(\w+)\s*=\s*(\d+),/g; + const rustVariants: Record = {}; + let match: RegExpExecArray | null; + while ((match = variantPattern.exec(body)) !== null) { + rustVariants[match[1]] = Number(match[2]); + } + expect(Object.keys(rustVariants).length).toBeGreaterThan(0); + + const tsVariants: Record = {}; + for (const key of Object.keys(VaultErrorCode)) { + const value = VaultErrorCode[key as keyof typeof VaultErrorCode]; + if (typeof value === 'number') { + tsVariants[key] = value; + } + } + + expect(tsVariants).toEqual(rustVariants); + }); +}); + +describe('extractContractErrorCode', () => { + it('extracts the code from a diagnostic event carrying a scvError(sceContract)', () => { + const code = extractContractErrorCode({ + diagnosticEvents: [diagnosticEventWithContractCode(6)], + }); + expect(code).toBe(6); + }); + + it('extracts the code from a simulation HostError message', () => { + const message = + 'HostError: Error(Contract, #9)\n\nEvent log (newest first):\n 0: [Diagnostic Event] ...'; + expect(extractContractErrorCode({ message })).toBe(9); + }); + + it('returns null for a diagnostic event that is not a contract error', () => { + const nonErrorScVal = new Address(Keypair.random().publicKey()).toScVal(); + const body = new xdr.ContractEventBody( + 0, + new xdr.ContractEventV0({ topics: [], data: nonErrorScVal }), + ); + const event = new xdr.ContractEvent({ + ext: new xdr.ExtensionPoint(0), + contractId: null, + type: xdr.ContractEventType.contract(), + body, + }); + const diag = new xdr.DiagnosticEvent({ inSuccessfulContractCall: true, event }); + expect(extractContractErrorCode({ diagnosticEvents: [diag] })).toBeNull(); + }); + + it('returns null for a non-contract failure (network/auth error text)', () => { + expect( + extractContractErrorCode({ message: 'HostError: Error(Auth, InvalidAction)' }), + ).toBeNull(); + expect(extractContractErrorCode({ message: 'fetch failed: ECONNREFUSED' })).toBeNull(); + expect(extractContractErrorCode({})).toBeNull(); + }); + + it('prefers diagnostic events over the message when both are present', () => { + const code = extractContractErrorCode({ + message: 'HostError: Error(Contract, #1)', + diagnosticEvents: [diagnosticEventWithContractCode(2)], + }); + expect(code).toBe(2); + }); +}); + +describe('VaultContractError', () => { + it('marks a known code with its variant name', () => { + const err = new VaultContractError(6, { some: 'raw' }); + expect(err.code).toBe(6); + expect(err.codeName).toBe('InsufficientAvailable'); + expect(err.known).toBe(true); + expect(err.raw).toEqual({ some: 'raw' }); + expect(err.message).toContain('InsufficientAvailable'); + }); + + it('preserves and flags an unmapped/unknown code rather than swallowing it', () => { + const err = new VaultContractError(999, 'raw-value'); + expect(err.code).toBe(999); + expect(err.codeName).toBeUndefined(); + expect(err.known).toBe(false); + expect(err.raw).toBe('raw-value'); + expect(err.message).toContain('999'); + }); +}); + +describe('error builders', () => { + it('errorFromSimulation returns a VaultContractError for a contract revert', () => { + const sim = { error: 'HostError: Error(Contract, #18)', events: [] } as any; + const err = errorFromSimulation(sim); + expect(err).toBeInstanceOf(VaultContractError); + expect((err as VaultContractError).code).toBe(18); + expect((err as VaultContractError).codeName).toBe('TooManyActiveTasks'); + }); + + it('errorFromSimulation returns a plain Error for a non-contract simulation failure', () => { + const sim = { error: 'HostError: Error(Auth, InvalidAction)', events: [] } as any; + const err = errorFromSimulation(sim); + expect(err).not.toBeInstanceOf(VaultContractError); + expect(err.message).toContain('Simulation failed'); + }); + + it('errorFromSendResponse returns a VaultContractError when diagnostic events carry the code', () => { + const response = { + status: 'ERROR', + diagnosticEvents: [diagnosticEventWithContractCode(9)], + } as any; + const err = errorFromSendResponse(response); + expect(err).toBeInstanceOf(VaultContractError); + expect((err as VaultContractError).code).toBe(9); + expect((err as VaultContractError).codeName).toBe('TaskAlreadyCompleted'); + }); + + it('errorFromSendResponse returns a plain Error when there is no contract code', () => { + const response = { status: 'ERROR', errorResult: { foo: 'bar' } } as any; + const err = errorFromSendResponse(response); + expect(err).not.toBeInstanceOf(VaultContractError); + expect(err.message).toContain('Send failed'); + }); + + it('errorFromFailedTransaction returns a VaultContractError when diagnostic events carry the code', () => { + const result = { + status: 'FAILED', + diagnosticEventsXdr: [diagnosticEventWithContractCode(24)], + } as any; + const err = errorFromFailedTransaction('deadbeef', result); + expect(err).toBeInstanceOf(VaultContractError); + expect((err as VaultContractError).code).toBe(24); + expect((err as VaultContractError).codeName).toBe('ReleaseConflict'); + }); + + it('errorFromFailedTransaction returns a plain Error when no contract code is present', () => { + const result = { status: 'FAILED' } as any; + const err = errorFromFailedTransaction('deadbeef', result); + expect(err).not.toBeInstanceOf(VaultContractError); + expect(err.message).toContain('deadbeef'); + }); +}); diff --git a/packages/orchestrator/src/vault-errors.ts b/packages/orchestrator/src/vault-errors.ts new file mode 100644 index 0000000..cf924a4 --- /dev/null +++ b/packages/orchestrator/src/vault-errors.ts @@ -0,0 +1,156 @@ +/** + * Typed mirror of the CleverVault contract's `VaultError` enum + * (contracts/agent-vault/src/lib.rs), plus the plumbing to recover a + * numeric contract error code from a failed Soroban invocation and turn it + * into a typed, branchable error. + * + * `VaultErrorCode` is kept in sync with the Rust source by + * `vault-errors.test.ts`, which parses `lib.rs` directly and fails if the + * two diverge — see docs/development.md. + */ + +import type { rpc as SorobanRpc } from '@stellar/stellar-sdk'; +import { xdr } from '@stellar/stellar-sdk'; + +// ── VaultError mirror ──────────────────────────────────────────────────────── + +export enum VaultErrorCode { + AlreadyInitialized = 1, + Unauthorized = 2, + ContractPaused = 3, + AssetNotSupported = 4, + InsufficientBalance = 5, + InsufficientAvailable = 6, + ActiveTaskExists = 7, + TaskNotFound = 8, + TaskAlreadyCompleted = 9, + TaskNotStale = 10, + InvalidAmount = 11, + ExceedsPlanCost = 12, + AssetMismatch = 13, + OrchestratorNotRegistered = 14, + OrchestratorAlreadyRegistered = 15, + NotYourTask = 16, + NotYourOrchestrator = 17, + TooManyActiveTasks = 18, + TaskDisputed = 19, + NotDisputeResolver = 20, + DisputeSplitMismatch = 21, + DisputeResolverNotSet = 22, + TaskNotDisputed = 23, + ReleaseConflict = 24, + TooManyStepReleases = 25, +} + +/** + * Thrown when a CleverVault contract invocation reverts with a `VaultError`. + * + * `code` and `raw` are always present. `codeName`/`known` reflect whether + * `code` matches a variant this client knows about — an unmapped code (e.g. + * a contract deployed with newer error variants than this client's mirror) + * still produces a `VaultContractError`, never a swallowed/opaque failure. + */ +export class VaultContractError extends Error { + readonly code: number; + readonly codeName: string | undefined; + readonly known: boolean; + /** The original simulation/send/transaction response this was extracted from. */ + readonly raw: unknown; + + constructor(code: number, raw: unknown) { + const codeName = VaultErrorCode[code] as string | undefined; + super(codeName ? `VaultError.${codeName} (code ${code})` : `VaultError: unknown code ${code}`); + this.name = 'VaultContractError'; + this.code = code; + this.codeName = codeName; + this.known = codeName !== undefined; + this.raw = raw; + } +} + +// ── Extracting the numeric code from a failed invocation ──────────────────── + +const SIMULATION_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/; + +function contractCodeFromScVal(val: xdr.ScVal): number | null { + if (val.switch().name !== 'scvError') return null; + const scError = val.error(); + if (scError.switch().name !== 'sceContract') return null; + return scError.contractCode(); +} + +/** + * Diagnostic events carry the structured `Error(Contract, #N)` value the + * host raised. This is the reliable path for the submit/poll flow, where — + * unlike simulation — there is no human-readable error string. + */ +function contractCodeFromDiagnosticEvents( + events: readonly xdr.DiagnosticEvent[] | undefined, +): number | null { + if (!events) return null; + for (const diag of events) { + try { + const body = diag.event().body(); + if (body.switch() !== 0) continue; // only ContractEventBody v0 exists today + const code = contractCodeFromScVal(body.v0().data()); + if (code !== null) return code; + } catch { + // Unexpected/malformed event shape — skip it rather than fail extraction. + } + } + return null; +} + +/** + * Simulation failures carry a human-readable `HostError` string (no + * diagnostic events are attached to the error response itself); the + * contract error, if any, appears as `Error(Contract, #N)` in that text. + * A non-contract failure (network error, auth failure, malformed + * transaction, ...) never matches this pattern, so it can't be + * misclassified as a VaultError. + */ +function contractCodeFromMessage(message: string | undefined): number | null { + if (!message) return null; + const match = SIMULATION_ERROR_PATTERN.exec(message); + return match ? Number(match[1]) : null; +} + +/** + * Extracts the numeric VaultError discriminant from a failed Soroban + * invocation, or `null` if this wasn't a contract revert at all. + */ +export function extractContractErrorCode(source: { + message?: string; + diagnosticEvents?: readonly xdr.DiagnosticEvent[]; +}): number | null { + return ( + contractCodeFromDiagnosticEvents(source.diagnosticEvents) ?? + contractCodeFromMessage(source.message) + ); +} + +// ── Building the right Error for each failure shape ────────────────────────── + +export function errorFromSimulation(sim: SorobanRpc.Api.SimulateTransactionErrorResponse): Error { + const code = extractContractErrorCode({ message: sim.error, diagnosticEvents: sim.events }); + return code !== null + ? new VaultContractError(code, sim) + : new Error(`Simulation failed: ${sim.error}`); +} + +export function errorFromSendResponse(response: SorobanRpc.Api.SendTransactionResponse): Error { + const code = extractContractErrorCode({ diagnosticEvents: response.diagnosticEvents }); + return code !== null + ? new VaultContractError(code, response) + : new Error(`Send failed: ${JSON.stringify(response.errorResult)}`); +} + +export function errorFromFailedTransaction( + hash: string, + result: SorobanRpc.Api.GetFailedTransactionResponse, +): Error { + const code = extractContractErrorCode({ diagnosticEvents: result.diagnosticEventsXdr }); + return code !== null + ? new VaultContractError(code, result) + : new Error(`Transaction failed: ${hash}`); +}