Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Transfers

prepareTransfer returns a PreparedTransfer carrying the API pricing quote on .fees and a set of execution methods. Preparation requests the quote and fetches the escrow bytecode once, so re-quoting and staged execution reuse the same context.

Every transfer deploys an EscrowBatch, including a single-recipient transfer, which is internally a one-row batch.

Lifecycle

execute() runs the full pipeline and yields one step at a time:

  1. fees - the API service fee and exact funding requirements
  2. approve - one exact approval per ERC-20 funding asset
  3. deploy - deployment of the exact API-quoted EscrowBatch
  4. compliance - an execution approval bound to the deployment and the quote
  5. signal - minimal encrypted Signal envelope submitted to Nomad
  6. transfer - emitted incrementally as each recipient's delivery lands on chain
  7. complete - all recipients delivered
for await (const event of prepared.execute(walletClient)) {
  if (event.step === "deploy") {
    // Persist before doing anything else. See Saving transfer secrets below.
    await saveSecrets(event.secrets);
  }
  if (event.step === "complete") {
    console.log(event.transfers.map((t) => t.transactionHash));
  }
}

execute() accepts a wallet client, falling back to the one passed to prepareTransfer.

Quoting without a wallet

The quote commits to a sender address, so preparation needs one. Supply senderAddress when no wallet client is available, which is the case when you only want to show a price.

const prepared = await prepareTransfer({
  transfers,
  senderAddress: "0x...",
  publicClient,
  network: networks.ethereum,
});

Without either, preparation throws SENDER_REQUIRED. If a wallet client is later supplied and its account differs from the quoted sender, the SDK throws ACCOUNT_CHANGED rather than deploying against a quote signed for someone else.

Saving transfer secrets

Under API-owned pricing the secrets carry more than the escrow identity. Three parts matter:

  • escrowAddress together with selectorMapping is what cancelTransfer needs to call the escrow's withdraw function. The deployed bytecode is obfuscated per deployment, so the standard function selector does not necessarily apply and the mapping is required to reach it.
  • blindingScalar is the base scalar for the batch's ordered one-time bid signers. Nomad cannot derive the constructor's signers without it. It is generated on the deploying device and never leaves it in recoverable form, so it exists nowhere else.
  • sealedPricingAuthorization with quoteCommitment, serviceFee, depositByAsset, msgValue, and senderAddress is the exact API authorization the deployed constructor was built from. A resume must submit the same authorization; a fresh quote will not match the escrow already on chain.

Lose the escrow address and the funds sit in an escrow you cannot address. Lose the scalar or the sealed authorization and the transfer can no longer be completed, though a cancel and withdraw still works while the escrow is unbonded.

Write the value to durable storage and await that write before continuing the generator:

if (event.step === "deploy") {
  // Serialize bigint fields; JSON.stringify throws on them by default.
  await db.put(event.escrowAddress, JSON.stringify(event.secrets, (_, v) =>
    typeof v === "bigint" ? `${v}n` : v,
  ));
}

Keep the record until the complete step confirms delivery. Anything earlier is an in-flight transfer that may still need to be resumed or withdrawn.

Multiple recipients

Pass transfers instead of the single-recipient fields to cover several rows in one escrow. Rows may use different tokens. Row order is preserved: the SDK groups rows into Signals by asset and derives one blinded bid signer per row.

const prepared = await prepareTransfer({
  transfers: [
    { tokenAddress: USDC, recipientAddress: ALICE, amount: parseUnits("100", 6) },
    { tokenAddress: USDC, recipientAddress: BOB, amount: parseUnits("250", 6) },
  ],
  senderAddress: account,
  publicClient,
  network: networks.ethereum,
});

The two forms are mutually exclusive: use tokenAddress + recipientAddress + amount, or transfers.

Staged execution

Interfaces that expose separate approval and deployment controls can drive the same prepared transfer without reimplementing protocol logic.

const prepared = await prepareTransfer({
  transfers,
  senderAddress: walletClient.account.address,
  publicClient,
  network: networks.ethereum,
});
 
// Approve button. Approvals are for the exact quoted amounts.
const approvals = prepared.approve(walletClient);
let checkpoint;
while (true) {
  const next = await approvals.next();
  if (next.done) {
    checkpoint = next.value;
    break;
  }
  console.log(next.value.hash);
}
 
// Deploy button.
const deployed = await prepared.deploy(walletClient, checkpoint);
 
// Persist before advancing. The escrow is funded from this point on.
await saveSecrets(deployed.secrets);
 
// Submit and monitor after deployment.
for await (const event of prepared.complete(walletClient, deployed.secrets)) {
  console.log(event.step);
}

ApprovalCheckpoint and TransferSecrets are serializable stage boundaries. TransferSecrets includes the batch scalar, the quote commitment, and the opaque sealed pricing authorization, so a reload submits the same authorization that produced the deployed constructor.

Staged flows make the save more important, not less: each stage is a separate user action, so a reload, tab close, or wallet disconnect between deploy and complete is expected rather than exceptional. Persist deployed.secrets before you render the next step.

Updating a quote

Before approval begins, amounts and recipients stay mutable. Both methods re-request a quote from the API, reusing the prepared context.

// Re-request a quote
const fees = await prepared.refreshFees();
 
// Change amounts or recipients. Row count and per-row token are fixed at preparation.
const updated = await prepared.updateTransfers(newRows);

Both throw INVALID_STAGE once an approval has been broadcast, a checkpoint exists, or the escrow is deployed, since the deployed constructor is bound to a specific quote from that point on. The gasPrice and ethToTokenRate overrides are deprecated and ignored; see Pricing.

Cancellation

Pass an AbortSignal to cancel mid-transfer.

const controller = new AbortController();
 
const prepared = await prepareTransfer({
  // ...params,
  abortSignal: controller.signal,
});
 
// Cancel from the UI, a network change, etc.
controller.abort();

If the abort lands before any transaction is sent, TransferAbortedError is thrown with no escrowAddress. If it lands after escrow deployment, the error carries escrowAddress for manual recovery.

Resuming

If a transfer fails after the escrow is deployed (account change, abort, timeout), resume from the saved secrets. This is what the save buys you, and it is the normal way to handle a mid-transfer failure.

const prepared = await prepareTransfer({
  // ...same params,
  resume: savedTransferSecrets,
});
 
for await (const event of prepared.execute(walletClient)) {
  // picks up at the compliance/signal step
}

Resuming requires the blindingScalar and the sealed pricing authorization, so it must run on the device that deployed the escrow. Without the scalar the SDK throws MissingBlindingScalarError rather than sending a Signal that Nomad cannot act on, and the remaining option is to cancel and withdraw.