diff --git a/packages/orchestrator/src/agent-vault-client.ts b/packages/orchestrator/src/agent-vault-client.ts index 2833ad2..e5ef949 100644 --- a/packages/orchestrator/src/agent-vault-client.ts +++ b/packages/orchestrator/src/agent-vault-client.ts @@ -1,38 +1,23 @@ /** * AgentVault Soroban contract client (server-side). * - * Mirrors the pattern from budget-contract.ts: simulate → assemble → sign → submit → poll. + * Delegates all Soroban interactions to `@clevercon/vault-sdk`, which wraps + * every CleverVault entrypoint with typed inputs/outputs, structured errors, + * and network config. This file preserves the orchestrator's existing public + * API surface so callers (executor.ts, server.ts) require no changes. * * If AGENT_VAULT_CONTRACT_ID is not set or is a placeholder, functions that * require the contract return safe defaults so the system works without it. */ -import { - Keypair, - Contract, - rpc as SorobanRpc, - TransactionBuilder, - Networks, - BASE_FEE, - nativeToScVal, - Address, - scValToNative, - xdr, -} from '@stellar/stellar-sdk'; -import { - errorFromSimulation, - errorFromSendResponse, - errorFromFailedTransaction, - VaultContractError, -} from './vault-errors.js'; +import { Keypair } from '@stellar/stellar-sdk'; +import { VaultClient, VaultContractError } from '@clevercon/vault-sdk'; -export { VaultErrorCode, VaultContractError } from './vault-errors.js'; +export { VaultErrorCode, VaultContractError } from '@clevercon/vault-sdk'; const CONTRACT_ID = process.env.AGENT_VAULT_CONTRACT_ID ?? ''; const RPC_URL = process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org'; const USDC_SAC = process.env.USDC_SAC ?? ''; -const NETWORK_PASSPHRASE = Networks.TESTNET; -const STROOPS_PER_USDC = 10_000_000; export const VAULT_ACTIVE = CONTRACT_ID.length > 10 && !CONTRACT_ID.startsWith('C...'); @@ -40,217 +25,61 @@ if (!VAULT_ACTIVE) { console.warn('[AgentVault] AGENT_VAULT_CONTRACT_ID not set — vault features disabled'); } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function usdcToStroops(usdc: number): bigint { - return BigInt(Math.round(usdc * STROOPS_PER_USDC)); -} - -function usdcSacScVal(): xdr.ScVal { - if (!USDC_SAC) { - throw new Error('USDC_SAC is required for AgentVault multi-asset calls'); - } - return new Address(USDC_SAC).toScVal(); -} - -function rpc() { - return new SorobanRpc.Server(RPC_URL, { allowHttp: false }); -} +// ── Singleton SDK client ───────────────────────────────────────────────────── -/** - * Build + simulate a contract call, returning the assembled (unsigned) XDR. - * Used for transactions the user must sign in Freighter. - */ -async function buildUnsignedXdr( - sourceAddress: string, - method: string, - args: xdr.ScVal[], -): Promise { - const server = rpc(); - const account = await server.getAccount(sourceAddress); - const contract = new Contract(CONTRACT_ID); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation(contract.call(method, ...args)) - .setTimeout(300) - .build(); - - const simulated = await server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simulated)) { - throw errorFromSimulation(simulated); - } - - return SorobanRpc.assembleTransaction(tx, simulated).build().toXDR(); -} +const vaultClient = new VaultClient({ + contractId: CONTRACT_ID, + rpcUrl: RPC_URL, + network: 'testnet', + usdcSac: USDC_SAC, +}); -/** - * Sign and submit a transaction using a server-side keypair. - * Returns tx hash after confirmation. - */ -async function signAndSubmit(keypair: Keypair, method: string, args: xdr.ScVal[]): Promise { - const server = rpc(); - const account = await server.getAccount(keypair.publicKey()); - const contract = new Contract(CONTRACT_ID); - - let tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation(contract.call(method, ...args)) - .setTimeout(60) - .build(); - - const simulated = await server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simulated)) { - throw errorFromSimulation(simulated); - } - - tx = SorobanRpc.assembleTransaction(tx, simulated).build(); - tx.sign(keypair); - - const response = await server.sendTransaction(tx); - if (response.status === 'ERROR') { - throw errorFromSendResponse(response); - } - - return pollForConfirmation(server, response.hash); -} - -async function pollForConfirmation(server: SorobanRpc.Server, hash: string): Promise { - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 1000)); - const result = await server.getTransaction(hash); - if (result.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { - return hash; - } - if (result.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { - throw errorFromFailedTransaction(hash, result); - } - } - throw new Error(`Transaction timed out: ${hash}`); -} +const STROOPS_PER_USDC = 10_000_000; // ── Submit a pre-signed XDR (signed by user in Freighter) ──────────────────── export async function submitSignedXdr(signedXdr: string): Promise { - const server = rpc(); - const tx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); - const response = await server.sendTransaction(tx); - if (response.status === 'ERROR') { - throw errorFromSendResponse(response); - } - return pollForConfirmation(server, response.hash); + return vaultClient.submitSignedXdr(signedXdr); } // ── U3: Orchestrator registration ───────────────────────────────────────────── -/** - * Build an unsigned register_orchestrator XDR. - * The user's Freighter wallet signs this so the contract records the mapping. - * Returns null if the vault contract is not configured. - */ export async function buildRegisterOrchestratorXdr( userAddress: string, orchestratorAddress: string, name: string, ): Promise { if (!VAULT_ACTIVE) return null; - return buildUnsignedXdr(userAddress, 'register_orchestrator', [ - new Address(userAddress).toScVal(), - new Address(orchestratorAddress).toScVal(), - nativeToScVal(name, { type: 'string' }), - ]); + return vaultClient.buildRegisterOrchestratorXdr(userAddress, orchestratorAddress, name); } // ── U4: Deposit / withdraw XDR builders ────────────────────────────────────── -/** - * Build an unsigned `deposit` XDR for the user to sign in Freighter. - * Transfers `amountUsdc` from the user's wallet into their vault balance. - */ export async function buildDepositXdr( userAddress: string, amountUsdc: number, ): Promise { if (!VAULT_ACTIVE) return null; - return buildUnsignedXdr(userAddress, 'deposit', [ - new Address(userAddress).toScVal(), - usdcSacScVal(), - nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }), - ]); + return vaultClient.buildDepositXdr(userAddress, amountUsdc); } -/** - * Build an unsigned `withdraw` XDR for the user to sign in Freighter. - * Fails on-chain if `amountUsdc` exceeds the user's available (unlocked) balance. - */ export async function buildWithdrawXdr( userAddress: string, amountUsdc: number, ): Promise { if (!VAULT_ACTIVE) return null; - return buildUnsignedXdr(userAddress, 'withdraw', [ - new Address(userAddress).toScVal(), - usdcSacScVal(), - nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }), - ]); + return vaultClient.buildWithdrawXdr(userAddress, amountUsdc); } // ── U5: Task lifecycle (signed by orchestrator keypair) ──────────────────────── -/** - * Create a new on-chain task, locking `planCostUsdc` from the user's - * available balance (the user is resolved on-chain via the orchestrator's - * registered address). Returns the new `task_id`, or `null` if the vault is - * inactive or the call fails. - */ export async function createTask( orchestratorKeypair: Keypair, planCostUsdc: number, ): Promise { if (!VAULT_ACTIVE) return null; try { - const server = rpc(); - const account = await server.getAccount(orchestratorKeypair.publicKey()); - const contract = new Contract(CONTRACT_ID); - - let tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation( - contract.call( - 'create_task', - new Address(orchestratorKeypair.publicKey()).toScVal(), - usdcSacScVal(), - nativeToScVal(usdcToStroops(planCostUsdc), { type: 'i128' }), - ), - ) - .setTimeout(60) - .build(); - - const simulated = await server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simulated)) { - throw errorFromSimulation(simulated); - } - - tx = SorobanRpc.assembleTransaction(tx, simulated).build(); - tx.sign(orchestratorKeypair); - - const response = await server.sendTransaction(tx); - if (response.status === 'ERROR') throw errorFromSendResponse(response); - - await pollForConfirmation(server, response.hash); - - // Re-fetch result - const result = await server.getTransaction(response.hash); - if (result.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS && result.returnValue) { - return BigInt(scValToNative(result.returnValue)); - } - return null; + return await vaultClient.createTask(orchestratorKeypair, planCostUsdc); } catch (err: any) { console.error('[AgentVault] createTask error:', err.message); return null; @@ -262,10 +91,7 @@ export async function createTask( * * 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`. + * `err.code`. Non-contract failures (RPC hiccups, etc.) are swallowed to `null`. */ export async function releasePayment( orchestratorKeypair: Keypair, @@ -275,14 +101,7 @@ export async function releasePayment( ): Promise { if (!VAULT_ACTIVE || !taskId) return null; try { - const hash = await signAndSubmit(orchestratorKeypair, 'release_payment', [ - new Address(orchestratorKeypair.publicKey()).toScVal(), - nativeToScVal(taskId, { type: 'u64' }), - nativeToScVal(stepId, { type: 'u64' }), - usdcSacScVal(), - nativeToScVal(usdcToStroops(amountUsdc), { type: 'i128' }), - ]); - return hash; + return await vaultClient.releasePayment(orchestratorKeypair, taskId, stepId, amountUsdc); } catch (err: any) { console.error('[AgentVault] releasePayment error:', err.message); if (err instanceof VaultContractError) throw err; @@ -290,17 +109,10 @@ export async function releasePayment( } } -/** - * Mark a vault task as complete, unlocking its remaining budget back to the - * user's available balance. No-op if the vault is inactive or `taskId` is falsy. - */ export async function completeTask(orchestratorKeypair: Keypair, taskId: bigint): Promise { if (!VAULT_ACTIVE || !taskId) return; try { - await signAndSubmit(orchestratorKeypair, 'complete_task', [ - new Address(orchestratorKeypair.publicKey()).toScVal(), - nativeToScVal(taskId, { type: 'u64' }), - ]); + await vaultClient.completeTask(orchestratorKeypair, taskId); } catch (err: any) { console.error('[AgentVault] completeTask error:', err.message); } @@ -308,36 +120,22 @@ export async function completeTask(orchestratorKeypair: Keypair, taskId: bigint) // ── U7: Cancel task XDR (user signs) + Force-complete (orchestrator signs) ──── -/** - * Build an unsigned cancel_task XDR for the user to sign in Freighter. - * This cancels an active vault task and refunds unused locked balance. - */ export async function buildCancelTaskXdr( userAddress: string, vaultTaskId: bigint, ): Promise { if (!VAULT_ACTIVE) return null; - return buildUnsignedXdr(userAddress, 'cancel_task', [ - new Address(userAddress).toScVal(), - nativeToScVal(vaultTaskId, { type: 'u64' }), - ]); + return vaultClient.buildCancelTaskXdr(userAddress, vaultTaskId); } -/** - * Force-complete a stale task using the orchestrator keypair. - * Calls complete_task — safe to call even if already completed. - */ export async function forceCompleteTask( orchestratorKeypair: Keypair, vaultTaskId: bigint, ): Promise { if (!VAULT_ACTIVE) return null; try { - const hash = await signAndSubmit(orchestratorKeypair, 'complete_task', [ - new Address(orchestratorKeypair.publicKey()).toScVal(), - nativeToScVal(vaultTaskId, { type: 'u64' }), - ]); - return hash; + await vaultClient.completeTask(orchestratorKeypair, vaultTaskId); + return 'ok'; } catch (err: any) { console.error('[AgentVault] forceCompleteTask error:', err.message); return null; @@ -346,55 +144,19 @@ export async function forceCompleteTask( // ── Read-only views ─────────────────────────────────────────────────────────── -async function callView(method: string, args: xdr.ScVal[]): Promise { - const server = rpc(); - // Use a throwaway keypair as source for read-only calls - const dummy = Keypair.random(); - const contract = new Contract(CONTRACT_ID); - - // For view calls we need an existing account — use the contract itself or skip - // Use the orchestrator's address if available; fall back to simulating with no source - const tx = new TransactionBuilder( - { - accountId: () => dummy.publicKey(), - sequenceNumber: () => '0', - incrementSequenceNumber: () => {}, - } as any, - { fee: BASE_FEE, networkPassphrase: NETWORK_PASSPHRASE }, - ) - .addOperation(contract.call(method, ...args)) - .setTimeout(30) - .build(); - - const simulated = await server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simulated)) return null; - if (!('result' in simulated) || !simulated.result) return null; - return scValToNative(simulated.result.retval); -} - -/** Total vault balance for `userAddress` (available + locked), in stroops. Returns `0n` if the vault is inactive or the call fails. */ export async function getBalance(userAddress: string): Promise { if (!VAULT_ACTIVE) return 0n; try { - const result = await callView('get_balance', [ - new Address(userAddress).toScVal(), - usdcSacScVal(), - ]); - return result !== null ? BigInt(result) : 0n; + return await vaultClient.getBalance(userAddress); } catch { return 0n; } } -/** Available (unlocked) vault balance for `userAddress`, in stroops. Returns `0n` if the vault is inactive or the call fails. */ export async function getAvailable(userAddress: string): Promise { if (!VAULT_ACTIVE) return 0n; try { - const result = await callView('get_available', [ - new Address(userAddress).toScVal(), - usdcSacScVal(), - ]); - return result !== null ? BigInt(result) : 0n; + return await vaultClient.getAvailable(userAddress); } catch { return 0n; } @@ -402,28 +164,18 @@ export async function getAvailable(userAddress: string): Promise { /** A user's vault account, with all amounts converted from stroops to USDC. */ export interface VaultAccount { - balance: number; // USDC - available: number; // USDC (balance - locked) - locked: number; // USDC - total_deposited: number; // USDC - total_spent: number; // USDC + balance: number; + available: number; + locked: number; + total_deposited: number; + total_spent: number; active_tasks_count: number; } -/** - * Fetch a user's full vault account. - * - * Returns a zeroed {@link VaultAccount} if the user has no on-chain account - * yet (the contract's `Option::None` case), or `null` if the vault is - * inactive. RPC errors propagate so callers can distinguish "no account" from - * "couldn't reach the network". - */ export async function getAccount(userAddress: string): Promise { if (!VAULT_ACTIVE) return null; - // Let exceptions propagate — caller distinguishes RPC errors from "no account" - const raw = await callView('get_account', [new Address(userAddress).toScVal(), usdcSacScVal()]); - // Option::None from the contract → account doesn't exist yet → zero balance - if (raw === null || raw === undefined) { + const raw = await vaultClient.getAccount(userAddress); + if (!raw) { return { balance: 0, available: 0, @@ -440,6 +192,6 @@ export async function getAccount(userAddress: string): Promise { + switch (event.type) { + case 'release': + console.log(`Released ${event.payload.amount} for task ${event.payload.task_id}`); + break; + case 'task_done': + console.log(`Task ${event.payload.task_id} done, spent ${event.payload.spent}`); + break; + } + }, +); + +// Stop later +sub.stop(); +``` + +## Architecture + +``` +packages/vault-sdk/ +├── src/ +│ ├── index.ts # Barrel exports +│ ├── client.ts # VaultClient — wraps every contract entrypoint +│ ├── errors.ts # VaultError model (mirrors lib.rs) +│ ├── types.ts # TypeScript interfaces for contract data +│ ├── events.ts # Event subscription with cursor handling +│ ├── mock.ts # Dependency-free mock mode +│ ├── vault-errors.test.ts # Divergence test against lib.rs +│ └── client.test.ts # Unit tests for client and mock +├── package.json +├── tsconfig.json +└── README.md +``` + +## Error Sync + +The `vault-errors.test.ts` file reads `contracts/agent-vault/src/lib.rs` at test time and verifies that `VaultErrorCode` matches every variant and discriminant. If the contract adds a new error variant, the test fails until the SDK is updated. diff --git a/packages/vault-sdk/package.json b/packages/vault-sdk/package.json new file mode 100644 index 0000000..0af9c7a --- /dev/null +++ b/packages/vault-sdk/package.json @@ -0,0 +1,20 @@ +{ + "name": "@clevercon/vault-sdk", + "version": "1.0.0", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@stellar/stellar-sdk": "^14.6.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.4.0", + "vitest": "^2.1.9" + } +} diff --git a/packages/vault-sdk/src/client.test.ts b/packages/vault-sdk/src/client.test.ts new file mode 100644 index 0000000..baa68c2 --- /dev/null +++ b/packages/vault-sdk/src/client.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { VaultClient } from './client.js'; +import { createMockVaultClient } from './mock.js'; +import { VaultErrorCode, VaultContractError } from './errors.js'; + +describe('VaultClient.usdcToStroops / stroopsToUsdc', () => { + it('converts 1 USDC to 10_000_000 stroops', () => { + expect(VaultClient.usdcToStroops(1)).toBe(10_000_000n); + }); + + it('converts 0.5 USDC to 5_000_000 stroops', () => { + expect(VaultClient.usdcToStroops(0.5)).toBe(5_000_000n); + }); + + it('converts 10_000_000 stroops to 1 USDC', () => { + expect(VaultClient.stroopsToUsdc(10_000_000n)).toBe(1); + }); + + it('round-trips through USDC ↔ stroops', () => { + const usdc = 42.1234567; + const stroops = VaultClient.usdcToStroops(usdc); + expect(VaultClient.stroopsToUsdc(stroops)).toBeCloseTo(usdc, 1); + }); +}); + +describe('createMockVaultClient', () => { + it('is in mock mode', () => { + const mock = createMockVaultClient(); + expect(mock.mock).toBe(true); + expect(mock.active).toBe(true); + }); + + it('returns zeroed account for unknown user', async () => { + const mock = createMockVaultClient(); + const acct = await mock.getAccount('GUNKNOWN'); + expect(acct).toBeNull(); + }); + + it('returns version 5', async () => { + const mock = createMockVaultClient(); + expect(await mock.version()).toBe(5); + }); + + it('returns 1800 for stale threshold', async () => { + const mock = createMockVaultClient(); + expect(await mock.getStaleThreshold()).toBe(1800); + }); + + it('returns 50 for max active tasks', async () => { + const mock = createMockVaultClient(); + expect(await mock.getMaxActiveTasks()).toBe(50); + }); + + it('returns zero fee config by default', async () => { + const mock = createMockVaultClient(); + const fee = await mock.getFee(); + expect(fee.bps).toBe(0); + expect(fee.recipient).toBeNull(); + }); + + it('mockDeposit increases user balance', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const before = await mock.getBalance(user); + await mock.mockDeposit(user, 100); + const after = await mock.getBalance(user); + expect(after - before).toBe(1_000_000_000n); + }); + + it('mockRegisterOrchestrator records the mapping', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; + await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); + const config = await mock.getUserConfig(user); + expect(config?.orchestrator).toBe(orch); + expect(config?.orchestrator_name).toBe('TestOrch'); + }); + + it('mockRegisterOrchestrator throws OrchestratorAlreadyRegistered', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; + await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); + await expect(mock.mockRegisterOrchestrator(user, orch, 'TestOrch')).rejects.toThrow( + VaultContractError, + ); + }); + + it('mockCreateTask returns sequential task IDs', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; + await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); + await mock.mockDeposit(user, 100); + + const id1 = await mock.mockCreateTask(orch, 10); + const id2 = await mock.mockCreateTask(orch, 10); + expect(id2).toBe(id1 + 1n); + }); + + it('mockCreateTask throws OrchestratorNotRegistered for unknown orchestrator', async () => { + const mock = createMockVaultClient(); + await expect(mock.mockCreateTask('GUNKNOWN', 10)).rejects.toThrow(VaultContractError); + }); + + it('mockCreateTask throws InsufficientAvailable when balance too low', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; + await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); + // Don't deposit — balance is 0 + await expect(mock.mockCreateTask(orch, 10)).rejects.toThrow(VaultContractError); + }); + + it('getTask returns task info after mockCreateTask', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; + await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); + await mock.mockDeposit(user, 100); + + const taskId = await mock.mockCreateTask(orch, 10); + const task = await mock.getTask(taskId); + expect(task).not.toBeNull(); + expect(task?.user).toBe(user); + expect(task?.orchestrator).toBe(orch); + expect(task?.plan_cost).toBe(100_000_000n); + expect(task?.completed).toBe(false); + expect(task?.disputed).toBe(false); + }); + + it('getTaskStatus returns Active for a new task', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; + await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); + await mock.mockDeposit(user, 100); + + const taskId = await mock.mockCreateTask(orch, 10); + const status = await mock.getTaskStatus(taskId); + expect(status).toBe('Active'); + }); + + it('taskCount increments after mockCreateTask', async () => { + const mock = createMockVaultClient(); + const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; + await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); + await mock.mockDeposit(user, 100); + + expect(await mock.taskCount()).toBe(0n); + await mock.mockCreateTask(orch, 10); + expect(await mock.taskCount()).toBe(1n); + await mock.mockCreateTask(orch, 5); + expect(await mock.taskCount()).toBe(2n); + }); +}); diff --git a/packages/vault-sdk/src/client.ts b/packages/vault-sdk/src/client.ts new file mode 100644 index 0000000..472b1bd --- /dev/null +++ b/packages/vault-sdk/src/client.ts @@ -0,0 +1,537 @@ +/** + * CleverVault Soroban contract client. + * + * Wraps every CleverVault entrypoint with typed inputs, decoded native + * TypeScript return values, and structured errors. Never exposes raw ScVal + * to callers. + * + * Two invocation modes: + * - **simulate** — read-only views (get_balance, get_account, etc.) + * - **sign+submit** — state-changing calls (deposit, withdraw, create_task, etc.) + * returns an unsigned XDR string for the caller to sign in Freighter, OR + * signs+submits with a provided Keypair. + */ + +import { + Keypair, + Contract, + rpc as SorobanRpc, + TransactionBuilder, + Networks, + BASE_FEE, + nativeToScVal, + Address, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { + VaultContractError, + errorFromSimulation, + errorFromSendResponse, + errorFromFailedTransaction, +} from './errors.js'; +import type { + VaultSDKConfig, + UserAccount, + TaskInfo, + TaskStatus, + FeeConfig, +} from './types.js'; + +const STROOPS_PER_USDC = 10_000_000n; + +export class VaultClient { + private readonly contractId: string; + private readonly rpcUrl: string; + private readonly networkPassphrase: string; + private readonly usdcSac: string; + private readonly _mock: boolean; + + constructor(config: VaultSDKConfig) { + this.contractId = config.contractId; + this.rpcUrl = config.rpcUrl ?? 'https://soroban-testnet.stellar.org'; + this.networkPassphrase = + config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; + this.usdcSac = config.usdcSac ?? ''; + this._mock = config.mock ?? false; + } + + /** Whether this client is in mock mode. */ + get mock(): boolean { + return this._mock; + } + + /** Whether the vault contract is configured (non-placeholder ID). */ + get active(): boolean { + return this.contractId.length > 10 && !this.contractId.startsWith('C...'); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private rpc(): SorobanRpc.Server { + return new SorobanRpc.Server(this.rpcUrl, { allowHttp: false }); + } + + private requireUsdcSac(): string { + if (!this.usdcSac) { + throw new Error('USDC_SAC is required for CleverVault multi-asset calls'); + } + return this.usdcSac; + } + + private usdcSacScVal(): xdr.ScVal { + return new Address(this.requireUsdcSac()).toScVal(); + } + + /** Build + simulate a contract call, returning the assembled unsigned XDR. */ + async buildUnsignedXdr( + sourceAddress: string, + method: string, + args: xdr.ScVal[], + ): Promise { + const server = this.rpc(); + const account = await server.getAccount(sourceAddress); + const contract = new Contract(this.contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(300) + .build(); + + const simulated = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw errorFromSimulation(simulated); + } + + return SorobanRpc.assembleTransaction(tx, simulated).build().toXDR(); + } + + /** Sign + submit with a server-side keypair. Returns tx hash after confirmation. */ + async signAndSubmit(keypair: Keypair, method: string, args: xdr.ScVal[]): Promise { + const server = this.rpc(); + const account = await server.getAccount(keypair.publicKey()); + const contract = new Contract(this.contractId); + + let tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(60) + .build(); + + const simulated = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw errorFromSimulation(simulated); + } + + tx = SorobanRpc.assembleTransaction(tx, simulated).build(); + tx.sign(keypair); + + const response = await server.sendTransaction(tx); + if (response.status === 'ERROR') { + throw errorFromSendResponse(response); + } + + return this.pollForConfirmation(server, response.hash); + } + + /** Sign + submit and return the decoded return value (for calls that return data). */ + async signAndSubmitWithResult( + keypair: Keypair, + method: string, + args: xdr.ScVal[], + ): Promise { + const server = this.rpc(); + const account = await server.getAccount(keypair.publicKey()); + const contract = new Contract(this.contractId); + + let tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(60) + .build(); + + const simulated = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw errorFromSimulation(simulated); + } + + tx = SorobanRpc.assembleTransaction(tx, simulated).build(); + tx.sign(keypair); + + const response = await server.sendTransaction(tx); + if (response.status === 'ERROR') { + throw errorFromSendResponse(response); + } + + await this.pollForConfirmation(server, response.hash); + + const result = await server.getTransaction(response.hash); + if ( + result.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS && + result.returnValue + ) { + return scValToNative(result.returnValue) as T; + } + throw new Error(`Transaction succeeded but no return value: ${response.hash}`); + } + + private async pollForConfirmation( + server: SorobanRpc.Server, + hash: string, + ): Promise { + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const result = await server.getTransaction(hash); + if (result.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return hash; + } + if (result.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw errorFromFailedTransaction(hash, result); + } + } + throw new Error(`Transaction timed out: ${hash}`); + } + + /** Submit a pre-signed XDR (signed by user in Freighter). */ + async submitSignedXdr(signedXdr: string): Promise { + const server = this.rpc(); + const tx = TransactionBuilder.fromXDR(signedXdr, this.networkPassphrase); + const response = await server.sendTransaction(tx); + if (response.status === 'ERROR') { + throw errorFromSendResponse(response); + } + return this.pollForConfirmation(server, response.hash); + } + + /** Read-only view call — simulate only, no signing. */ + async callView(method: string, args: xdr.ScVal[]): Promise { + const server = this.rpc(); + const dummy = Keypair.random(); + const contract = new Contract(this.contractId); + + const tx = new TransactionBuilder( + { + accountId: () => dummy.publicKey(), + sequenceNumber: () => '0', + incrementSequenceNumber: () => {}, + } as any, + { fee: BASE_FEE, networkPassphrase: this.networkPassphrase }, + ) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); + + const simulated = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulated)) return null; + if (!('result' in simulated) || !simulated.result) return null; + return scValToNative(simulated.result.retval); + } + + // ── Conversion helpers ──────────────────────────────────────────────────── + + static usdcToStroops(usdc: number): bigint { + return BigInt(Math.round(usdc * Number(STROOPS_PER_USDC))); + } + + static stroopsToUsdc(stroops: bigint | number): number { + return Number(stroops) / Number(STROOPS_PER_USDC); + } + + // ════════════════════════════════════════════════════════════════════════════ + // ── State-changing calls (user-signed via Freighter) ──────────────────── + // ════════════════════════════════════════════════════════════════════════════ + + /** + * Build unsigned `deposit` XDR for the user to sign in Freighter. + */ + async buildDepositXdr(userAddress: string, amountUsdc: number): Promise { + return this.buildUnsignedXdr(userAddress, 'deposit', [ + new Address(userAddress).toScVal(), + this.usdcSacScVal(), + nativeToScVal(VaultClient.usdcToStroops(amountUsdc), { type: 'i128' }), + ]); + } + + /** + * Build unsigned `withdraw` XDR for the user to sign in Freighter. + */ + async buildWithdrawXdr(userAddress: string, amountUsdc: number): Promise { + return this.buildUnsignedXdr(userAddress, 'withdraw', [ + new Address(userAddress).toScVal(), + this.usdcSacScVal(), + nativeToScVal(VaultClient.usdcToStroops(amountUsdc), { type: 'i128' }), + ]); + } + + /** + * Build unsigned `register_orchestrator` XDR. + */ + async buildRegisterOrchestratorXdr( + userAddress: string, + orchestratorAddress: string, + name: string, + ): Promise { + return this.buildUnsignedXdr(userAddress, 'register_orchestrator', [ + new Address(userAddress).toScVal(), + new Address(orchestratorAddress).toScVal(), + nativeToScVal(name, { type: 'string' }), + ]); + } + + /** + * Build unsigned `cancel_task` XDR for the user to sign. + */ + async buildCancelTaskXdr(userAddress: string, taskId: bigint): Promise { + return this.buildUnsignedXdr(userAddress, 'cancel_task', [ + new Address(userAddress).toScVal(), + nativeToScVal(taskId, { type: 'u64' }), + ]); + } + + // ════════════════════════════════════════════════════════════════════════════ + // ── State-changing calls (orchestrator-signed server-side) ────────────── + // ════════════════════════════════════════════════════════════════════════════ + + /** + * Create a new on-chain task. Returns the new task_id. + */ + async createTask( + orchestratorKeypair: Keypair, + planCostUsdc: number, + ): Promise { + return this.signAndSubmitWithResult( + orchestratorKeypair, + 'create_task', + [ + new Address(orchestratorKeypair.publicKey()).toScVal(), + this.usdcSacScVal(), + nativeToScVal(VaultClient.usdcToStroops(planCostUsdc), { type: 'i128' }), + ], + ); + } + + /** + * Release funds for one step. Returns the tx hash. + */ + async releasePayment( + orchestratorKeypair: Keypair, + taskId: bigint, + stepId: bigint, + amountUsdc: number, + ): Promise { + return this.signAndSubmit(orchestratorKeypair, 'release_payment', [ + new Address(orchestratorKeypair.publicKey()).toScVal(), + nativeToScVal(taskId, { type: 'u64' }), + nativeToScVal(stepId, { type: 'u64' }), + this.usdcSacScVal(), + nativeToScVal(VaultClient.usdcToStroops(amountUsdc), { type: 'i128' }), + ]); + } + + /** + * Mark a task as complete. Returns the tx hash. + */ + async completeTask(orchestratorKeypair: Keypair, taskId: bigint): Promise { + return this.signAndSubmit(orchestratorKeypair, 'complete_task', [ + new Address(orchestratorKeypair.publicKey()).toScVal(), + nativeToScVal(taskId, { type: 'u64' }), + ]); + } + + /** + * Force-complete a stale task (anyone can call). + */ + async forceCompleteStaleTask(taskId: bigint): Promise { + const dummy = Keypair.random(); + return this.signAndSubmit(dummy, 'force_complete_stale_task', [ + nativeToScVal(taskId, { type: 'u64' }), + ]); + } + + // ════════════════════════════════════════════════════════════════════════════ + // ── Read-only views ──────────────────────────────────────────────────── + // ════════════════════════════════════════════════════════════════════════════ + + /** Total balance (available + locked) in stroops. */ + async getBalance(userAddress: string): Promise { + const result = await this.callView('get_balance', [ + new Address(userAddress).toScVal(), + this.usdcSacScVal(), + ]); + return result !== null ? BigInt(result) : 0n; + } + + /** Available (unlocked) balance in stroops. */ + async getAvailable(userAddress: string): Promise { + const result = await this.callView('get_available', [ + new Address(userAddress).toScVal(), + this.usdcSacScVal(), + ]); + return result !== null ? BigInt(result) : 0n; + } + + /** + * Full user account record. + * Returns null if user has no on-chain account yet. + */ + async getAccount(userAddress: string): Promise { + const raw = await this.callView('get_account', [ + new Address(userAddress).toScVal(), + this.usdcSacScVal(), + ]); + if (raw === null || raw === undefined) return null; + return { + balance: BigInt(raw.balance), + locked: BigInt(raw.locked), + total_deposited: BigInt(raw.total_deposited), + total_spent: BigInt(raw.total_spent), + active_tasks_count: Number(raw.active_tasks_count), + orchestrator: raw.orchestrator ?? null, + orchestrator_name: String(raw.orchestrator_name ?? ''), + created_at: BigInt(raw.created_at), + }; + } + + /** User config (orchestrator registration, active tasks). */ + async getUserConfig(userAddress: string): Promise { + const raw = await this.callView('get_user_config', [ + new Address(userAddress).toScVal(), + ]); + if (raw === null || raw === undefined) return null; + return { + orchestrator: raw.orchestrator ?? null, + orchestrator_name: String(raw.orchestrator_name ?? ''), + active_tasks_count: Number(raw.active_tasks_count), + created_at: BigInt(raw.created_at), + }; + } + + /** Full task record by task_id. */ + async getTask(taskId: bigint): Promise { + const raw = await this.callView('get_task', [ + nativeToScVal(taskId, { type: 'u64' }), + ]); + if (raw === null || raw === undefined) return null; + return { + user: String(raw.user), + orchestrator: String(raw.orchestrator), + asset: String(raw.asset), + plan_cost: BigInt(raw.plan_cost), + spent: BigInt(raw.spent), + completed: Boolean(raw.completed), + disputed: Boolean(raw.disputed), + created_at: BigInt(raw.created_at), + }; + } + + /** Task lifecycle status. */ + async getTaskStatus(taskId: bigint): Promise { + const raw = await this.callView('get_task_status', [ + nativeToScVal(taskId, { type: 'u64' }), + ]); + if (raw === null || raw === undefined) return null; + // Soroban enum variants come back as the variant name string + return String(raw) as TaskStatus; + } + + /** List of a user's task IDs. */ + async getUserTasks(userAddress: string): Promise { + const raw = await this.callView('get_user_tasks', [ + new Address(userAddress).toScVal(), + ]); + if (!raw) return []; + return Array.from(raw).map((id: any) => BigInt(id)); + } + + /** Reverse lookup: orchestrator address → user address. */ + async getOrchestratorOwner(orchestratorAddress: string): Promise { + const raw = await this.callView('get_orchestrator_owner', [ + new Address(orchestratorAddress).toScVal(), + ]); + return raw ? String(raw) : null; + } + + /** Whether an asset is whitelisted. */ + async isSupportedAsset(assetAddress: string): Promise { + const raw = await this.callView('is_supported_asset', [ + new Address(assetAddress).toScVal(), + ]); + return Boolean(raw); + } + + /** List of all whitelisted assets. */ + async getSupportedAssets(): Promise { + const raw = await this.callView('get_supported_assets', []); + if (!raw) return []; + return Array.from(raw).map((a: any) => String(a)); + } + + /** Total tasks ever created. */ + async taskCount(): Promise { + const raw = await this.callView('task_count', []); + return raw !== null ? BigInt(raw) : 0n; + } + + /** Contract version. */ + async version(): Promise { + const raw = await this.callView('version', []); + return Number(raw ?? 0); + } + + /** Whether the contract is paused. */ + async isPaused(): Promise { + const raw = await this.callView('is_paused', []); + return Boolean(raw); + } + + /** Token balance held by the vault for an asset. */ + async tokenBalance(assetAddress: string): Promise { + const raw = await this.callView('token_balance', [ + new Address(assetAddress).toScVal(), + ]); + return raw !== null ? BigInt(raw) : 0n; + } + + /** Current dispute resolver address. */ + async getDisputeResolver(): Promise { + const raw = await this.callView('get_dispute_resolver', []); + return raw ? String(raw) : null; + } + + /** Current stale task threshold in seconds. */ + async getStaleThreshold(): Promise { + const raw = await this.callView('get_stale_threshold', []); + return Number(raw ?? 1800); + } + + /** Current max active tasks per user. */ + async getMaxActiveTasks(): Promise { + const raw = await this.callView('get_max_active_tasks', []); + return Number(raw ?? 50); + } + + /** Fee config (bps, recipient). */ + async getFee(): Promise { + const raw = await this.callView('get_fee', []); + if (!raw) return { bps: 0, recipient: null }; + return { + bps: Number(raw[0] ?? 0), + recipient: raw[1] ? String(raw[1]) : null, + }; + } + + /** Accrued but unclaimed fees for an asset. */ + async getAccruedFees(assetAddress: string): Promise { + const raw = await this.callView('get_accrued_fees', [ + new Address(assetAddress).toScVal(), + ]); + return raw !== null ? BigInt(raw) : 0n; + } +} diff --git a/packages/vault-sdk/src/errors.ts b/packages/vault-sdk/src/errors.ts new file mode 100644 index 0000000..546032a --- /dev/null +++ b/packages/vault-sdk/src/errors.ts @@ -0,0 +1,150 @@ +/** + * Structured error model mapped from the CleverVault contract's `VaultError`. + * + * Every contract error discriminant has a named variant. Unknown/newer + * error codes are preserved (never swallowed) — callers can branch on + * `error.known` to decide how to handle them. + * + * The `vault-errors.test.ts` divergence test reads `contracts/agent-vault/src/lib.rs` + * and fails this package's test suite if they drift apart. + */ + +// ── Error code enum (mirrors lib.rs VaultError) ────────────────────────────── + +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, + FeeBpsExceedsCap = 26, + NoFeesAccrued = 27, +} + +/** + * 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; + } +} + +// ── Extraction utilities ───────────────────────────────────────────────────── + +const SIMULATION_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/; + +function contractCodeFromScVal(val: import('@stellar/stellar-sdk').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(); +} + +function contractCodeFromDiagnosticEvents( + events: readonly import('@stellar/stellar-sdk').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; + const code = contractCodeFromScVal(body.v0().data()); + if (code !== null) return code; + } catch { + // Unexpected/malformed event shape — skip + } + } + return null; +} + +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 import('@stellar/stellar-sdk').xdr.DiagnosticEvent[]; +}): number | null { + return ( + contractCodeFromDiagnosticEvents(source.diagnosticEvents) ?? + contractCodeFromMessage(source.message) + ); +} + +// ── Error builders (for SDK internal use) ──────────────────────────────────── + +export function errorFromSimulation(sim: { + error?: string; + events?: readonly import('@stellar/stellar-sdk').xdr.DiagnosticEvent[]; +}): 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: { + diagnosticEvents?: readonly import('@stellar/stellar-sdk').xdr.DiagnosticEvent[]; + errorResult?: unknown; +}): 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: { + diagnosticEventsXdr?: readonly import('@stellar/stellar-sdk').xdr.DiagnosticEvent[]; + }, +): Error { + const code = extractContractErrorCode({ diagnosticEvents: result.diagnosticEventsXdr }); + return code !== null + ? new VaultContractError(code, result) + : new Error(`Transaction failed: ${hash}`); +} diff --git a/packages/vault-sdk/src/events.ts b/packages/vault-sdk/src/events.ts new file mode 100644 index 0000000..bdcf6bf --- /dev/null +++ b/packages/vault-sdk/src/events.ts @@ -0,0 +1,238 @@ +/** + * Event subscription helper over Soroban RPC `getEvents`. + * + * Provides typed payloads for each vault event and handles cursor + * management, gaps, and duplicate detection. + */ + +import { rpc as SorobanRpc, xdr, Address } from '@stellar/stellar-sdk'; +import type { + VaultEvent, + DepositEventPayload, + WithdrawEventPayload, + RegOrchEventPayload, + UpdateOrchEventPayload, + TaskNewEventPayload, + ReleaseEventPayload, + TaskDoneEventPayload, + PauseEventPayload, + UnpauseEventPayload, + UpdateAdminEventPayload, + DisputeRaisedEventPayload, + DisputeResolvedEventPayload, + FeeSetEventPayload, + FeeAccruedEventPayload, + FeeClaimedEventPayload, +} from './types.js'; + +/** Event topic → type name mapping. */ +const EVENT_TOPIC_MAP: Record = { + deposit: 'deposit', + withdraw: 'withdraw', + reg_orch: 'reg_orch', + update_orch: 'update_orch', + task_new: 'task_new', + release: 'release', + task_done: 'task_done', + pause: 'pause', + unpause: 'unpause', + update_admin: 'update_admin', + dispute_raised: 'dispute_raised', + dispute_resolved: 'dispute_resolved', + fee_set: 'fee_set', + fee_accrued: 'fee_accrued', + fee_claimed: 'fee_claimed', +}; + +function parseEventPayload( + eventType: string, + data: xdr.ScVal, +): VaultEvent | null { + try { + const native = xdr.scValToNative(data) as any; + switch (eventType) { + case 'deposit': + return { type: 'deposit', payload: { user: String(native.user), asset: String(native.asset), amount: BigInt(native.amount) } as DepositEventPayload }; + case 'withdraw': + return { type: 'withdraw', payload: { user: String(native.user), asset: String(native.asset), amount: BigInt(native.amount) } as WithdrawEventPayload }; + case 'reg_orch': + return { type: 'reg_orch', payload: { user: String(native.user), orchestrator: String(native.orchestrator) } as RegOrchEventPayload }; + case 'update_orch': + return { type: 'update_orch', payload: { user: String(native.user), old_orchestrator: String(native.old_orchestrator), new_orchestrator: String(native.new_orchestrator) } as UpdateOrchEventPayload }; + case 'task_new': + return { type: 'task_new', payload: { user: String(native.user), orchestrator: String(native.orchestrator), task_id: BigInt(native.task_id), asset: String(native.asset), plan_cost: BigInt(native.plan_cost) } as TaskNewEventPayload }; + case 'release': + return { type: 'release', payload: { user: String(native.user), orchestrator: String(native.orchestrator), task_id: BigInt(native.task_id), asset: String(native.asset), amount: BigInt(native.amount) } as ReleaseEventPayload }; + case 'task_done': + return { type: 'task_done', payload: { user: String(native.user), task_id: BigInt(native.task_id), asset: String(native.asset), spent: BigInt(native.spent), refund: BigInt(native.refund) } as TaskDoneEventPayload }; + case 'pause': + return { type: 'pause', payload: { admin: String(native.admin) } as PauseEventPayload }; + case 'unpause': + return { type: 'unpause', payload: { admin: String(native.admin) } as UnpauseEventPayload }; + case 'update_admin': + return { type: 'update_admin', payload: { old_admin: String(native.old_admin), new_admin: String(native.new_admin) } as UpdateAdminEventPayload }; + case 'dispute_raised': + return { type: 'dispute_raised', payload: { user: String(native.user), task_id: BigInt(native.task_id) } as DisputeRaisedEventPayload }; + case 'dispute_resolved': + return { type: 'dispute_resolved', payload: { resolver: String(native.resolver), task_id: BigInt(native.task_id), refund_to_user: BigInt(native.refund_to_user), payout_to_orchestrator: BigInt(native.payout_to_orchestrator) } as DisputeResolvedEventPayload }; + case 'fee_set': + return { type: 'fee_set', payload: { admin: String(native.admin), bps: Number(native.bps), recipient: native.recipient ? String(native.recipient) : null } as FeeSetEventPayload }; + case 'fee_accrued': + return { type: 'fee_accrued', payload: { asset: String(native.asset), recipient: String(native.recipient), fee_amount: BigInt(native.fee_amount), task_id: BigInt(native.task_id) } as FeeAccruedEventPayload }; + case 'fee_claimed': + return { type: 'fee_claimed', payload: { asset: String(native.asset), recipient: String(native.recipient), amount: BigInt(native.amount) } as FeeClaimedEventPayload }; + default: + return null; + } + } catch { + return null; + } +} + +export interface EventSubscriptionOptions { + /** RPC server URL. */ + rpcUrl: string; + /** Contract ID to filter events. */ + contractId: string; + /** Optional: filter to specific event topic(s). */ + topics?: string[]; + /** Polling interval in ms. Default: 5000. */ + pollIntervalMs?: number; +} + +export interface EventSubscription { + /** Stop polling and close the subscription. */ + stop(): void; + /** Get the current cursor (for resuming later). */ + getCursor(): string | undefined; +} + +/** + * Subscribe to vault events with typed payloads. + * + * Handles cursor management: the cursor is advanced after each successful + * poll. Cursor gaps (e.g. if the RPC skipped ledgers) and duplicates + * (e.g. from reconnection) are handled by deduplicating on event txHash. + * + * @param callback Called for each parsed vault event. + * @returns An `EventSubscription` handle to stop polling. + */ +export function subscribeEvents( + options: EventSubscriptionOptions, + callback: (event: VaultEvent) => void, +): EventSubscription { + const server = new SorobanRpc.Server(options.rpcUrl, { allowHttp: false }); + let cursor: string | undefined; + let stopped = false; + const seen = new Set(); + + const poll = async () => { + while (!stopped) { + try { + const response = await server.getEvents({ + contractIds: [options.contractId], + startLedger: cursor ? undefined : undefined, + cursor, + limit: 100, + }); + + for (const event of response.events) { + const txHash = event.txHash ?? ''; + if (seen.has(txHash)) continue; + seen.add(txHash); + + // Extract event type from topics + const topics = event.topics; + if (!topics || topics.length === 0) continue; + + let topicStr: string; + try { + topicStr = xdr.ScVal.scvSymbol(options.topics?.[0] ?? '').toBuffer().toString(); + } catch { + // fallback: try to read the raw string from the ScVal + try { + const topicNative = xdr.scValToNative(topics[0]); + topicStr = String(topicNative); + } catch { + continue; + } + } + + const eventType = EVENT_TOPIC_MAP[topicStr]; + if (!eventType) continue; + + if (options.topics && !options.topics.includes(topicStr)) continue; + + const parsed = parseEventPayload(topicStr, event.value); + if (parsed) { + callback(parsed); + } + } + + if (response.cursor) { + cursor = response.cursor; + } + } catch { + // RPC hiccup — retry on next interval + } + + if (!stopped) { + await new Promise((r) => setTimeout(r, options.pollIntervalMs ?? 5000)); + } + } + }; + + poll(); + + return { + stop() { + stopped = true; + }, + getCursor() { + return cursor; + }, + }; +} + +/** + * Fetch a single page of events (non-streaming). + * Useful for initial data load or one-shot queries. + */ +export async function fetchEvents( + rpcUrl: string, + contractId: string, + options?: { cursor?: string; limit?: number; topics?: string[] }, +): Promise<{ events: VaultEvent[]; cursor?: string }> { + const server = new SorobanRpc.Server(rpcUrl, { allowHttp: false }); + + const response = await server.getEvents({ + contractIds: [contractId], + cursor: options?.cursor, + limit: options?.limit ?? 100, + }); + + const events: VaultEvent[] = []; + for (const event of response.events) { + const topics = event.topics; + if (!topics || topics.length === 0) continue; + + let topicStr: string; + try { + const topicNative = xdr.scValToNative(topics[0]); + topicStr = String(topicNative); + } catch { + continue; + } + + const eventType = EVENT_TOPIC_MAP[topicStr]; + if (!eventType) continue; + if (options?.topics && !options.topics.includes(topicStr)) continue; + + const parsed = parseEventPayload(topicStr, event.value); + if (parsed) { + events.push(parsed); + } + } + + return { events, cursor: response.cursor }; +} diff --git a/packages/vault-sdk/src/index.ts b/packages/vault-sdk/src/index.ts new file mode 100644 index 0000000..863cf5f --- /dev/null +++ b/packages/vault-sdk/src/index.ts @@ -0,0 +1,70 @@ +/** + * @clevercon/vault-sdk — Reusable typed SDK for the CleverVault Soroban contract. + * + * Wraps every contract entrypoint with typed inputs, decoded native TypeScript + * return values, and structured errors. Provides event subscription, network + * config, and a dependency-free mock mode for local development. + * + * @example + * ```ts + * import { VaultClient } from '@clevercon/vault-sdk'; + * + * const vault = new VaultClient({ + * contractId: 'C...', + * rpcUrl: 'https://soroban-testnet.stellar.org', + * network: 'testnet', + * usdcSac: 'D...', + * }); + * + * const balance = await vault.getBalance(userAddress); + * const taskInfo = await vault.getTask(taskId); + * ``` + */ + +// Client +export { VaultClient } from './client.js'; + +// Errors +export { + VaultErrorCode, + VaultContractError, + extractContractErrorCode, + errorFromSimulation, + errorFromSendResponse, + errorFromFailedTransaction, +} from './errors.js'; + +// Types +export type { + VaultSDKConfig, + NetworkPassphrase, + UserAssetAccount, + UserConfig, + UserAccount, + TaskInfo, + TaskStatus, + FeeConfig, + VaultEvent, + DepositEventPayload, + WithdrawEventPayload, + RegOrchEventPayload, + UpdateOrchEventPayload, + TaskNewEventPayload, + ReleaseEventPayload, + TaskDoneEventPayload, + PauseEventPayload, + UnpauseEventPayload, + UpdateAdminEventPayload, + DisputeRaisedEventPayload, + DisputeResolvedEventPayload, + FeeSetEventPayload, + FeeAccruedEventPayload, + FeeClaimedEventPayload, +} from './types.js'; + +// Events +export { subscribeEvents, fetchEvents } from './events.js'; +export type { EventSubscription, EventSubscriptionOptions } from './events.js'; + +// Mock +export { createMockVaultClient } from './mock.js'; diff --git a/packages/vault-sdk/src/mock.ts b/packages/vault-sdk/src/mock.ts new file mode 100644 index 0000000..043844f --- /dev/null +++ b/packages/vault-sdk/src/mock.ts @@ -0,0 +1,222 @@ +/** + * Dependency-free mock mode for local development and testing. + * + * Returns deterministic responses for every VaultClient method so tests + * can run with zero RPC connectivity. + */ + +import type { + UserAccount, + UserConfig, + TaskInfo, + TaskStatus, + FeeConfig, +} from './types.js'; +import { VaultContractError } from './errors.js'; + +/** Default deterministic mock asset address. */ +const MOCK_ASSET = 'DCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC'; + +/** Internal state for the mock. */ +interface MockState { + userAccounts: Map; + tasks: Map; + taskCounter: bigint; + paused: boolean; + supportedAssets: string[]; + fee: FeeConfig; + disputeResolver: string | null; +} + +function createDefaultState(): MockState { + return { + userAccounts: new Map(), + tasks: new Map(), + taskCounter: 0n, + paused: false, + supportedAssets: [MOCK_ASSET], + fee: { bps: 0, recipient: null }, + disputeResolver: null, + }; +} + +/** + * Create a mock VaultClient-like object with deterministic responses. + * Every method that normally hits RPC returns immediately with canned data. + * + * The returned object implements the same public API surface as VaultClient + * but without any network dependencies. + */ +export function createMockVaultClient(overrides?: Partial) { + const state: MockState = { ...createDefaultState(), ...overrides }; + + return { + get mock(): boolean { + return true; + }, + get active(): boolean { + return true; + }, + + // ── Views ────────────────────────────────────────────────────────────── + async getBalance(userAddress: string): Promise { + const acct = state.userAccounts.get(userAddress); + return acct?.balance ?? 0n; + }, + async getAvailable(userAddress: string): Promise { + const acct = state.userAccounts.get(userAddress); + if (!acct) return 0n; + return acct.balance - acct.locked; + }, + async getAccount(userAddress: string): Promise { + return state.userAccounts.get(userAddress) ?? null; + }, + async getUserConfig(userAddress: string): Promise { + const acct = state.userAccounts.get(userAddress); + if (!acct) return null; + return { + orchestrator: acct.orchestrator, + orchestrator_name: acct.orchestrator_name, + active_tasks_count: acct.active_tasks_count, + created_at: acct.created_at, + }; + }, + async getTask(taskId: bigint): Promise { + return state.tasks.get(taskId) ?? null; + }, + async getTaskStatus(taskId: bigint): Promise { + const task = state.tasks.get(taskId); + if (!task) return null; + if (task.completed) return 'Completed'; + if (task.disputed) return 'Disputed'; + return 'Active'; + }, + async getUserTasks(userAddress: string): Promise { + const tasks: bigint[] = []; + for (const [id, task] of state.tasks) { + if (task.user === userAddress) tasks.push(id); + } + return tasks; + }, + async getOrchestratorOwner(orchestratorAddress: string): Promise { + for (const [userAddr, acct] of state.userAccounts.entries()) { + if (acct.orchestrator === orchestratorAddress) return userAddr; + } + return null; + }, + async isSupportedAsset(assetAddress: string): Promise { + return state.supportedAssets.includes(assetAddress); + }, + async getSupportedAssets(): Promise { + return [...state.supportedAssets]; + }, + async taskCount(): Promise { + return state.taskCounter; + }, + async version(): Promise { + return 5; + }, + async isPaused(): Promise { + return state.paused; + }, + async tokenBalance(assetAddress: string): Promise { + // Sum all user balances for this asset + let total = 0n; + for (const acct of state.userAccounts.values()) { + total += acct.balance; + } + return total; + }, + async getDisputeResolver(): Promise { + return state.disputeResolver; + }, + async getStaleThreshold(): Promise { + return 1800; + }, + async getMaxActiveTasks(): Promise { + return 50; + }, + async getFee(): Promise { + return { ...state.fee }; + }, + async getAccruedFees(assetAddress: string): Promise { + return 0n; + }, + + // ── Mutations (mock implementations that update state) ────────────────── + + /** Simulate deposit — updates mock state. */ + async mockDeposit(userAddress: string, amountUsdc: number): Promise { + const stroops = BigInt(Math.round(amountUsdc * 10_000_000)); + let acct = state.userAccounts.get(userAddress); + if (!acct) { + acct = { + balance: 0n, locked: 0n, total_deposited: 0n, total_spent: 0n, + active_tasks_count: 0, orchestrator: null, orchestrator_name: '', created_at: BigInt(Date.now()), + }; + state.userAccounts.set(userAddress, acct); + } + acct.balance += stroops; + acct.total_deposited += stroops; + }, + + /** Simulate create_task — updates mock state, returns mock task_id. */ + async mockCreateTask( + orchestratorAddress: string, + planCostUsdc: number, + assetAddress?: string, + ): Promise { + const owner = await this.getOrchestratorOwner(orchestratorAddress); + if (!owner) throw new VaultContractError(14, { mock: true }); // OrchestratorNotRegistered + + const stroops = BigInt(Math.round(planCostUsdc * 10_000_000)); + const asset = assetAddress ?? MOCK_ASSET; + const acct = state.userAccounts.get(owner); + if (!acct) throw new Error('User not found in mock state'); + if (acct.balance - acct.locked < stroops) { + throw new VaultContractError(6, { mock: true }); // InsufficientAvailable + } + + acct.locked += stroops; + acct.active_tasks_count += 1; + + state.taskCounter += 1n; + const task: TaskInfo = { + user: owner, + orchestrator: orchestratorAddress, + asset, + plan_cost: stroops, + spent: 0n, + completed: false, + disputed: false, + created_at: BigInt(Date.now()), + }; + state.tasks.set(state.taskCounter, task); + return state.taskCounter; + }, + + /** Register an orchestrator in mock state. */ + async mockRegisterOrchestrator( + userAddress: string, + orchestratorAddress: string, + name: string, + ): Promise { + let acct = state.userAccounts.get(userAddress); + if (!acct) { + acct = { + balance: 0n, locked: 0n, total_deposited: 0n, total_spent: 0n, + active_tasks_count: 0, orchestrator: null, orchestrator_name: '', created_at: BigInt(Date.now()), + }; + state.userAccounts.set(userAddress, acct); + } + if (acct.orchestrator) { + throw new VaultContractError(15, { mock: true }); // OrchestratorAlreadyRegistered + } + acct.orchestrator = orchestratorAddress; + acct.orchestrator_name = name; + }, + + /** Access internal state for assertions in tests. */ + _state: state, + }; +} diff --git a/packages/vault-sdk/src/types.ts b/packages/vault-sdk/src/types.ts new file mode 100644 index 0000000..fea7080 --- /dev/null +++ b/packages/vault-sdk/src/types.ts @@ -0,0 +1,187 @@ +/** + * Typed interfaces for CleverVault contract data structures. + * + * All amounts are in **stroops** (1 USDC = 10_000_000 stroops) unless + * explicitly marked with a USDC suffix. The SDK always returns native + * TypeScript types — callers never touch raw `ScVal`. + */ + +// ── Contract data types ────────────────────────────────────────────────────── + +/** Asset-specific balances and history for a user. */ +export interface UserAssetAccount { + balance: bigint; + locked: bigint; + total_deposited: bigint; + total_spent: bigint; + created_at: bigint; +} + +/** Global, asset-agnostic user settings. */ +export interface UserConfig { + orchestrator: string | null; + orchestrator_name: string; + active_tasks_count: number; + created_at: bigint; +} + +/** Consolidated user account for external view queries. */ +export interface UserAccount { + balance: bigint; + locked: bigint; + total_deposited: bigint; + total_spent: bigint; + active_tasks_count: number; + orchestrator: string | null; + orchestrator_name: string; + created_at: bigint; +} + +/** Per-task state. */ +export interface TaskInfo { + user: string; + orchestrator: string; + asset: string; + plan_cost: bigint; + spent: bigint; + completed: boolean; + disputed: boolean; + created_at: bigint; +} + +/** Task lifecycle status. */ +export type TaskStatus = 'Active' | 'Stale' | 'Disputed' | 'Completed'; + +/** Protocol fee configuration. */ +export interface FeeConfig { + bps: number; + recipient: string | null; +} + +// ── Event payload types ────────────────────────────────────────────────────── + +export interface DepositEventPayload { + user: string; + asset: string; + amount: bigint; +} + +export interface WithdrawEventPayload { + user: string; + asset: string; + amount: bigint; +} + +export interface RegOrchEventPayload { + user: string; + orchestrator: string; +} + +export interface UpdateOrchEventPayload { + user: string; + old_orchestrator: string; + new_orchestrator: string; +} + +export interface TaskNewEventPayload { + user: string; + orchestrator: string; + task_id: bigint; + asset: string; + plan_cost: bigint; +} + +export interface ReleaseEventPayload { + user: string; + orchestrator: string; + task_id: bigint; + asset: string; + amount: bigint; +} + +export interface TaskDoneEventPayload { + user: string; + task_id: bigint; + asset: string; + spent: bigint; + refund: bigint; +} + +export interface PauseEventPayload { + admin: string; +} + +export interface UnpauseEventPayload { + admin: string; +} + +export interface UpdateAdminEventPayload { + old_admin: string; + new_admin: string; +} + +export interface DisputeRaisedEventPayload { + user: string; + task_id: bigint; +} + +export interface DisputeResolvedEventPayload { + resolver: string; + task_id: bigint; + refund_to_user: bigint; + payout_to_orchestrator: bigint; +} + +export interface FeeSetEventPayload { + admin: string; + bps: number; + recipient: string | null; +} + +export interface FeeAccruedEventPayload { + asset: string; + recipient: string; + fee_amount: bigint; + task_id: bigint; +} + +export interface FeeClaimedEventPayload { + asset: string; + recipient: string; + amount: bigint; +} + +/** Union of all vault event types. */ +export type VaultEvent = + | { type: 'deposit'; payload: DepositEventPayload } + | { type: 'withdraw'; payload: WithdrawEventPayload } + | { type: 'reg_orch'; payload: RegOrchEventPayload } + | { type: 'update_orch'; payload: UpdateOrchEventPayload } + | { type: 'task_new'; payload: TaskNewEventPayload } + | { type: 'release'; payload: ReleaseEventPayload } + | { type: 'task_done'; payload: TaskDoneEventPayload } + | { type: 'pause'; payload: PauseEventPayload } + | { type: 'unpause'; payload: UnpauseEventPayload } + | { type: 'update_admin'; payload: UpdateAdminEventPayload } + | { type: 'dispute_raised'; payload: DisputeRaisedEventPayload } + | { type: 'dispute_resolved'; payload: DisputeResolvedEventPayload } + | { type: 'fee_set'; payload: FeeSetEventPayload } + | { type: 'fee_accrued'; payload: FeeAccruedEventPayload } + | { type: 'fee_claimed'; payload: FeeClaimedEventPayload }; + +// ── SDK config types ───────────────────────────────────────────────────────── + +export type NetworkPassphrase = 'testnet' | 'mainnet'; + +export interface VaultSDKConfig { + /** Contract ID on-chain. */ + contractId: string; + /** RPC server URL. */ + rpcUrl?: string; + /** Network passphrase — defaults to testnet. */ + network?: NetworkPassphrase; + /** USDC Stellar Asset Contract address. */ + usdcSac?: string; + /** Enable mock mode (no RPC calls, returns deterministic responses). */ + mock?: boolean; +} diff --git a/packages/vault-sdk/src/vault-errors.test.ts b/packages/vault-sdk/src/vault-errors.test.ts new file mode 100644 index 0000000..ce8b2f4 --- /dev/null +++ b/packages/vault-sdk/src/vault-errors.test.ts @@ -0,0 +1,114 @@ +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, +} from './errors.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONTRACT_LIB_RS = path.join( + __dirname, + '..', '..', '..', 'contracts', 'agent-vault', 'src', 'lib.rs', +); + +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 errorScVal = xdr.ScVal.scvError(xdr.ScError.sceContract(6)); + 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, + }); + const diag = new xdr.DiagnosticEvent({ inSuccessfulContractCall: false, event }); + + const code = extractContractErrorCode({ diagnosticEvents: [diag] }); + 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 non-contract failure', () => { + expect(extractContractErrorCode({ message: 'fetch failed: ECONNREFUSED' })).toBeNull(); + expect(extractContractErrorCode({})).toBeNull(); + }); + + it('prefers diagnostic events over the message when both are present', () => { + const errorScVal = xdr.ScVal.scvError(xdr.ScError.sceContract(2)); + 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, + }); + const diag = new xdr.DiagnosticEvent({ inSuccessfulContractCall: false, event }); + + const code = extractContractErrorCode({ + message: 'HostError: Error(Contract, #1)', + diagnosticEvents: [diag], + }); + 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', () => { + 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'); + }); +}); diff --git a/packages/vault-sdk/tsconfig.json b/packages/vault-sdk/tsconfig.json new file mode 100644 index 0000000..479c240 --- /dev/null +++ b/packages/vault-sdk/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}