Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/soroban-simulation-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@caatinga/core": minor
"@caatinga/client": minor
---

Classify common Soroban simulation failures and add opt-in archived-state restoration to `invoke()`.
19 changes: 19 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
35 changes: 19 additions & 16 deletions docs/errors.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions packages/client/src/bindings/default-binding-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});

Expand Down
12 changes: 10 additions & 2 deletions packages/client/src/bindings/default-binding-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const fn = candidate[method];

Expand All @@ -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);
}
};
}
45 changes: 38 additions & 7 deletions packages/client/src/client/caatinga-contract-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ function createClientConfig(overrides: Record<string, unknown> = {}) {
};

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";
Expand Down Expand Up @@ -137,6 +137,10 @@ function createClientConfig(overrides: Record<string, unknown> = {}) {
};
}

missingAccount() {
return Promise.reject(new Error("ACCOUNT_NOT_FOUND"));
}

failingSubmit() {
return {
toXDR() {
Expand Down Expand Up @@ -254,6 +258,33 @@ describe("CaatingaContractClient (via createCaatingaClient)", () => {
await expect(client.contract("counter").read<number>("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());

Expand Down
111 changes: 71 additions & 40 deletions packages/client/src/client/caatinga-contract-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -60,9 +63,15 @@ export class CaatingaContractClient {
argsOrOptions?: Record<string, unknown> | CaatingaInvokeOptions,
maybeOptions?: CaatingaInvokeOptions
): Promise<CaatingaInvokeResult<T>> {
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,
Expand Down Expand Up @@ -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<void> {
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<string, unknown>,
options: { readOnly?: boolean; sourceAccount?: string } = {}
methodOptions?: Record<string, unknown>
) {
const contractId = resolveContractId({
artifacts: this.config.artifacts,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -364,4 +356,43 @@ export class CaatingaContractClient {

return typeof publicKey === "string" && publicKey.trim().length > 0 ? publicKey : undefined;
}

private createRestoreMethodOptions(method: string): Record<string, unknown> {
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 };
},
};
}
}
9 changes: 8 additions & 1 deletion packages/client/src/client/invoke-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,26 @@ export function splitInvokeArgsAndOptions(
argsOrOptions?: Record<string, unknown> | 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,
};
}

return {
args: argsOrOptions as Record<string, unknown> | undefined,
debugXdr: maybeOptions?.debugXdr ?? false,
debugRaw: maybeOptions?.debugRaw ?? false,
restore: maybeOptions?.restore ?? false,
};
}

Expand Down
54 changes: 53 additions & 1 deletion packages/client/src/client/transaction-simulate.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -48,4 +52,52 @@ describe("transaction-simulate", () => {

expect(readSimulationResult<boolean>(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" }),
}),
});
});
});
Loading