Profiles

Settle an unknown outcome

After a timeout, find out from the ledger's history whether the first attempt went through, before anything is sent again.

A transfer timed out, and the ledger's later answer is TooOld: the stamp's deduplication window has passed, so resending the same bytes can no longer tell you whether the first attempt executed, and a new stamp could pay twice. The outcome is reconcile. @meantjs/reconcile answers the one question left: is the original in the ledger's ICRC-3 history? It reads nothing itself. You read the blocks and pass them in. Its exports are on the JavaScript API reference.

Check the history#

bash
npm install --save-exact @meantjs/reconcile@0.5.0
js
import { DEDUP_WINDOW_NS, expectedTransaction, matchBlocks } from "@meantjs/reconcile";

/**
 * Your read of the ledger's icrc3_get_blocks: certified, archives followed, every block from `floor` to the tip.
 * @param {bigint} floor
 * @returns {Promise<{ blocks: { id: bigint, block: import("@meantjs/reconcile").Icrc3Value }[], logLength: bigint, ledgerTime: bigint }>}
 */
async function readBlocks(floor) {
  throw new Error(`read the blocks from ${floor} to the tip with your agent`);
}

// The call whose outcome is unknown, as candid-core values: icrc1_transfer of 1.5 TKA to Alice, memo "lunch".
const arg = {
  to: { owner: "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", subaccount: null },
  amount: 150_000_000n,
  memo: new TextEncoder().encode("lunch"),
  fee: null,
  from_subaccount: null,
  created_at_time: 1_791_547_201_000_000_000n, // the stamp
};
const caller = "snnfi-lalgb-kxvh6-e5ehd-gwd5u-ld6ye-jwloa-klsxp-cq4v5-a5izx-zae"; // the person's principal, which sent it

const expected = expectedTransaction("icrc1_transfer", arg, caller);

const floor = 1041n; // the log length before the first send
const { blocks, logLength, ledgerTime } = await readBlocks(floor);
const finding = matchBlocks(blocks, expected, { logLength, floor, inFlight: true, ledgerTime, windowNs: DEDUP_WINDOW_NS });

expectedTransaction(method, arg, caller, { minting }) maps the call to the transaction it would leave in the log. For the call above:

text
{
  ts: 1791547201000000000n,
  amt: 150000000n,
  memo: '6c756e6368',
  fee: null,
  op: 'xfer',
  btype: '1xfer',
  from: { owner: '0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf202', subaccount: '0000000000000000000000000000000000000000000000000000000000000000' },
  to: { owner: 'b56bf994b37ae8e79f5ce000be1727a6060ae4eef24736b7cc999c3c02', subaccount: '0000000000000000000000000000000000000000000000000000000000000000' }
}

It maps icrc1_transfer, icrc2_approve and icrc2_transfer_from (METHODS). It returns undefined for any other method, for a call without a stamp, and for a caller it cannot read, and matchBlocks then answers unreadable. Pass the ledger's icrc1_minting_account as minting: a transfer from it is a mint and one to it a burn. Without it, the original's mint or burn block is never found, only reported as a lookalike.

matchBlocks(blocks, expected, options) judges the blocks you read. blocks is [{ id, block }] with each block an ICRC-3 value in { tag, value } form (Map, Array, Nat, Int, Text, Blob), and together they must hold every index from floor to logLength - 1. The options:

OptionMeaning
logLengthThe log length when the blocks were read (log_length of icrc3_get_blocks). Required.
floorThe log length before the first send; the original is at this index or later. Default 0n, the whole log.
inFlightA send had an unknown outcome, so it may still execute until it is refused. Required.
stampExpiredA send was answered TooOld. Sound only for blocks read after that answer arrived.
ledgerTime, ledgerTimeAtLeastThe ledger's clock in ns, or a time it is at least. Without either, the tip block's time stands in.
windowNsHow long after its stamp a call can still execute: the ledger's TX_WINDOW plus PERMITTED_DRIFT. Without it nothing closes by the clock.
notOriginalBlock indices the person said are not the original.

DEDUP_WINDOW_NS is 24 hours and 2 minutes, the DFINITY ledger's default TX_WINDOW plus PERMITTED_DRIFT. assumption Passing it assumes your ledger keeps that default: each ledger defines its own window, so check yours first.

Act on the answer#

The four answers matchBlocks can give, each for the call above:

text
{ status: "found", block: 1042n }
{ status: "absent", closedBy: "window_passed", clock: { at: 1791633721000000001n, by: "ledger" } }
{ status: "not_yet", openUntil: 1791633721000000000n, clock: { at: 1791547204000000000n, by: "ledger" } }
{ status: "unreadable", reason: "block 1042 since the first send was not read (an archive, or a short page)" }
statusWhat you do
foundThe first attempt executed: report it as already_done, with the block. Never send it again.
absentIt did not execute and no send can still land. The intent is settled; the same arguments may be prepared again as a new intent, with a new stamp, which the person confirms again.
not_yetNot in the log, but a send may still land until openUntil. Read again later; send nothing.
unreadableThe history cannot settle it. Ask the person, and send nothing until they answer.

absent needs one of three things: every send was answered (closedBy: "answered"), a send was answered TooOld ("too_old"), or the ledger's clock is past the stamp plus windowNs ("window_passed"). Without windowNs an in-flight call stays not_yet, with the reason no windowNs: the ledger's deduplication window is not known, so a send may still land.

What a match compares#

A block is the original only when it carries exactly the call's transaction. Every rule leans the safe way: a doubt is unreadable, never absent.

  • Operation. The block's btype when it has one (1xfer, 2xfer, 1mint, 1burn, 2approve; ICRC-3 makes it authoritative), else the legacy tx.op. An unknown btype matches nothing. A transfer also needs the btype its method leaves, when the block has one: 1xfer for icrc1_transfer, 2xfer for icrc2_transfer_from.
  • Stamp. tx.ts equals created_at_time.
  • Amount and fee. tx.amt equals the amount; tx.fee equals the fee the call set, and is absent when it set none.
  • Accounts. from, to and spender, compared as owner bytes and a 32-byte subaccount, all zero for the default. The caller is one of them: the from of a transfer or an approval, the spender of a transfer_from. A block with a spender the call has not is another call.
  • Memo. An absent field is part of the match: an empty memo is not no memo.
  • For an approval, expires_at and expected_allowance too.

fact The tests build blocks in the shapes recorded from the pinned DFINITY ledger wasm on PocketIC (legacy tx.op, no btype), and each rule above has a test that fails when the rule is removed. A burn, a spender's own transfer_from, a mint through transfer_from and a block with a btype were not observed on a real ledger; they follow ICRC-1, ICRC-2 and ICRC-3 as written.

What it cannot settle#

  • Balances and allowances. They cannot tell whether one call executed: another call can move the same amount. Only the original's block settles it (spec §5.3, consumer rule 1).
  • A gap. A block in floor..logLength-1 that you did not read, because it is archived or a page was short, or one whose operation or amount cannot be read, is unreadable.
  • A lookalike. A block with the call's stamp and amount from the call's account, in a shape the mapping does not know, may be the original: { status: "unreadable", reason: "block 1042 (op xfer) carries this call's stamp and amount from its account in another shape: it may be the original", lookalike: { block: 1042n, op: "xfer", capped: false } }. An approval above u64::MAX that a ledger capped is one too (capped: true).
  • A call without a stamp. An update without an idempotency tag leaves nothing that makes its block specific, so there is nothing to match.
  • A call it does not map: any method but the three ICRC-1 and ICRC-2 ones.

Ask the person#

If the history cannot settle it, ask the user, and MUST NOT send or re-stamp the call until the user has answered.

That is consumer rule 1 of spec §5.3. When the answer is unreadable, tell the person what is known: the call, its stamp, and the lookalike block if there is one. Then hold the intent. Do not re-stamp it, do not prepare it again, and never let the model decide. If the person says a lookalike is not the original, pass its index in notOriginal and read again: with every send answered, the same block then gives { status: "absent", closedBy: "answered" }. Only then may the same arguments be stamped again, as a new intent the person confirms again.

Wire it into the tool layer#

js
import { DEDUP_WINDOW_NS, expectedTransaction, matchBlocks } from "@meantjs/reconcile";

/**
 * Your read of icrc3_get_blocks on one ledger, from `floor` to the tip.
 * @param {string} canister
 * @param {bigint} floor
 * @returns {Promise<{ blocks: { id: bigint, block: import("@meantjs/reconcile").Icrc3Value }[], logLength: bigint, ledgerTime: bigint }>}
 */
async function readBlocks(canister, floor) {
  throw new Error(`read ${canister} from block ${floor} with your agent`);
}
/** The log length you read before the first send of each prepared call, by its bytes. */
const floors = new Map();
const caller = "snnfi-lalgb-kxvh6-e5ehd-gwd5u-ld6ye-jwloa-klsxp-cq4v5-a5izx-zae";

/** @type {NonNullable<import("@meantjs/tools").ToolLayerConfig["reconcile"]>} */
const reconcile = async ({ canister, method, value, preview }) => {
  const expected = expectedTransaction(method, /** @type {Record<string, unknown>} */ (value), caller);
  const floor = floors.get(preview.arg_hex) ?? 0n;
  const { blocks, logLength, ledgerTime } = await readBlocks(canister, floor);
  const finding = matchBlocks(blocks, expected, { logLength, floor, inFlight: true, ledgerTime, windowNs: DEDUP_WINDOW_NS });
  if (finding.status === "found") return { status: "found", block: finding.block };
  if (finding.status === "unreadable") return { status: "unreadable", reason: finding.reason };
  return { status: finding.status };
};

Pass reconcile to createToolLayer (give an agent tools). The layer calls it when an intent with an unknown outcome gets any answer but success, already_done or retry_same, with the call's value, its stamp and its preview. found becomes already_done, absent settles the intent, and anything else holds it at reconcile: no commit and no new preparation of it sends again. Without the hook the layer holds the intent with the finding unreadable, the same as a history that cannot be read. Why this rule exists is on outcomes and retries.