Multi-step off-chain scoring with durable child promises in TypeScript

A DAO contract emits ProposalCreated; a server-side process needs to run a four-step scoring workflow (fetch votes, score reputations, check eligibility, compute weighted result) and submit the result back to the contract. Without a durable execution layer, a crash partway through means re-running every step and double-charging RPC reads. The example wraps each step in ctx.run so completed steps replay from cache and the orchestrator picks up where it left off. It also shows the integration shape: a blockchain event listener that uses resonate.rpc to invoke a workflow on a named worker group.

The shape of the solution

The orchestrating function is a generator. Each step is a child promise yielded through ctx.run.

export function* scoreProposal(
ctx: Context,
proposalId: number
): Generator<any, ScoringResult, any> {
// ...
const proposalCreatedAt = Date.now() - (7 * 24 * 60 * 60 * 1000);

// Step 1: Fetch all votes (durable child promise)
// ...
const votes = yield* ctx.run(
fetchVotes,
proposalId,
ctx.options({})
);

// Step 2: Calculate voter reputations (durable child promise)
// ...
const reputations = yield* ctx.run(
getReputations,
votes,
ctx.options({})
);

// Step 3: Check voter eligibility (durable child promise)
// ...
const eligibility = yield* ctx.run(
checkEligibility,
votes,
proposalCreatedAt,
ctx.options({})
);

// Step 4: Calculate final score (durable child promise)
// ...
const result = yield* ctx.run(
calculateScore,
votes,
reputations,
eligibility,
proposalCreatedAt,
ctx.options({})
);

// ...
return {
proposalId,
finalScore: result.finalScore,
breakdown: result.breakdown,
proofHash: '',
};
}
// from example-dao-proposal-scorer-ts/resonate-layer/src/functions/scoreProposal.ts:32-93

The entry point is a resonate.rpc call from the blockchain listener, routed by group name:

contract.on('ProposalCreated', async (proposalId: any, contentHash: any, proposer: any, event: any) => {
const promiseId = `scoreProposal.${proposalId}`;

const result: any = await resonate.rpc(
promiseId,
'scoreProposal',
Number(proposalId),
resonate.options({ target: 'poll://any@proposal-scorers' })
);

// ...
const proofHash = await getMerkleRoot(resonate, promiseId);
// ...
const tx = await contract.submitScore(
proposalId,
result.finalScore,
proofHash
);
// ...
});
// from example-dao-proposal-scorer-ts/resonate-layer/src/blockchain/listener.ts:69-108

The durable primitives in play

ctx.run(fn, ...args, ctx.options({})) — creates a durable child promise; if the parent replays after a crash, completed children return their cached result rather than re-executing. Used at resonate-layer/src/functions/scoreProposal.ts:47, 55, 63, 72.

ctx.options({}) — per-invocation options object passed as the trailing argument to ctx.run. Same file, same lines.

ctx.id — the promise id of the current invocation; the example reads it to log the run and (in its wrapper) tag commitments. resonate-layer/src/functions/scoreProposal.ts:37.

resonate.register(name, fn) — registers a function on the worker so the server can dispatch it. All five functions are registered: resonate-layer/src/worker.ts:44, 48, 51, 54, 57.

resonate.rpc(promiseId, fnName, arg, resonate.options({ target })) — client-side call that creates a durable promise and routes it to a worker group via the target URI. resonate-layer/src/blockchain/listener.ts:83-88.

resonate.options({ target: 'poll://any@proposal-scorers' }) — directs the RPC to any worker in the proposal-scorers group. The matching group name is set on the worker at resonate-layer/src/worker.ts:31.

The orchestrator does not use ctx.sleep, ctx.detached, or ctx.beginRun. The shape is sequential durable steps with an RPC entry point.

What the SDK handles vs. what you write

The SDK handles: persisting each ctx.run result before the next step begins; replaying the generator on worker crash and short-circuiting completed children; routing the rpc call to the worker group named in target; retrying failed child promises per the configured policy.

You write: the orchestrating generator function and its four leaf functions; the registration calls on the worker; the listener that turns a chain event into an rpc call and the chain transaction that submits the result. You also write whatever idempotency guarantees the leaf functions need — fetchVotes is a contract read so it is naturally idempotent; the other three are pure computations over their inputs.

One thing in this repo that is not an SDK feature: the CryptoResonate class (resonate-layer/src/crypto/CryptoResonate.ts:20) is an app-level subclass that overrides register to wrap each function with input/output hashing and builds a SHA-256 merkle tree over the resulting commitments. It uses public SDK methods (super.register, this.promises.get) to do this. The SDK does not natively produce a merkle root for a promise tree; the example layers one on top.

Failure modes covered

Worker crash between steps 1 and 2 (or 2 and 3, etc.) — the parent promise's recorded child results survive the crash. When a new worker picks up the parent, the generator replays; each completed ctx.run returns its cached value and execution resumes at the first unfinished child. Grounded in the structure of resonate-layer/src/functions/scoreProposal.ts:47-79.

Worker crash mid-step — the unfinished child promise is re-dispatched. fetchVotes is a contract read (resonate-layer/src/functions/fetchVotes.ts:26); getReputations, checkEligibility, and calculateScore are pure functions of their inputs. Re-running them is safe.

Errors thrown inside a step — fetchVotes catches its provider error and rethrows a wrapped Error (resonate-layer/src/functions/fetchVotes.ts:37-40); the SDK applies its retry policy to the failing child promise without re-running the completed earlier steps.

Listener crash after the RPC completes but before submitScore lands on chain — not handled by this example. The score itself is durable inside Resonate under the id scoreProposal.{proposalId} and can be re-fetched, but the chain submission is a bare await outside any ctx.run (resonate-layer/src/blockchain/listener.ts:104-108). A production version would wrap the chain submission in its own durable promise.

When to reach for this pattern

If you have a multi-step workflow triggered by an external event and want each step to be an independent checkpoint.

If your steps are expensive (contract reads, historical queries, paid API calls) and you do not want to re-run completed steps after a crash.

If you want a single RPC entry point that routes to a worker pool by group name rather than addressing individual workers.

If you need to feed a result back to a system of record (here: a smart contract) and want the workflow result itself to be durable, fetchable by id.

If you want each leaf function to be retried independently when it fails, without restarting the orchestrator.

Sources

Example repo: https://github.com/resonatehq-examples/example-dao-proposal-scorer-ts

Orchestrator: resonate-layer/src/functions/scoreProposal.ts

Worker registration: resonate-layer/src/worker.ts

Listener and RPC entry point: resonate-layer/src/blockchain/listener.ts

Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts

SDK Context.run / Context.options / Context.id definitions: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts (lines 208–209 for run, 244 for options)

SDK Resonate.rpc definition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L373-L376

SDK Resonate.register definition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L253-L266

SDK Resonate.options definition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L518

Resonate documentation: https://docs.resonatehq.io