Skip to content

Potential premature settlement success issue: settle returns workflow id before transfer workflow completes #14

Description

@chenshj73

Hi, I noticed a possible premature settlement success issue in the current /settle flow.

The public settlement route returns the result of dispatchSettlement:

27: .post(
28:   '/settle',
29:   async ({ body }) => {
30:     return dispatchSettlement(body)
31:   },

In packages/relay/src/settlements/settlements.service.ts, verifyPayment performs several request-side checks, but it does not itself execute settlement:

22: export async function verifyPayment(req: VerifyRequest): Promise<VerifyResponse> {
23:   const { paymentPayload, paymentRequirements } = req
25:   if (!paymentRequirements.extra?.merchantId) {
34:   if (!supportedCaip2s.includes(paymentRequirements.network)) {
38:   const expectedPayTo = getFacilitatorAddress(paymentRequirements.network)
39:   if (paymentRequirements.payTo !== expectedPayTo) {
43:   if (!paymentPayload.payload.signature || paymentPayload.payload.signature.length < 10) {
47:   const amount = BigInt(paymentRequirements.amount)
52:   return { isValid: true }

dispatchSettlement then marks the replay key before starting a Temporal workflow:

55: export async function dispatchSettlement(req: SettleRequest): Promise<SettleResponse> {
56:   await checkCircuitBreakers(req.paymentRequirements.amount)
57:   const replayKey = req.paymentPayload.payload.signature
58:   if (await checkReplay(replayKey)) throw new ReplayError()
59:   await markProcessed(replayKey)

The settlement request is translated into a workflow start:

78: const txRef = req.paymentPayload.payload.signature.slice(0, 16)
79: const workflowType = isSameChain ? 'same' : 'cross'
80: const workflowId = `${workflowType}-${sellerNetwork}-${buyerNetwork}-${txRef}`
84: const temporal = await getTemporalClient()
86: const { authorization, signature } = req.paymentPayload.payload
90: const params = isSameChain
96:   amount: req.paymentRequirements.amount,
97:   authorization: {
100:     nonce: authorization.nonce,
101:     signature,

The function returns success: true immediately after starting the workflow:

133: await temporal.workflow.start(workflowName, {
134:   taskQueue,
135:   workflowId,
136:   args: [params],
137:   workflowExecutionTimeout: isSameChain ? '5m' : '30m',
138: })
140: await recordVolume(req.paymentRequirements.amount)
142: return {
143:   success: true,
144:   transaction: workflowId,
145:   network: buyerNetwork,
146: }

However, the cross-chain workflow can fail after funds are pulled or burned. For example:

72: const pullTxHash = await pullFromBuyer({
73:   network: params.buyerNetwork,
75:   amount: params.amount,
76:   authorization: params.authorization,
87: const burnResult = await cctpBurn({
90:   amount: sellerAmount,
91:   recipient: params.sellerAddress,
97: const attestation = await waitAttestation({
126: const mintTx = await cctpMint({
133: await recordPayment({
140: status.step = 'settled'
153: } catch (err) {
154:   status = {
155:     step: 'failed',
158:     error: `Mint failed: ${(err as Error).message}. Attestation valid for manual retry.`,
161:   await recordPayment({
165:     status: 'mint_pending',
167:   throw err

This creates a potentially ambiguous API contract:

source: /settle request
transform: replay key marked processed, Temporal workflow started
sink: /settle response success=true with transaction=workflowId
later sink: workflow may settle, fail, or enter mint_pending

If x402 clients interpret /settle success: true as completed settlement, they may release paid resources before the workflow has actually transferred or minted funds to the seller. The replay key is also marked before workflow completion, which can complicate retries after a workflow-start or downstream settlement failure.

Possible hardening directions:

  • Distinguish accepted, pending, and settled in the /settle response instead of returning success: true for a started workflow.
  • Mark replay keys as processed only when the workflow reaches a terminal settled state, or store a retryable pending state.
  • If asynchronous settlement is intended, document that clients must poll getWorkflowStatus before treating the payment as settled.
  • Add a test where the workflow starts and then fails, and assert that the initial /settle response is not equivalent to final settlement.

I am reporting this as a potential issue rather than a confirmed exploit, since asynchronous settlement may be intentional.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions