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#
npm install --save-exact @meantjs/reconcile@0.5.0import { 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:
{
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:
| Option | Meaning |
|---|---|
logLength | The log length when the blocks were read (log_length of icrc3_get_blocks). Required. |
floor | The log length before the first send; the original is at this index or later. Default 0n, the whole log. |
inFlight | A send had an unknown outcome, so it may still execute until it is refused. Required. |
stampExpired | A send was answered TooOld. Sound only for blocks read after that answer arrived. |
ledgerTime, ledgerTimeAtLeast | The ledger's clock in ns, or a time it is at least. Without either, the tip block's time stands in. |
windowNs | How long after its stamp a call can still execute: the ledger's TX_WINDOW plus PERMITTED_DRIFT. Without it nothing closes by the clock. |
notOriginal | Block 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:
{ 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)" }status | What you do |
|---|---|
found | The first attempt executed: report it as already_done, with the block. Never send it again. |
absent | It 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_yet | Not in the log, but a send may still land until openUntil. Read again later; send nothing. |
unreadable | The 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
btypewhen it has one (1xfer,2xfer,1mint,1burn,2approve; ICRC-3 makes it authoritative), else the legacytx.op. An unknownbtypematches nothing. A transfer also needs thebtypeits method leaves, when the block has one:1xferforicrc1_transfer,2xferforicrc2_transfer_from. - Stamp.
tx.tsequalscreated_at_time. - Amount and fee.
tx.amtequals the amount;tx.feeequals the fee the call set, and is absent when it set none. -
Accounts.
from,toandspender, compared as owner bytes and a 32-byte subaccount, all zero for the default. The caller is one of them: thefromof a transfer or an approval, thespenderof atransfer_from. A block with aspenderthe 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_atandexpected_allowancetoo.
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-1that you did not read, because it is archived or a page was short, or one whose operation or amount cannot be read, isunreadable. -
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 aboveu64::MAXthat a ledger capped is one too (capped: true). -
A call without a stamp. An update without an
idempotencytag 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#
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.