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-93The 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-108The 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 atresonate-layer/src/functions/scoreProposal.ts:47, 55, 63, 72.ctx.options({})— per-invocation options object passed as the trailing argument toctx.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 thetargetURI.resonate-layer/src/blockchain/listener.ts:83-88.resonate.options({ target: 'poll://any@proposal-scorers' })— directs the RPC to any worker in theproposal-scorersgroup. The matching group name is set on the worker atresonate-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.runreturns its cached value and execution resumes at the first unfinished child. Grounded in the structure ofresonate-layer/src/functions/scoreProposal.ts:47-79. - Worker crash mid-step — the unfinished child promise is re-dispatched.
fetchVotesis a contract read (resonate-layer/src/functions/fetchVotes.ts:26);getReputations,checkEligibility, andcalculateScoreare pure functions of their inputs. Re-running them is safe. - Errors thrown inside a step —
fetchVotescatches its provider error and rethrows a wrappedError(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
submitScorelands on chain — not handled by this example. The score itself is durable inside Resonate under the idscoreProposal.{proposalId}and can be re-fetched, but the chain submission is a bareawaitoutside anyctx.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.iddefinitions: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts (lines 208–209 forrun, 244 foroptions) - SDK
Resonate.rpcdefinition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L373-L376 - SDK
Resonate.registerdefinition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L253-L266 - SDK
Resonate.optionsdefinition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L518 - Resonate documentation: https://docs.resonatehq.io
