From 776e0244562dba57b7e492695301a85f409e8355 Mon Sep 17 00:00:00 2001 From: Lex0865 Date: Sun, 27 Sep 2026 14:56:32 -0600 Subject: [PATCH] Classify Soroban simulation failures and support state restoration Added distinct errors for missing accounts, resource or fee limits, and archived entries. Added opt-in restore: true for invoke() and documented the restore flow. Closes #240 --- .changeset/soroban-simulation-restore.md | 6 + docs/client.md | 19 ++ docs/errors.md | 3 + .../bindings/default-binding-adapter.test.ts | 15 ++ .../src/bindings/default-binding-adapter.ts | 12 +- .../client/caatinga-contract-client.test.ts | 40 +++- .../src/client/caatinga-contract-client.ts | 74 ++++++- packages/client/src/client/invoke-args.ts | 4 +- .../src/client/transaction-simulate.test.ts | 54 +++++- .../client/src/client/transaction-simulate.ts | 180 ++++++++++++++++-- packages/client/src/types.ts | 4 + packages/client/src/xdr/build-xdr.ts | 16 +- packages/core/src/errors/CaatingaErrorCode.ts | 3 + .../core/src/errors/error-surface.test.ts | 12 ++ 14 files changed, 403 insertions(+), 39 deletions(-) create mode 100644 .changeset/soroban-simulation-restore.md diff --git a/.changeset/soroban-simulation-restore.md b/.changeset/soroban-simulation-restore.md new file mode 100644 index 00000000..125a2b56 --- /dev/null +++ b/.changeset/soroban-simulation-restore.md @@ -0,0 +1,6 @@ +--- +"@caatinga/core": minor +"@caatinga/client": minor +--- + +Classify common Soroban simulation failures and add opt-in archived-state restoration to `invoke()`. diff --git a/docs/client.md b/docs/client.md index 8ccd7641..c642809e 100644 --- a/docs/client.md +++ b/docs/client.md @@ -184,6 +184,22 @@ console.log(result.raw); `wallet.getPublicKey()` to build the generated client, but it does not call `wallet.signTransaction()`. If the simulated method does not expose a result, the client throws `CAATINGA_READ_RESULT_MISSING`. +Reads do not sign or submit transactions. If simulation reports archived Soroban state, use +`invoke()` with the explicit `restore: true` option when invoking the contract method: + +```ts +await client.contract("counter").invoke("increment", { restore: true }); +``` + +The default binding adapter forwards this option to compatible generated bindings and supplies +the connected wallet signer. The binding may ask the wallet to approve and submit a +`RestoreFootprint` transaction before preparing the invocation. Custom binding adapters must +forward `methodOptions` for this option to work. Networks with automatic restoration may not +return a `restorePreamble` or require a separate restore transaction. If only a read is needed, +`simulate()` will not submit a restore transaction; use the RPC's `restorePreamble.minResourceFee` +and `restorePreamble.transactionData` with the SDK's `Operation.restoreFootprint()` to construct +and sign a separate restore transaction, following the [Stellar JS SDK restore guide](https://developers.stellar.org/docs/build/guides/archival/restore-data-js), then retry the read. + Calling `invoke()` on a read-only binding method may fail with a hint to use `read()` or `simulate()` instead. ## Project layout @@ -356,6 +372,9 @@ Client failures use public `CAATINGA_*` codes. The most common are: - `CAATINGA_WALLET_TIMEOUT` - `CAATINGA_XDR_BUILD_FAILED` - `CAATINGA_XDR_PREPARE_FAILED` +- `CAATINGA_SIMULATION_ACCOUNT_NOT_FOUND` +- `CAATINGA_SIMULATION_RESOURCE_LIMIT` +- `CAATINGA_SIMULATION_ENTRY_ARCHIVED` - `CAATINGA_XDR_SIGN_FAILED` - `CAATINGA_XDR_SUBMIT_FAILED` - `CAATINGA_XDR_RESULT_FAILED` diff --git a/docs/errors.md b/docs/errors.md index 0ba5db8d..5867c6e5 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -85,6 +85,9 @@ error-code table because they are not errors. | `CAATINGA_PLACEHOLDER_BINDING` | The app is still using the scaffolded placeholder binding instead of real generated bindings. | `ctg generate` was not run after deploy, so the template's placeholder stub is still imported. | Run `ctg generate --network `, then restart the dev server. | Fail CI and generate bindings before building the frontend. | Public code; adding a new code is minor, removal/rename/meaning change is major. | | `CAATINGA_XDR_BUILD_FAILED` | Client could not build or read transaction XDR. | Binding transaction object is incompatible or lacks `toXDR()`. | Regenerate bindings or provide a compatible binding adapter. | Fail CI and block release until client bindings can build XDR. | Public code; adding a new code is minor, removal/rename/meaning change is major. | | `CAATINGA_XDR_PREPARE_FAILED` | Client could not complete the binding `prepare()` step. | Thrown only when the transaction exposes `prepare()`; simulation/RPC failed, `prepare()` threw a non-Caatinga error, or the prepared value could not be read as XDR afterward. | Check RPC connectivity, simulation errors, and binding compatibility; omit or fix `prepare()` in custom adapters if unused. | Fail CI for deterministic tests; mark external RPC outages separately. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_SIMULATION_ACCOUNT_NOT_FOUND` | The wallet source account does not exist on the selected network. | Simulation or account lookup returned `ACCOUNT_NOT_FOUND`, `txNO_ACCOUNT`, or equivalent account-not-found text. | Fund the account with enough XLM to create it and cover transaction fees, or use an existing funded account. | Fail CI when a required source account is missing; fund or select the intended account. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_SIMULATION_RESOURCE_LIMIT` | Soroban simulation exceeded a resource limit or rejected its resource fee. | RPC simulation reported a resource limit, budget exhaustion, or insufficient resource fee. | Inspect RPC diagnostics; reduce contract resource use or use the simulation's required transaction data and fee when building a custom transaction. | Fail CI for deterministic contract resource failures; investigate ledger-dependent fees separately. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_SIMULATION_ENTRY_ARCHIVED` | Simulation needs archived Soroban state restored before the invocation can proceed. | RPC or a generated binding reported `ENTRY_ARCHIVED`, an expired-state error, or a `restorePreamble`. | For `invoke()`, pass `{ restore: true }` to let a compatible generated binding request wallet approval and submit its RestoreFootprint transaction before retrying. | Fail CI if restoration is expected but cannot complete; verify the restore and retry flow. | Public code; adding a new code is minor, removal/rename/meaning change is major. | | `CAATINGA_XDR_SIGN_FAILED` | Wallet failed to sign the prepared XDR. | `wallet.signTransaction()` rejected, threw, or returned unusable output (wrong network, user rejection, adapter bug). | Connect the wallet, confirm network/passphrase, and approve the transaction. | In browser CI, fail mocked wallet tests; in manual flows, prompt user retry. | Public code; adding a new code is minor, removal/rename/meaning change is major. | | `CAATINGA_XDR_SUBMIT_FAILED` | Client could not submit signed XDR. | RPC rejected the transaction or binding submit method failed. | Check signature, RPC URL, network passphrase, and account state. | Fail CI and preserve RPC response details. | Public code; adding a new code is minor, removal/rename/meaning change is major. | | `CAATINGA_XDR_RESULT_FAILED` | Submit returned a non-null object without a recognized transaction id or result field. | After a successful `signAndSend`/`send` call, the value is an object that lacks `txHash`, `transactionHash`, `hash`, and `result` (`null` and non-objects are accepted without this code). | Inspect `debugRaw` output and align the binding adapter with the expected submit return shape. | Fail CI and capture the raw result payload. | Public code; adding a new code is minor, removal/rename/meaning change is major. | diff --git a/packages/client/src/bindings/default-binding-adapter.test.ts b/packages/client/src/bindings/default-binding-adapter.test.ts index 094f4531..063bee86 100644 --- a/packages/client/src/bindings/default-binding-adapter.test.ts +++ b/packages/client/src/bindings/default-binding-adapter.test.ts @@ -58,6 +58,21 @@ describe("createDefaultBindingAdapter", () => { }); }); + it("forwards method options to zero-argument generated methods", async () => { + class Client { + increment(options?: { restore?: boolean }) { + return options; + } + } + + const adapter = createDefaultBindingAdapter({ Client }); + const methodOptions = { restore: true }; + + await expect( + adapter.callMethod({ client: new Client(), method: "increment", methodOptions }) + ).resolves.toBe(methodOptions); + }); + it("throws when generated binding does not export Client", () => { const adapter = createDefaultBindingAdapter({}); diff --git a/packages/client/src/bindings/default-binding-adapter.ts b/packages/client/src/bindings/default-binding-adapter.ts index d1d19065..b27daacb 100644 --- a/packages/client/src/bindings/default-binding-adapter.ts +++ b/packages/client/src/bindings/default-binding-adapter.ts @@ -43,7 +43,7 @@ export function createDefaultBindingAdapter(binding: BindingWithClient): Caating }); }, - async callMethod({ client, method, args }) { + async callMethod({ client, method, args, methodOptions }) { const candidate = client as Record; const fn = candidate[method]; @@ -55,7 +55,15 @@ export function createDefaultBindingAdapter(binding: BindingWithClient): Caating ); } - return args ? fn.call(client, args) : fn.call(client); + if (args !== undefined && methodOptions !== undefined) { + return fn.call(client, args, methodOptions); + } + + if (args !== undefined) { + return fn.call(client, args); + } + + return methodOptions !== undefined ? fn.call(client, methodOptions) : fn.call(client); } }; } diff --git a/packages/client/src/client/caatinga-contract-client.test.ts b/packages/client/src/client/caatinga-contract-client.test.ts index 74bce7ad..3dc2c004 100644 --- a/packages/client/src/client/caatinga-contract-client.test.ts +++ b/packages/client/src/client/caatinga-contract-client.test.ts @@ -30,7 +30,14 @@ function createClientConfig(overrides: Record = {}) { }; class Client { - increment() { + async increment(options?: { + restore?: boolean; + signTransaction?: (xdr: string) => Promise<{ signedTxXdr: string }>; + }) { + if (options?.restore) { + await options.signTransaction?.("AAAA_RESTORE"); + } + return { toXDR() { return "AAAA_UNSIGNED"; @@ -103,6 +110,10 @@ function createClientConfig(overrides: Record = {}) { }; } + missingAccount() { + return Promise.reject(new Error("ACCOUNT_NOT_FOUND")); + } + failingSubmit() { return { toXDR() { @@ -220,6 +231,33 @@ describe("CaatingaContractClient (via createCaatingaClient)", () => { await expect(client.contract("counter").read("get")).resolves.toBe(42); }); + it("should_forward_opt_in_state_restoration_and_wallet_signer_to_generated_binding", async () => { + const signTransaction = vi.fn(async () => "AAAA_SIGNED"); + const client = createCaatingaClient( + createClientConfig({ + wallet: { + getPublicKey: vi.fn(async () => "GPUBLIC"), + signTransaction, + }, + }) + ); + + await client.contract("counter").invoke("increment", { restore: true }); + + expect(signTransaction).toHaveBeenCalledWith({ + xdr: "AAAA_RESTORE", + networkPassphrase: "Test SDF Network ; September 2015", + }); + }); + + it("should_classify_missing_account_during_generated_method_construction", async () => { + const client = createCaatingaClient(createClientConfig()); + + await expect(client.contract("counter").simulate("missingAccount")).rejects.toMatchObject({ + code: CaatingaErrorCode.SIMULATION_ACCOUNT_NOT_FOUND, + }); + }); + it("should_forward_read_args_and_include_raw_when_debugRaw_is_enabled", async () => { const client = createCaatingaClient(createClientConfig()); diff --git a/packages/client/src/client/caatinga-contract-client.ts b/packages/client/src/client/caatinga-contract-client.ts index aaedc620..a8872a7e 100644 --- a/packages/client/src/client/caatinga-contract-client.ts +++ b/packages/client/src/client/caatinga-contract-client.ts @@ -18,7 +18,11 @@ import { splitInvokeArgsAndOptions, splitReadArgsAndOptions, } from "./invoke-args.js"; -import { prepareReadTransaction, readSimulationResult } from "./transaction-simulate.js"; +import { + prepareReadTransaction, + readSimulationResult, + toSimulationError, +} from "./transaction-simulate.js"; import { normalizeSubmitResult, submitTransaction } from "./transaction-submit.js"; import type { StellarSdkSignTransaction, SubmitTransactionLike } from "./transaction-types.js"; @@ -58,8 +62,15 @@ export class CaatingaContractClient { argsOrOptions?: Record | CaatingaInvokeOptions, maybeOptions?: CaatingaInvokeOptions ): Promise> { - const { args, debugXdr, debugRaw } = splitInvokeArgsAndOptions(argsOrOptions, maybeOptions); - const { contractId, transaction } = await this.createTransaction(method, args); + const { args, debugXdr, debugRaw, restore } = splitInvokeArgsAndOptions( + argsOrOptions, + maybeOptions + ); + const { contractId, transaction } = await this.createTransaction( + method, + args, + restore ? this.createRestoreMethodOptions(method) : undefined + ); const xdr = await buildTransactionXdr({ contractName: this.contractName, method, @@ -182,7 +193,11 @@ export class CaatingaContractClient { return result.result; } - private async createTransaction(method: string, args?: Record) { + private async createTransaction( + method: string, + args?: Record, + methodOptions?: Record + ) { const contractId = resolveContractId({ artifacts: this.config.artifacts, network: this.config.network.name, @@ -213,8 +228,57 @@ export class CaatingaContractClient { rpcUrl: this.config.network.rpcUrl, networkPassphrase: this.config.network.networkPassphrase, }); - const transaction = await this.bindingAdapter.callMethod({ client, method, args }); + let transaction: unknown; + try { + transaction = await this.bindingAdapter.callMethod({ + client, + method, + args, + methodOptions, + }); + } catch (error) { + throw toSimulationError(error, this.contractName, method, this.config.network.rpcUrl); + } return { contractId, transaction }; } + + private createRestoreMethodOptions(method: string): Record { + return { + restore: true, + signTransaction: async (xdr: string) => { + let signedTxXdr: string; + try { + signedTxXdr = await withWalletTimeout("signTransaction", this.config.walletTimeout, () => + this.config.wallet.signTransaction({ + xdr, + networkPassphrase: this.config.network.networkPassphrase, + }) + ); + } catch (error) { + if (error instanceof CaatingaError) { + throw error; + } + + throw new CaatingaError( + `Failed to sign the state restoration transaction for "${this.contractName}.${method}".`, + CaatingaErrorCode.XDR_SIGN_FAILED, + "Approve the RestoreFootprint transaction in the wallet and retry.", + error + ); + } + + if (typeof signedTxXdr !== "string" || signedTxXdr.trim().length === 0) { + throw new CaatingaError( + `Failed to sign the state restoration transaction for "${this.contractName}.${method}".`, + CaatingaErrorCode.XDR_SIGN_FAILED, + "Wallet returned an empty or invalid signed XDR for RestoreFootprint.", + signedTxXdr + ); + } + + return { signedTxXdr }; + }, + }; + } } diff --git a/packages/client/src/client/invoke-args.ts b/packages/client/src/client/invoke-args.ts index 953ac520..8b7d111c 100644 --- a/packages/client/src/client/invoke-args.ts +++ b/packages/client/src/client/invoke-args.ts @@ -16,7 +16,7 @@ export function splitInvokeArgsAndOptions( ) { const looksLikeOptions = argsOrOptions !== undefined && - ("debugXdr" in argsOrOptions || "debugRaw" in argsOrOptions) && + ("debugXdr" in argsOrOptions || "debugRaw" in argsOrOptions || "restore" in argsOrOptions) && maybeOptions === undefined; if (looksLikeOptions) { @@ -25,6 +25,7 @@ export function splitInvokeArgsAndOptions( args: undefined, debugXdr: options.debugXdr ?? false, debugRaw: options.debugRaw ?? false, + restore: options.restore ?? false, }; } @@ -32,6 +33,7 @@ export function splitInvokeArgsAndOptions( args: argsOrOptions as Record | undefined, debugXdr: maybeOptions?.debugXdr ?? false, debugRaw: maybeOptions?.debugRaw ?? false, + restore: maybeOptions?.restore ?? false, }; } diff --git a/packages/client/src/client/transaction-simulate.test.ts b/packages/client/src/client/transaction-simulate.test.ts index 591d0a6f..d379efe3 100644 --- a/packages/client/src/client/transaction-simulate.test.ts +++ b/packages/client/src/client/transaction-simulate.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { CaatingaErrorCode } from "@caatinga/core/browser"; -import { normalizeSimulationValue, readSimulationResult } from "./transaction-simulate.js"; +import { + normalizeSimulationValue, + prepareReadTransaction, + readSimulationResult, +} from "./transaction-simulate.js"; describe("transaction-simulate", () => { it("should_unwrap_ok_results_from_stellar_bindings", () => { @@ -48,4 +52,52 @@ describe("transaction-simulate", () => { expect(readSimulationResult(raw, "verifier", "verify_proof")).toBe(false); }); + + it("should_classify_account_not_found_during_preparation", async () => { + await expect( + prepareReadTransaction( + { prepare: () => Promise.reject(new Error("ACCOUNT_NOT_FOUND")) }, + "verifier", + "verify_proof", + "https://rpc.example" + ) + ).rejects.toMatchObject({ + code: CaatingaErrorCode.SIMULATION_ACCOUNT_NOT_FOUND, + hint: expect.stringContaining("Fund this Stellar account"), + }); + }); + + it("should_classify_resource_limit_during_preparation", async () => { + await expect( + prepareReadTransaction( + { prepare: () => Promise.reject(new Error("HostError(Budget, LimitExceeded)")) }, + "verifier", + "verify_proof", + "https://rpc.example" + ) + ).rejects.toMatchObject({ code: CaatingaErrorCode.SIMULATION_RESOURCE_LIMIT }); + }); + + it("should_classify_archived_entry_with_restore_preamble", async () => { + await expect( + prepareReadTransaction( + { + prepare: () => + Promise.reject({ + error: "Simulation requires restoration", + restorePreamble: { minResourceFee: "100" }, + }), + }, + "verifier", + "verify_proof", + "https://rpc.example" + ) + ).rejects.toMatchObject({ + code: CaatingaErrorCode.SIMULATION_ENTRY_ARCHIVED, + hint: expect.stringContaining("restore: true"), + cause: expect.objectContaining({ + restorePreamble: expect.objectContaining({ minResourceFee: "100" }), + }), + }); + }); }); diff --git a/packages/client/src/client/transaction-simulate.ts b/packages/client/src/client/transaction-simulate.ts index c2bfb76b..ae6e174e 100644 --- a/packages/client/src/client/transaction-simulate.ts +++ b/packages/client/src/client/transaction-simulate.ts @@ -8,6 +8,164 @@ type StellarContractResult = { unwrapErr: () => unknown; }; +type SimulationFailure = { + code: + | typeof CaatingaErrorCode.SIMULATION_ACCOUNT_NOT_FOUND + | typeof CaatingaErrorCode.SIMULATION_RESOURCE_LIMIT + | typeof CaatingaErrorCode.SIMULATION_ENTRY_ARCHIVED; + message: string; + hint: string; +}; + +const ARCHIVED_ENTRY_PATTERNS = [ + /ENTRY_ARCHIVED|ARCHIVED.{0,50}ENTR|ENTR(?:Y|IES).{0,50}ARCHIVED/, + /(?:STORAGE|LEDGER).{0,30}ARCHIVED/, + /EXPIRED.?STATE|RESTORE.?PREAMBLE|RESTORE.{0,35}(?:ARCHIVED|EXPIRED|CONTRACT STATE)/, +]; +const MISSING_ACCOUNT_PATTERNS = [ + /ACCOUNT_NOT_FOUND|ACCOUNT.?NOT.?FOUND/, + /TX_?NO_ACCOUNT|OP_?NO_ACCOUNT/, + /ACCOUNT.{0,60}(?:NOT.{0,12}FOUND|DOES NOT EXIST|UNFUNDED|NOT FUNDED)/, + /NOT.{0,12}FOUND.{0,60}ACCOUNT/, +]; +const RESOURCE_LIMIT_PATTERNS = [ + /RESOURCE_LIMIT|RESOURCE.{0,25}LIMIT|LIMIT.{0,25}RESOURCE|LIMIT.?EXCEEDED/, + /HOSTERROR\(BUDGET|INSTRUCTION.{0,30}EXCEEDED|MEMORY.{0,30}EXCEEDED/, + /INSUFFICIENT.{0,30}(?:FEE|RESOURCE)/, + /(?:FEE|RESOURCE).{0,30}(?:INSUFFICIENT|TOO LOW|LOWER|BELOW|EXCEEDED)/, + /(?:FEE|RESOURCE)_?(?:TOO_?LOW|BELOW|INSUFFICIENT)/, + /SOROBANINVALID.{0,40}RESOURCEFEE/, +]; + +function collectErrorText(value: unknown, seen = new Set(), depth = 0): string { + if (depth > 4 || value === null || value === undefined) { + return ""; + } + + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + + if (typeof value !== "object" || seen.has(value)) { + return ""; + } + + seen.add(value); + + const fields = [ + "name", + "code", + "message", + "hint", + "error", + "details", + "detail", + "data", + "response", + "result", + "cause", + "simulation", + "diagnostics", + "diagnosticEvents", + "restorePreamble", + ]; + const text = fields + .filter((field) => field in value) + .flatMap((field) => [ + field, + collectErrorText((value as Record)[field], seen, depth + 1), + ]) + .filter(Boolean); + + try { + const serialized = JSON.stringify(value); + if (serialized) { + text.push(serialized); + } + } catch { + // The named fields above still provide useful information for cyclic SDK/RPC errors. + } + + return text.join(" "); +} + +function classifySimulationFailure(error: unknown): SimulationFailure | undefined { + const evidence = collectErrorText(error).toUpperCase(); + + if (ARCHIVED_ENTRY_PATTERNS.some((pattern) => pattern.test(evidence))) { + return { + code: CaatingaErrorCode.SIMULATION_ENTRY_ARCHIVED, + message: "Simulation requires restoration of archived Soroban contract state.", + hint: [ + "For a write, retry with invoke(method, args, { restore: true }) and a compatible binding.", + "If RPC returns restorePreamble, the binding may request approval and submit RestoreFootprint.", + "When present, restorePreamble is retained on error.cause for manual restoration.", + "For a read, use the preamble's fee and transaction data to submit a separate restore transaction.", + "Then retry the read.", + ].join(" "), + }; + } + + if (MISSING_ACCOUNT_PATTERNS.some((pattern) => pattern.test(evidence))) { + return { + code: CaatingaErrorCode.SIMULATION_ACCOUNT_NOT_FOUND, + message: "The wallet source account does not exist on the selected Stellar network.", + hint: [ + "Fund this Stellar account with enough XLM to create it and cover transaction fees,", + "or use an existing funded account, then retry.", + ].join(" "), + }; + } + + if (RESOURCE_LIMIT_PATTERNS.some((pattern) => pattern.test(evidence))) { + return { + code: CaatingaErrorCode.SIMULATION_RESOURCE_LIMIT, + message: "Soroban simulation exceeded a resource limit or rejected its resource fee.", + hint: [ + "Inspect error.cause for RPC simulation details and diagnostics. Reduce contract resource use,", + "or use the simulation's required transaction data and fee when building a custom transaction.", + ].join(" "), + }; + } + + return undefined; +} + +export function toSimulationError( + error: unknown, + contractName: string, + method: string, + rpcUrl: string, + action: "prepare" | "simulate" = "simulate" +): CaatingaError { + if (error instanceof CaatingaError && error.code !== CaatingaErrorCode.XDR_PREPARE_FAILED) { + return error; + } + + const failure = classifySimulationFailure(error); + if (failure) { + return new CaatingaError( + `${failure.message} Call: "${contractName}.${method}".`, + failure.code, + `${failure.hint} RPC: ${rpcUrl}.`, + error + ); + } + + if (error instanceof CaatingaError) { + return error; + } + + return new CaatingaError( + action === "prepare" + ? `Failed to prepare XDR for "${contractName}.${method}".` + : `Failed to simulate "${contractName}.${method}".`, + CaatingaErrorCode.XDR_PREPARE_FAILED, + `RPC: ${rpcUrl}. Check connectivity, simulation errors, and binding compatibility.`, + error + ); +} + export async function prepareReadTransaction( transaction: unknown, contractName: string, @@ -20,16 +178,7 @@ export async function prepareReadTransaction( try { return await candidate.prepare.call(transaction); } catch (error) { - if (error instanceof CaatingaError) { - throw error; - } - - throw new CaatingaError( - `Failed to prepare XDR for "${contractName}.${method}".`, - CaatingaErrorCode.XDR_PREPARE_FAILED, - `RPC: ${rpcUrl}. Check connectivity, simulation errors, and binding compatibility.`, - error - ); + throw toSimulationError(error, contractName, method, rpcUrl, "prepare"); } } @@ -37,16 +186,7 @@ export async function prepareReadTransaction( try { return await candidate.simulate.call(transaction); } catch (error) { - if (error instanceof CaatingaError) { - throw error; - } - - throw new CaatingaError( - `Failed to simulate "${contractName}.${method}".`, - CaatingaErrorCode.XDR_PREPARE_FAILED, - `RPC: ${rpcUrl}. Check connectivity, simulation errors, and binding compatibility.`, - error - ); + throw toSimulationError(error, contractName, method, rpcUrl); } } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index e4b74021..d06a256e 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -50,6 +50,8 @@ export type CaatingaInvokeStatus = export interface CaatingaInvokeOptions { debugXdr?: boolean; debugRaw?: boolean; + /** Allow a compatible generated binding to restore archived Soroban state before invocation. */ + restore?: boolean; } export interface CaatingaReadOptions { @@ -105,5 +107,7 @@ export interface CaatingaBindingAdapter { client: unknown; method: string; args?: Record; + /** Generated binding method options, such as `restore` and its signer callback. */ + methodOptions?: Record; }): Promise; } diff --git a/packages/client/src/xdr/build-xdr.ts b/packages/client/src/xdr/build-xdr.ts index c3b5682f..bb5a2252 100644 --- a/packages/client/src/xdr/build-xdr.ts +++ b/packages/client/src/xdr/build-xdr.ts @@ -1,5 +1,6 @@ import { CaatingaError, CaatingaErrorCode } from "@caatinga/core/browser"; import type { CaatingaXdrBuildResult } from "../types.js"; +import { toSimulationError } from "../client/transaction-simulate.js"; interface XdrTransactionLike { toXDR?: () => string; @@ -27,15 +28,12 @@ export async function buildXdr(input: { try { preparedTransaction = await transaction.prepare(); } catch (error) { - if (error instanceof CaatingaError) { - throw error; - } - - throw new CaatingaError( - `Failed to prepare XDR for "${input.contractName}.${input.method}".`, - CaatingaErrorCode.XDR_PREPARE_FAILED, - `RPC: ${input.rpcUrl}. Check connectivity, simulation errors, and binding compatibility.`, - error + throw toSimulationError( + error, + input.contractName, + input.method, + input.rpcUrl, + "prepare" ); } } else { diff --git a/packages/core/src/errors/CaatingaErrorCode.ts b/packages/core/src/errors/CaatingaErrorCode.ts index 87864a67..389848f5 100644 --- a/packages/core/src/errors/CaatingaErrorCode.ts +++ b/packages/core/src/errors/CaatingaErrorCode.ts @@ -37,6 +37,9 @@ export const CaatingaErrorCode = { PLACEHOLDER_BINDING: "CAATINGA_PLACEHOLDER_BINDING", XDR_BUILD_FAILED: "CAATINGA_XDR_BUILD_FAILED", XDR_PREPARE_FAILED: "CAATINGA_XDR_PREPARE_FAILED", + SIMULATION_ACCOUNT_NOT_FOUND: "CAATINGA_SIMULATION_ACCOUNT_NOT_FOUND", + SIMULATION_RESOURCE_LIMIT: "CAATINGA_SIMULATION_RESOURCE_LIMIT", + SIMULATION_ENTRY_ARCHIVED: "CAATINGA_SIMULATION_ENTRY_ARCHIVED", XDR_SIGN_FAILED: "CAATINGA_XDR_SIGN_FAILED", XDR_SUBMIT_FAILED: "CAATINGA_XDR_SUBMIT_FAILED", XDR_RESULT_FAILED: "CAATINGA_XDR_RESULT_FAILED", diff --git a/packages/core/src/errors/error-surface.test.ts b/packages/core/src/errors/error-surface.test.ts index bd566ea9..77c8e53d 100644 --- a/packages/core/src/errors/error-surface.test.ts +++ b/packages/core/src/errors/error-surface.test.ts @@ -186,6 +186,18 @@ const productionTriggerTests: Record