Skip to main content

Handle a re-sign request

CoRelayer asks for a second signature in one case only: the assigned relayer became unusable before it co-signed.

What it means​

Was anything sent?No.
Can the transaction already signed ever execute?No. It has no relayer signature, so it can never become valid.
Which nonce does the new one use?The same one, pinned.
Whose decision is it?Yours. No library makes it for you.

RESIGN_REQUIRED and RESIGN_SAME_NONCE both carry resign: "NEW_SIGNATURE_SAME_NONCE":

CodeCause
RESIGN_REQUIREDThe assigned relayer can no longer be used, and nothing was co-signed.
RESIGN_SAME_NONCEA replacement for the same nonce is needed, with a gas price at least one higher. This happens when the relayer's key is flagged as compromised, or the relayer could not be funded in time.

Both problem documents carry an assignment whose lease is pinned to that nonce, and the signer refuses to co-sign any other nonce with it. Sign against that assignment. A new assignment from the assign endpoint is not pinned, so it is not safe here. (The intent lifecycle)

How it reaches you​

relayOnce returns this case instead of throwing. The sendToken example passes it on with the call it was relaying:

import { type RelayContext, type SendOutcome, sendToken } from './send-token.ts';

export async function send(
context: RelayContext,
receiver: string,
amount: bigint,
intentKey: string,
): Promise<SendOutcome> {
const outcome = await sendToken(context, { receiver, amount, intentKey });
if (outcome.kind === 'resign-required') {
console.log(outcome.previousRelayer); // the relayer that went away
console.log(outcome.nextAssignment); // the replacement, pinned to the nonce
console.log(outcome.pinnedNonce); // the nonce to keep
console.log(outcome.call); // the unchanged call
}
return outcome;
}

Sign once more​

You can pass assignment: outcome.nextAssignment, minGasPrice and a transaction built for the pinned nonce to relayOnce. With signProof, it times a renewal proof for that lease from the moment the re-sign answer arrived, so the time the person took to decide is counted. That works for the nextAssignment object relayOnce returned, passed on as it is. A copy of it is timed from the moment you call relayOnce.

The example below does the same steps by hand: it checks the new relayer, builds the same call at the pinned nonce, signs once through guardSignOnce, checks the nonce and submits with the pinned lease:

send-token.ts
/**
* Signs the same call once more, for the replacement relayer, on the pinned nonce. This is the one
* case in which a second signature is correct. Call it only after a person confirmed, or from an
* agent policy that stops after a fixed number of re-signs.
*
* It uses the assignment that came back with `RESIGN_REQUIRED`. That lease is pinned to the nonce,
* so the second transaction can only replace the first. A new assignment from the assign endpoint
* is not pinned, so it is not safe here.
*/
export async function resignWith(
context: RelayContext,
outcome: Extract<SendOutcome, { kind: 'resign-required' }>,
intentKey: string,
signal?: AbortSignal,
): Promise<RelayResponse> {
const { client, wallet, relayers, chainId } = context;
const assignment = outcome.nextAssignment;
if (assignment === undefined) {
throw new Error(
'The server returned no replacement assignment; ask for one before re-signing.',
);
}
const nonce = assignment.pinNonce ?? outcome.pinnedNonce;
if (nonce !== outcome.pinnedNonce) {
throw new Error(
`Replacement lease is pinned to nonce ${nonce}, expected ${outcome.pinnedNonce}.`,
);
}

await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal);
const unsigned = forAssignment(outcome.call, nonce, assignment, chainId);
const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction));
const signed = await sign({ assignment, transaction: unsigned });
if (signed.nonce !== nonce) {
throw new Error(
`The wallet signed nonce ${signed.nonce}, not the pinned ${nonce}. Nothing sent.`,
);
}

const { data } = await client.relay(
{ tx: signed, lease: assignment.lease },
{ intentKey, ...(signal === undefined ? {} : { signal }) },
);
return data;
}

Keep the idempotency key of the original action, because it is the same action.

For a person​

Ask the person first, in a dialog from your own UI. The function below signs only when the dialog answers yes:

confirm-resign.ts
import type { RelayResponse } from '@corelayer/sdk';
import { type RelayContext, resignWith, type SendOutcome } from './send-token.ts';

/** What the dialog shows before the person decides. */
export interface ResignQuestion {
/** The nonce the new transaction keeps. */
readonly nonce: number;
readonly previousRelayer: string;
readonly nextRelayer: string;
/** 1 for the first re-sign of this nonce, 2 for the second. */
readonly attempt: number;
}

/**
* Signs once more if the person agrees. `ask` opens your dialog and resolves to `true` when the
* person chooses to sign again. Resolves to `undefined` when nothing was signed.
*/
export async function confirmResign(
context: RelayContext,
outcome: Extract<SendOutcome, { kind: 'resign-required' }>,
intentKey: string,
attempt: number,
ask: (question: ResignQuestion) => Promise<boolean>,
): Promise<RelayResponse | undefined> {
const next = outcome.nextAssignment;
// Without a replacement assignment there is nothing safe to sign.
if (next === undefined || attempt > 2) return undefined;
const agreed = await ask({
nonce: outcome.pinnedNonce,
previousRelayer: outcome.previousRelayer,
nextRelayer: next.relayer,
attempt,
});
return agreed ? resignWith(context, outcome, intentKey) : undefined;
}

Open the dialog with focus on Discard, so a stray Enter cannot sign. Say that the new transaction uses the same nonce, so the two cannot both execute. After two re-signs for one nonce, stop offering a third.

Do not re-sign silently or call this a retry. Tell the user the first transaction was never sent and can never execute.

For a headless agent​

A program with no person to ask can decide in code, with a fixed limit:

resign-policy.ts
/**
* Re-signs at most `maxResigns` times, for a program with no person to ask. Each re-sign uses the
* pinned assignment from the answer and keeps the nonce and the idempotency key.
*/
import { isApiError, type RelayResponse } from '@corelayer/sdk';
import { type RelayContext, resignWith, type SendTokenRequest, sendToken } from './send-token.ts';

export async function sendWithBoundedResign(
context: RelayContext,
request: SendTokenRequest,
maxResigns = 2,
): Promise<RelayResponse> {
const first = await sendToken(context, request);
if (first.kind === 'relayed') return first.response;

let pending = first;
for (let resigns = 1; ; resigns += 1) {
try {
return await resignWith(context, pending, request.intentKey, request.signal);
} catch (error) {
// Retry only on another re-sign answer, and only within the bound. Repeated re-signs point
// to a wider problem, so report it.
if (!isApiError(error) || !error.needsNewSignature || resigns >= maxResigns) throw error;
pending = {
...pending,
previousRelayer: pending.nextAssignment?.relayer ?? pending.previousRelayer,
nextAssignment: error.problem.assignment,
};
}
}
}

The loop stops after maxResigns, so one action never collects an unlimited number of signatures. It keeps the idempotency key, because it is the same action, and it keeps the pinned nonce, because moving to the next nonce could leave two live transactions for one action.

Decide from resign​

A 409 has several meanings, so do not re-sign on the status alone. Read resign:

import { isApiError } from '@corelayer/sdk';

export async function onRelayError(
error: unknown,
signAgain: () => Promise<void>,
): Promise<void> {
// `needsNewSignature` is true when `resign` is 'NEW_SIGNATURE_SAME_NONCE'.
if (isApiError(error) && error.needsNewSignature) {
await signAgain();
return;
}
throw error;
}
resignDo
NONEDo not sign. A new signature does not help.
SAME_BYTESSend the same signed bytes again. Do not sign.
NEW_SIGNATURE_SAME_NONCESign once more, on the same nonce, with the pinned assignment.

What is not a re-sign​

SituationCorrect response
A timeout on POST /v1/relaySend the same signed bytes again, or read the intent. Do not sign again: the transaction may already be on its way.
LEASE_EXPIRED with renewable: trueRenew the lease for the same relayer and send the same signed bytes again. relayOnce does this for you when the client has a native-auth token for the sender or you pass signProof, because the renewal needs a fresh presence proof.
NONCE_TOO_LOW or INTENT_ALREADY_EXECUTED after a re-signThe flow is over: the slot was consumed, very likely by your first transaction. Read its outcome with GET /v1/intents/{sender}/{nonce}; do not start a fresh flow for the same action.
RATE_LIMITEDWait, then send the same signed bytes again.

Why CoRelayer cannot pick another relayer for you​

CoRelayer cannot swap the relayer, because its address is part of the bytes you signed (Relayed v3). Signing one variant per candidate relayer would avoid this prompt, but each variant could execute, so CoRelayer asks for one signature and asks again only in this case. (One signature)