Skip to content

Commit 699cf3a

Browse files
Heazzy500David-patrick-chuksclaude
authored
feat(sdk): add preflight simulation helpers for round transactions (#65)
Introduce typed preflight* methods on SubRosaClient so integrators can simulate create, commit, reveal, clear, settle, and void calls before submitting signed transactions. Closes #43 Co-authored-by: Heazzy500 <285961681+Heazzy500@users.noreply.github.com> Co-authored-by: David-patrick-chuks <pd3072894@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f43252a commit 699cf3a

8 files changed

Lines changed: 809 additions & 2 deletions

File tree

docs/INTEGRATION.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,59 @@ await client.commit({
5757
After Drand round `R` is published, any keeper or participant can submit the
5858
Drand signature, reveal valid entries, clear the round, and settle escrow.
5959

60+
## Preflight simulation
61+
62+
Before signing and submitting a state-changing call, integrators can simulate
63+
the transaction against Soroban RPC to see whether it is likely to succeed:
64+
65+
```ts
66+
const preflight = await client.preflightCommit({
67+
roundId,
68+
sealed,
69+
escrow,
70+
});
71+
72+
if (!preflight.ok) {
73+
if (preflight.error.kind === "contract_error") {
74+
console.error(
75+
"Contract rejected commit:",
76+
preflight.error.contractErrorMessage,
77+
);
78+
} else {
79+
console.error("Preflight failed:", preflight.error.message);
80+
}
81+
return;
82+
}
83+
84+
console.log("Estimated fee (stroops):", preflight.fee.transactionFee);
85+
console.log("Min resource fee:", preflight.fee.minResourceFee?.toString());
86+
87+
await client.commit({ roundId, sealed, escrow });
88+
```
89+
90+
Each mutating `SubRosaClient` method has a matching `preflight*` helper:
91+
92+
| Submit | Preflight |
93+
| --- | --- |
94+
| `createRound` | `preflightCreateRound` |
95+
| `commit` | `preflightCommit` |
96+
| `openReveal` | `preflightOpenReveal` |
97+
| `reveal` | `preflightReveal` |
98+
| `clear` | `preflightClear` |
99+
| `settle` | `preflightSettle` |
100+
| `void` | `preflightVoid` |
101+
102+
Preflight results include:
103+
104+
- `ok` — whether simulation indicates the call would succeed
105+
- `fee` — estimated transaction and minimum resource fees when available
106+
- `resources` — CPU/memory footprint estimates when available
107+
- `error` — typed `SubRosaPreflightError` for RPC failures, simulation errors,
108+
expired contract state, or decoded Round contract error codes
109+
110+
Existing submit methods are unchanged; preflight is optional and does not
111+
require live signing credentials beyond a source `publicKey` (or `secretKey`).
112+
60113
## Grant scoring pilot template
61114

62115
For SCF-style sealed grant scoring (multiple projects, panel judges, ranked

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
".": "./src/index.ts"
2525
},
2626
"scripts": {
27-
"test": "node --import tsx --test src/client.test.ts src/encoding.test.ts src/errors.test.ts src/mainnet-readiness.test.ts src/redact.test.ts",
27+
"test": "node --import tsx --test src/client.test.ts src/encoding.test.ts src/errors.test.ts src/ids.test.ts src/mainnet-readiness.test.ts src/preflight.test.ts src/public-api-snapshot.test.ts src/redact.test.ts src/verify.test.ts",
2828
"typecheck": "tsc --noEmit -p tsconfig.json"
2929
},
3030
"dependencies": {

packages/sdk/src/client.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import type { RoundReceipt } from "./receipt.js";
2626
import { validateEncryptedBlob } from "./encrypted-blob.js";
2727
import { networkFingerprint } from "./receipt.js";
2828
import type { TransactionSubmitter } from "./submitter.js";
29+
import {
30+
evaluatePreflight,
31+
classifyPreflightBuildError,
32+
type PreflightOperation,
33+
type PreflightResult,
34+
} from "./preflight.js";
2935
import {
3036
SubRosaClientConfigError,
3137
SubRosaMissingReturnValueError,
@@ -71,6 +77,10 @@ export interface SubRosaClientConfig {
7177
* @internal Testing hook: override the poll-loop sleep function.
7278
*/
7379
_sleep?: (ms: number) => Promise<void>;
80+
/**
81+
* @internal Testing hook: inject a mock Soroban RPC server for simulation.
82+
*/
83+
_server?: rpc.Server;
7484
}
7585

7686
export type ClearingRuleTag = ClearingRule["tag"];
@@ -174,6 +184,7 @@ export class SubRosaClient {
174184
allowHttp,
175185
...(source ? { publicKey: source } : {}),
176186
...(signer ? { signTransaction: signer.signTransaction } : {}),
187+
...(config._server ? { server: config._server } : {}),
177188
});
178189
}
179190

@@ -348,6 +359,117 @@ export class SubRosaClient {
348359
await this.#sendUnwrap(tx);
349360
}
350361

362+
// ── Preflight simulation (no signing/submission) ─────────────────────
363+
364+
async #preflight<T>(
365+
operation: PreflightOperation,
366+
buildTx: () => Promise<AssembledTransaction<Result<T>>>,
367+
): Promise<PreflightResult<T>> {
368+
try {
369+
const tx = await buildTx();
370+
return evaluatePreflight(operation, tx);
371+
} catch (error) {
372+
if (error instanceof SubRosaClientConfigError) {
373+
throw error;
374+
}
375+
return {
376+
ok: false,
377+
operation,
378+
error: classifyPreflightBuildError(operation, error),
379+
};
380+
}
381+
}
382+
383+
/** Simulate `createRound` without signing or submitting. */
384+
preflightCreateRound(params: CreateRoundParams): Promise<PreflightResult<bigint>> {
385+
return this.#preflight("create_round", () => {
386+
const operator = params.operator ?? this.#requireSource("operator");
387+
const clearing_rule = {
388+
tag: params.clearingRule ?? "HighestBid",
389+
values: undefined,
390+
} as ClearingRule;
391+
return this.contract.create_round({
392+
operator,
393+
item_ref: toBuffer(params.itemRef),
394+
reveal_round: toBigInt(params.revealRound),
395+
clearing_rule,
396+
commit_deadline: toBigInt(params.commitDeadline),
397+
reveal_deadline: toBigInt(params.revealDeadline),
398+
auditor_pubkey: toBuffer(params.auditorPubkey),
399+
});
400+
});
401+
}
402+
403+
/** Simulate `commit` without signing or submitting. */
404+
preflightCommit(params: CommitParams): Promise<PreflightResult<void>> {
405+
return this.#preflight("commit", () => {
406+
const bidder = params.bidder ?? this.#requireSource("bidder");
407+
return this.contract.commit({
408+
round_id: toBigInt(params.roundId),
409+
bidder,
410+
commitment: toBuffer(params.sealed.commitment),
411+
ciphertext: toBuffer(params.sealed.ciphertext),
412+
escrow: params.escrow,
413+
auditor_blob: toBuffer(params.sealed.auditorBlob),
414+
});
415+
});
416+
}
417+
418+
/** Simulate `openReveal` without signing or submitting. */
419+
preflightOpenReveal(
420+
roundId: number | bigint,
421+
drandSignature: Uint8Array,
422+
): Promise<PreflightResult<void>> {
423+
return this.#preflight("open_reveal", () =>
424+
this.contract.open_reveal({
425+
round_id: toBigInt(roundId),
426+
drand_signature: toBuffer(drandSignature),
427+
}),
428+
);
429+
}
430+
431+
/** Simulate `reveal` without signing or submitting. */
432+
preflightReveal(params: RevealParams): Promise<PreflightResult<void>> {
433+
return this.#preflight("reveal", () =>
434+
this.contract.reveal({
435+
round_id: toBigInt(params.roundId),
436+
bidder: params.bidder,
437+
value: params.value,
438+
nonce: toBuffer(params.nonce),
439+
}),
440+
);
441+
}
442+
443+
/** Simulate `clear` without signing or submitting. */
444+
async preflightClear(
445+
roundId: number | bigint,
446+
): Promise<PreflightResult<string | undefined>> {
447+
const result = await this.#preflight<string | null | undefined>("clear", () =>
448+
this.contract.clear({ round_id: toBigInt(roundId) }),
449+
);
450+
if (!result.ok) {
451+
return result;
452+
}
453+
return {
454+
...result,
455+
result: result.result ?? undefined,
456+
};
457+
}
458+
459+
/** Simulate `settle` without signing or submitting. */
460+
preflightSettle(roundId: number | bigint): Promise<PreflightResult<void>> {
461+
return this.#preflight("settle", () =>
462+
this.contract.settle({ round_id: toBigInt(roundId) }),
463+
);
464+
}
465+
466+
/** Simulate `void` without signing or submitting. */
467+
preflightVoid(roundId: number | bigint): Promise<PreflightResult<void>> {
468+
return this.#preflight("void", () =>
469+
this.contract.void({ round_id: toBigInt(roundId) }),
470+
);
471+
}
472+
351473
// ── Read-only views (simulation only; no signing/submission) ───────────
352474

353475
async getRound(roundId: number | bigint): Promise<Round> {

packages/sdk/src/errors.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,45 @@ export interface TimeoutErrorParams {
4444
pollIntervalMs: number;
4545
}
4646

47+
export type PreflightFailureKind =
48+
| "rpc_error"
49+
| "simulation_error"
50+
| "expired_state"
51+
| "contract_error"
52+
| "malformed_response";
53+
54+
export interface SubRosaPreflightErrorParams {
55+
kind: PreflightFailureKind;
56+
operation: string;
57+
message: string;
58+
simulationError?: string;
59+
contractErrorCode?: number;
60+
contractErrorMessage?: string;
61+
restoreMinResourceFee?: bigint;
62+
cause?: unknown;
63+
}
64+
65+
/** Typed error for preflight/simulation failures before transaction submission. */
66+
export class SubRosaPreflightError extends Error {
67+
readonly name = "SubRosaPreflightError";
68+
readonly kind: PreflightFailureKind;
69+
readonly operation: string;
70+
readonly simulationError?: string;
71+
readonly contractErrorCode?: number;
72+
readonly contractErrorMessage?: string;
73+
readonly restoreMinResourceFee?: bigint;
74+
75+
constructor(params: SubRosaPreflightErrorParams) {
76+
super(params.message, params.cause ? { cause: params.cause } : undefined);
77+
this.kind = params.kind;
78+
this.operation = params.operation;
79+
this.simulationError = params.simulationError;
80+
this.contractErrorCode = params.contractErrorCode;
81+
this.contractErrorMessage = params.contractErrorMessage;
82+
this.restoreMinResourceFee = params.restoreMinResourceFee;
83+
}
84+
}
85+
4786
export class SubRosaTimeoutError extends Error {
4887
readonly name = "SubRosaTimeoutError";
4988
readonly hash: string;

packages/sdk/src/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ export {
77
type ClearingRuleTag,
88
} from "./client.js";
99
export { normalizeRoundId, normalizeSorobanContractId } from "./ids.js";
10+
export {
11+
type PreflightOperation,
12+
type PreflightResult,
13+
type PreflightSuccess,
14+
type PreflightFailureResult,
15+
type PreflightFeeEstimate,
16+
type PreflightResourceEstimate,
17+
evaluatePreflight,
18+
contractErrorCode,
19+
} from "./preflight.js";
1020
export {
1121
createOzChannelsSubmitter,
1222
createOzChannelsSubmitterFromEnv,
@@ -18,11 +28,16 @@ export {
1828
export {
1929
SubRosaClientConfigError,
2030
SubRosaMissingReturnValueError,
31+
SubRosaPreflightError,
2132
SubRosaSubmitError,
2233
SubRosaTimeoutError,
2334
SubRosaTransactionError,
2435
} from "./errors.js";
25-
export type { TimeoutErrorParams } from "./errors.js";
36+
export type {
37+
PreflightFailureKind,
38+
SubRosaPreflightErrorParams,
39+
TimeoutErrorParams,
40+
} from "./errors.js";
2641

2742
export {
2843
validateEncryptedBlob,

0 commit comments

Comments
 (0)