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 fb1dadf8..c4f5df51 100644 --- a/docs/client.md +++ b/docs/client.md @@ -184,6 +184,22 @@ console.log(result.raw); 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. ### Source account @@ -419,6 +435,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 ccd38390..7f053a49 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -77,22 +77,25 @@ error-code table because they are not errors. ## Client / wallet -| Code | Meaning | Common cause | User action | CI/release action | Versioning note | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `CAATINGA_CONTRACT_ARTIFACT_NOT_FOUND` | Client could not resolve a deployed contract artifact. | The selected network is missing from artifacts, the contract was not deployed on that network, or no explicit `contractId` was passed. | Run `ctg doctor --network `. Deploy the contract with `ctg deploy --network --source `. Alternatively, provide `contractId` in the client registration. `ctg build` alone does not register a contract ID. | Fail CI and make deploy artifacts available to client tests. | Public code; adding a new code is minor, removal/rename/meaning change is major. | -| `CAATINGA_BINDING_CLIENT_NOT_FOUND` | Generated binding did not export `Client`. | Binding output is missing, stale, or incompatible. | Run `ctg generate --network `. | Fail CI and regenerate bindings before publish. | Public code; adding a new code is minor, removal/rename/meaning change is major. | -| `CAATINGA_BINDING_METHOD_NOT_FOUND` | Requested binding method was not found. | Contract method name is wrong or bindings are stale. | Check the method name or regenerate bindings. | Fail CI and update generated bindings or tests. | Public code; adding a new code is minor, removal/rename/meaning change is major. | -| `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_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. | -| `CAATINGA_READ_RESULT_MISSING` | A simulated read did not expose a return value. | `contract(name).simulate()` completed preparation, but the generated binding output lacked a usable `result` property. | Confirm the method returns a value, regenerate bindings, or rerun with `debugRaw` to inspect the binding output. | Fail CI and update the binding adapter or test fixture before release. | Public code; adding a new code is minor, removal/rename/meaning change is major. | -| `CAATINGA_WALLET_NOT_CONNECTED` | Wallet public key could not be obtained before building the binding client. | `wallet.getPublicKey()` rejected or threw (extension locked, disconnected, not installed, or user denied access); failures that are already Caatinga errors are rethrown unchanged. | Connect the wallet, grant account access, and retry. | Fail automated wallet tests with fixture setup; surface as retryable in UI flows. | Public code; adding a new code is minor, removal/rename/meaning change is major. | -| `CAATINGA_WALLET_TIMEOUT` | Wallet operation exceeded the optional client timeout. | `wallet.getPublicKey()` or `wallet.signTransaction()` did not settle within `walletTimeout` ms on `CaatingaClientConfig` (no default when omitted). | Ensure the adapter rejects on user dismissal, connect the wallet, approve the transaction, or increase `walletTimeout`. | Fail automated tests with fake timers; surface as retryable in UI flows. | Public code; adding a new code is minor, removal/rename/meaning change is major. | -| `CAATINGA_WALLET_NETWORK_MISMATCH` | Wallet reports a different network than the app before an `invoke` is signed. | The adapter implements `getNetworkPassphrase()` (the Freighter and Stellar Wallets Kit adapters do) and the wallet is on another network than `config.network.networkPassphrase`, for example Freighter on Testnet while the dApp targets mainnet. Wallets that cannot report their network are not blocked. | Switch the wallet to the network named in the hint, or point the app at the wallet's network. | Fail wallet integration tests that use a mismatched fixture network. | Public code; adding a new code is minor, removal/rename/meaning change is major. | -| `CAATINGA_MULTI_AUTH_REQUIRED` | A contract invoke needs non-invoker signatures (delegated Soroban address credentials / "AddressV2"). | AssembledTransaction.sign raised `NeedsMoreSignaturesError`: the simulated transaction contains unsigned auth entries for accounts other than the invoker/source. | Multi-auth is application-owned in `@caatinga/client` — handle `signAuthEntry` orchestration in application code. Caatinga surfaces this error when the browser adapter cannot satisfy non-invoker auth. | Fail CI for invoke paths that require non-invoker signing. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| Code | Meaning | Common cause | User action | CI/release action | Versioning note | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `CAATINGA_CONTRACT_ARTIFACT_NOT_FOUND` | Client could not resolve a deployed contract artifact. | The selected network is missing from artifacts, the contract was not deployed on that network, or no explicit `contractId` was passed. | Run `ctg doctor --network `. Deploy the contract with `ctg deploy --network --source `. Alternatively, provide `contractId` in the client registration. `ctg build` alone does not register a contract ID. | Fail CI and make deploy artifacts available to client tests. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_BINDING_CLIENT_NOT_FOUND` | Generated binding did not export `Client`. | Binding output is missing, stale, or incompatible. | Run `ctg generate --network `. | Fail CI and regenerate bindings before publish. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_BINDING_METHOD_NOT_FOUND` | Requested binding method was not found. | Contract method name is wrong or bindings are stale. | Check the method name or regenerate bindings. | Fail CI and update generated bindings or tests. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `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. | +| `CAATINGA_READ_RESULT_MISSING` | A simulated read did not expose a return value. | `contract(name).simulate()` completed preparation, but the generated binding output lacked a usable `result` property. | Confirm the method returns a value, regenerate bindings, or rerun with `debugRaw` to inspect the binding output. | Fail CI and update the binding adapter or test fixture before release. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_WALLET_NOT_CONNECTED` | Wallet public key could not be obtained before building the binding client. | `wallet.getPublicKey()` rejected or threw (extension locked, disconnected, not installed, or user denied access); failures that are already Caatinga errors are rethrown unchanged. | Connect the wallet, grant account access, and retry. | Fail automated wallet tests with fixture setup; surface as retryable in UI flows. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_WALLET_TIMEOUT` | Wallet operation exceeded the optional client timeout. | `wallet.getPublicKey()` or `wallet.signTransaction()` did not settle within `walletTimeout` ms on `CaatingaClientConfig` (no default when omitted). | Ensure the adapter rejects on user dismissal, connect the wallet, approve the transaction, or increase `walletTimeout`. | Fail automated tests with fake timers; surface as retryable in UI flows. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_WALLET_NETWORK_MISMATCH` | Wallet reports a different network than the app before an `invoke` is signed. | The adapter implements `getNetworkPassphrase()` (the Freighter and Stellar Wallets Kit adapters do) and the wallet is on another network than `config.network.networkPassphrase`, for example Freighter on Testnet while the dApp targets mainnet. Wallets that cannot report their network are not blocked. | Switch the wallet to the network named in the hint, or point the app at the wallet's network. | Fail wallet integration tests that use a mismatched fixture network. | Public code; adding a new code is minor, removal/rename/meaning change is major. | +| `CAATINGA_MULTI_AUTH_REQUIRED` | A contract invoke needs non-invoker signatures (delegated Soroban address credentials / "AddressV2"). | AssembledTransaction.sign raised `NeedsMoreSignaturesError`: the simulated transaction contains unsigned auth entries for accounts other than the invoker/source. | Multi-auth is application-owned in `@caatinga/client` — handle `signAuthEntry` orchestration in application code. Caatinga surfaces this error when the browser adapter cannot satisfy non-invoker auth. | Fail CI for invoke paths that require non-invoker signing. | Public code; adding a new code is minor, removal/rename/meaning change is major. | ## Templates 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 af2b8359..8e92d5d3 100644 --- a/packages/client/src/bindings/default-binding-adapter.ts +++ b/packages/client/src/bindings/default-binding-adapter.ts @@ -44,7 +44,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]; @@ -56,7 +56,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 f7a54192..4c5dc789 100644 --- a/packages/client/src/client/caatinga-contract-client.test.ts +++ b/packages/client/src/client/caatinga-contract-client.test.ts @@ -40,14 +40,14 @@ function createClientConfig(overrides: Record = {}) { }; class Client { - public readonly clientInput?: { publicKey?: string }; - - constructor(input?: { publicKey?: string }) { - this.clientInput = input; - lastClientInput = input; - } + async increment(options?: { + restore?: boolean; + signTransaction?: (xdr: string) => Promise<{ signedTxXdr: string }>; + }) { + if (options?.restore) { + await options.signTransaction?.("AAAA_RESTORE"); + } - increment() { return { toXDR() { return "AAAA_UNSIGNED"; @@ -137,6 +137,10 @@ function createClientConfig(overrides: Record = {}) { }; } + missingAccount() { + return Promise.reject(new Error("ACCOUNT_NOT_FOUND")); + } + failingSubmit() { return { toXDR() { @@ -254,6 +258,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 d11e4840..195c931c 100644 --- a/packages/client/src/client/caatinga-contract-client.ts +++ b/packages/client/src/client/caatinga-contract-client.ts @@ -19,8 +19,11 @@ import { splitInvokeArgsAndOptions, splitReadArgsAndOptions, } from "./invoke-args.js"; -import { assertReadSourceAccount, normalizeReadSourceAccount } from "./read-source.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"; @@ -60,9 +63,15 @@ export class CaatingaContractClient { argsOrOptions?: Record | CaatingaInvokeOptions, maybeOptions?: CaatingaInvokeOptions ): Promise> { - const { args, debugXdr, debugRaw } = splitInvokeArgsAndOptions(argsOrOptions, maybeOptions); - await this.assertWalletNetwork(method); - 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, @@ -196,44 +205,10 @@ export class CaatingaContractClient { return result.result; } - /** - * Fails before building or signing when the wallet reports a different network than - * the app, instead of a generic sign/submit failure later. Wallets that cannot report - * their network (adapter method missing, returns undefined, or throws) are not blocked. - */ - private async assertWalletNetwork(method: string): Promise { - const { wallet, network } = this.config; - if (!wallet || !wallet.getNetworkPassphrase) { - return; - } - - let walletPassphrase: string | undefined; - try { - walletPassphrase = await withWalletTimeout( - "getNetworkPassphrase", - this.config.walletTimeout, - () => wallet.getNetworkPassphrase!() - ); - } catch (error) { - if (error instanceof CaatingaError && error.code === CaatingaErrorCode.WALLET_TIMEOUT) { - throw error; - } - return; - } - - if (walletPassphrase && walletPassphrase !== network.networkPassphrase) { - throw new CaatingaError( - `Wallet is on a different network than the app for "${this.contractName}.${method}".`, - CaatingaErrorCode.WALLET_NETWORK_MISMATCH, - `Switch the wallet to "${network.name}" (${network.networkPassphrase}); it is on "${walletPassphrase}".` - ); - } - } - private async createTransaction( method: string, args?: Record, - options: { readOnly?: boolean; sourceAccount?: string } = {} + methodOptions?: Record ) { const contractId = resolveContractId({ artifacts: this.config.artifacts, @@ -286,6 +261,23 @@ export class CaatingaContractClient { error ); } + const client = this.bindingAdapter.createClient({ + contractId, + publicKey, + rpcUrl: this.config.network.rpcUrl, + networkPassphrase: this.config.network.networkPassphrase, + }); + 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); + } if (typeof publicKey !== "string" || publicKey.trim().length === 0) { throw new CaatingaError( @@ -364,4 +356,43 @@ export class CaatingaContractClient { return typeof publicKey === "string" && publicKey.trim().length > 0 ? publicKey : undefined; } + + 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 65411b56..e4461c40 100644 --- a/packages/client/src/client/invoke-args.ts +++ b/packages/client/src/client/invoke-args.ts @@ -33,12 +33,18 @@ export function splitInvokeArgsAndOptions( argsOrOptions?: Record | CaatingaInvokeOptions, maybeOptions?: CaatingaInvokeOptions ) { - if (maybeOptions === undefined && isOptionsObject(argsOrOptions, INVOKE_OPTION_KEYS)) { + const looksLikeOptions = + argsOrOptions !== undefined && + ("debugXdr" in argsOrOptions || "debugRaw" in argsOrOptions || "restore" in argsOrOptions) && + maybeOptions === undefined; + + if (looksLikeOptions) { const options = argsOrOptions as CaatingaInvokeOptions; return { args: undefined, debugXdr: options.debugXdr ?? false, debugRaw: options.debugRaw ?? false, + restore: options.restore ?? false, }; } @@ -46,6 +52,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 4ecab4dc..00963a5b 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -71,6 +71,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 { @@ -143,5 +145,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 98eef789..1da0d7a1 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 d09f2ceb..fbd03ecb 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