Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/checklists/issue-337.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Acceptance Criteria Checklist — Issue #337

> Generated for pre-PR verification. Confirm each item, then run:
>
> ```bash
> npm run verify:pr -- --checklist .github/checklists/issue-337.md
> ```

## Issue

- **Number:** #337
- **Title:** Add SDK transaction simulation result mapper

## Acceptance Criteria

- [x] Simulation result mapper is implemented
- [x] Success, warning, failed, unsupported, and unknown states are represented
- [x] Soroban client uses the mapper
- [x] Tests cover representative simulation responses
- [x] Errors are typed and safe
- [x] Documentation explains simulation handling

## Contributor confirmations

- [ ] Automated checks passed (`npm run verify:pr`)
- [x] Tests added or updated for behaviour changes
- [x] Documentation updated when public behaviour changed
- [ ] PR description maps each acceptance criterion to the change
- [x] No secrets or `.env` values committed
6 changes: 6 additions & 0 deletions docs/contract-client-factory.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,15 @@ interface ContractInvokeResult<T = unknown> {
value?: T; // Parsed return value
error?: string; // Error message if failed
errorCode?: string | number; // Stable SDK or contract-specific failure code
/** Typed simulation classification when relevant (see simulation-result-mapping.md) */
simulationStatus?: 'success' | 'warning' | 'failed' | 'unsupported' | 'unknown';
warnings?: Array<{ code: string; message: string }>;
}
```

Simulation responses are classified by `mapSimulationResult` before assemble/sign.
See [Simulation result mapping](./simulation-result-mapping.md).

---

## Error Handling
Expand Down
14 changes: 9 additions & 5 deletions docs/signing-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ called.

## Simulation before signing

For state-changing smart contract calls (Soroban), a simulation step must occur **before** the transaction crosses the signing boundary. The `simulateContractCall()` function executes a dry run against the network, returning a `ContractSimulationResult`.
For state-changing smart contract calls (Soroban), a simulation step must occur **before** the transaction crosses the signing boundary. The `simulateContractCall()` function executes a dry run against the network, returning a `ContractSimulationResult` with a typed `status` (`success` | `warning` | `failed` | `unsupported` | `unknown`). See [Simulation result mapping](./simulation-result-mapping.md).

This ensures that:
1. **Errors are caught early**: If a contract call will fail, it fails during simulation before prompting the user for a signature.
Expand All @@ -223,13 +223,17 @@ const params: ContractSimulationParams = {
const result = await simulateContractCall(params);

if (!result.success) {
// Handle simulation failure (e.g. invalid arguments, contract trapped)
console.error("Simulation failed:", result.error);
// failed | unsupported | unknown — do not sign
console.error('Simulation failed:', result.status, result.error);
return;
}

if (result.status === 'warning') {
console.warn('Simulation advisories:', result.warnings);
}

// Proceed to build and sign the transaction using result metrics
console.log("Required CPU:", result.cost?.cpuInstructions);
console.log("Estimated Fee:", result.cost?.minResourceFee);
console.log('Required CPU:', result.cost?.cpuInstructions);
console.log('Estimated Fee:', result.cost?.minResourceFee);
```

69 changes: 69 additions & 0 deletions docs/simulation-result-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Simulation result mapping

The SDK converts raw Soroban `simulateTransaction` responses into a typed
{@link SimulationMappedResult} with one of five statuses:

| Status | `success` | Meaning |
| --- | --- | --- |
| `success` | `true` | Simulation completed; safe to assemble / sign when applicable |
| `warning` | `true` | Simulation completed with non-fatal advisories (events, RPC warnings) |
| `failed` | `false` | Simulation returned a contract or runtime error |
| `unsupported` | `false` | Response requires a path this client cannot complete (e.g. state restore) |
| `unknown` | `false` | Response shape could not be classified safely |

## API

```ts
import {
mapSimulationResult,
simulateContractCall,
pocketPayErrorFromSimulation,
ErrorCode,
} from 'stellar-pocketpay-sdk';

const mapped = mapSimulationResult(rawRpcResponse);

if (!mapped.success) {
// failed | unsupported | unknown — do not sign
throw pocketPayErrorFromSimulation(mapped);
}

if (mapped.status === 'warning') {
// Inspect mapped.warnings before proceeding
}

// success — use cost metrics / retval as needed
console.log(mapped.cost?.minResourceFee);
```

`simulateContractCall()` runs a dry-run against Soroban RPC and returns the same
mapped shape (`ContractSimulationResult`).

## Soroban client integration

`ContractClient` (`readOnly`, `invoke`, and auth simulation) runs every response
through `mapSimulationResult` before assembling or signing:

- **readOnly** — throws a typed `PocketPayError` when `success` is false
- **invoke** — returns `{ success: false, status: 'simulation_error', simulationStatus }`
without signing when simulation is not proceedable
- **warning** — treated as proceedable; `warnings` are attached to successful invoke results

Error codes:

- `SOROBAN_SIMULATION_FAILED` — `failed`
- `SOROBAN_SIMULATION_UNSUPPORTED` — `unsupported` (e.g. restore preamble)
- `SOROBAN_SIMULATION_UNKNOWN` — unclassifiable payload

Contract-specific remaps (via `ContractClient` error maps) still apply on the
simulation `error` string before the default Soroban codes.

## Safety

- Mapped results may include `rawSimulation` for diagnostics; the SDK does not
log raw RPC payloads.
- Prefer checking `status` (not only `success`) when handling restore /
unknown cases differently from contract failures.
- Do not prompt for signatures when `success` is `false`.

See also [Signing boundaries](./signing-boundaries.md).
16 changes: 16 additions & 0 deletions src/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export const ErrorCode = {
// ─── Soroban ────────────────────────────────────────────────────────────────
SOROBAN_CONTRACT_ERROR: 'SOROBAN_CONTRACT_ERROR',
SOROBAN_SIMULATION_FAILED: 'SOROBAN_SIMULATION_FAILED',
SOROBAN_SIMULATION_UNSUPPORTED: 'SOROBAN_SIMULATION_UNSUPPORTED',
SOROBAN_SIMULATION_UNKNOWN: 'SOROBAN_SIMULATION_UNKNOWN',
SOROBAN_RPC_UNAVAILABLE: 'SOROBAN_RPC_UNAVAILABLE',
SOROBAN_INVALID_RESPONSE: 'SOROBAN_INVALID_RESPONSE',

Expand Down Expand Up @@ -319,6 +321,20 @@ export const ERROR_CODES: Record<ErrorCodeValue, ErrorCodeSpec> = {
safeMessage: 'Contract simulation failed. Please try again.',
developerHint: 'Often transient RPC; retry before giving up.',
},
[ErrorCode.SOROBAN_SIMULATION_UNSUPPORTED]: {
category: ErrorCategory.Soroban,
retryable: false,
safeMessage: 'This contract call cannot be completed as simulated.',
developerHint:
'Response requires a path the client does not support (e.g. state restore). Restore ledger entries, then retry.',
},
[ErrorCode.SOROBAN_SIMULATION_UNKNOWN]: {
category: ErrorCategory.Soroban,
retryable: false,
safeMessage: 'The simulation response could not be interpreted.',
developerHint:
'RPC returned an unexpected simulation shape; inspect rawSimulation in diagnostics only.',
},
[ErrorCode.SOROBAN_RPC_UNAVAILABLE]: {
category: ErrorCategory.Soroban,
retryable: true,
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,13 @@ export {
mapSorobanInvocationResult,
mapVaultInvocationResult,
mapSorobanContractError,
mapSimulationResult,
pocketPayErrorFromSimulation,
simulationStatusToInvocationStatus,
simulateContractCall,
} from './soroban';
export type {
MapSimulationResultOptions,
} from './soroban';

// ─── Vault capability model and action intents (issue #274) ─────────────────
Expand Down
96 changes: 55 additions & 41 deletions src/soroban/client-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ import {
validatePublicKey,
validateSecretKey,
} from '../utils';
import { mapSorobanContractError } from './mapper';

import {
mapSorobanContractError,
mapSimulationResult,
pocketPayErrorFromSimulation,
simulationStatusToInvocationStatus,
} from './mapper';
import type { SimulationMappedResult, SimulationWarning } from '../types';
// ─── Type Definitions ───────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -68,6 +73,13 @@ export interface ContractInvokeResult<T = unknown> {
error?: string;
/** Contract-specific or SDK error code if failed. */
errorCode?: string | number;
/**
* Typed simulation classification when the invoke path stopped (or warned)
* at simulation. See {@link mapSimulationResult}.
*/
simulationStatus?: import('../types').SimulationResultStatus;
/** Non-fatal simulation advisories when status is warning. */
warnings?: SimulationWarning[];
}

/**
Expand Down Expand Up @@ -237,22 +249,12 @@ export class ContractClient<
this.sorobanServer.simulateTransaction(tx),
);

if (StellarSDK.rpc.Api.isSimulationError(simulated)) {
const mappedError = this.mapContractError((simulated as any).error);
throw new PocketPayError(
`Simulation failed: ${mappedError.error}`,
String(
mappedError.errorCode ?? ErrorCode.SOROBAN_SIMULATION_FAILED,
),
{
cause: new Error(mappedError.error),
category: ERROR_CODES[ErrorCode.SOROBAN_SIMULATION_FAILED].category,
safeMessage: ERROR_CODES[ErrorCode.SOROBAN_SIMULATION_FAILED].safeMessage,
},
);
const mapped = this.mapSimulation(simulated);
if (!mapped.success) {
throw pocketPayErrorFromSimulation(mapped);
}

const success = simulated as StellarSDK.rpc.Api.SimulateTransactionSuccessResponse;
const success = mapped.rawSimulation as StellarSDK.rpc.Api.SimulateTransactionSuccessResponse;
return success.result?.auth ? [...success.result.auth] : [];
}

Expand Down Expand Up @@ -283,26 +285,18 @@ export class ContractClient<
this.sorobanServer.simulateTransaction(tx),
);

if (StellarSDK.rpc.Api.isSimulationError(simulated)) {
const mappedError = this.mapContractError((simulated as any).error);
throw new PocketPayError(
`Simulation failed: ${mappedError.error}`,
String(
mappedError.errorCode ?? ErrorCode.SOROBAN_SIMULATION_FAILED,
),
{
cause: new Error(mappedError.error),
category: ERROR_CODES[ErrorCode.SOROBAN_SIMULATION_FAILED].category,
safeMessage: ERROR_CODES[ErrorCode.SOROBAN_SIMULATION_FAILED].safeMessage,
}
);
const mapped = this.mapSimulation(
simulated,
resultParser
? (retval) => resultParser(retval as StellarSDK.xdr.ScVal)
: undefined,
);
if (!mapped.success) {
throw pocketPayErrorFromSimulation(mapped);
}

// Extract and parse return value
const successSim = simulated as StellarSDK.rpc.Api.SimulateTransactionSuccessResponse;
if (successSim.result && successSim.result.retval) {
const parser = resultParser || StellarSDK.scValToNative;
return parser(successSim.result.retval) as T;
if (mapped.result !== undefined) {
return mapped.result as T;
}

return undefined as T;
Expand Down Expand Up @@ -352,19 +346,24 @@ export class ContractClient<
this.sorobanServer.simulateTransaction(tx),
);

if (StellarSDK.rpc.Api.isSimulationError(simulated)) {
const mappedError = this.mapContractError((simulated as any).error);
const mapped = this.mapSimulation(simulated);
if (!mapped.success) {
return {
success: false,
status: 'simulation_error',
error: `Simulation failed: ${mappedError.error}`,
errorCode:
mappedError.errorCode ?? ErrorCode.SOROBAN_SIMULATION_FAILED,
status: simulationStatusToInvocationStatus(mapped.status),
error: mapped.error ?? 'Simulation failed',
errorCode: mapped.errorCode ?? ErrorCode.SOROBAN_SIMULATION_FAILED,
simulationStatus: mapped.status,
};
}

// Prepare and sign the transaction
const prepared = StellarSDK.rpc.assembleTransaction(tx, simulated).build();
const prepared = StellarSDK.rpc
.assembleTransaction(
tx,
mapped.rawSimulation as StellarSDK.rpc.Api.SimulateTransactionResponse,
)
.build();
prepared.sign(keypair);

// Submit the transaction
Expand Down Expand Up @@ -399,6 +398,8 @@ export class ContractClient<
status: 'success',
hash: sendResult.hash,
value,
simulationStatus: mapped.status,
warnings: mapped.warnings,
};
}

Expand Down Expand Up @@ -565,6 +566,19 @@ export class ContractClient<
return mapped;
}

/**
* Classifies a raw `simulateTransaction` response via {@link mapSimulationResult}.
*/
private mapSimulation(
simulated: unknown,
parseRetval?: (retval: unknown) => unknown,
): SimulationMappedResult {
return mapSimulationResult(simulated, {
mapError: (error) => this.mapContractError(error),
parseRetval,
});
}

/**
* Wraps an error in a PocketPayError.
*/
Expand Down
7 changes: 6 additions & 1 deletion src/soroban/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export {
mapSorobanInvocationResult,
mapVaultInvocationResult,
mapSorobanContractError,
};
mapSimulationResult,
pocketPayErrorFromSimulation,
simulationStatusToInvocationStatus,
} from './mapper';
export type { MapSimulationResultOptions } from './mapper';


// ─── Contract Client Factory ─────────────────────────────────────────────────────
export {
Expand Down
Loading