Treasury approval
Two parties, one transfer: one proposes it, another confirms it. The proposer cannot send on its own, and the confirmer cannot change what it is confirming.
This is enforced on the ledger by key authority, not by the off-ledger
REST scopes a key can also carry. A treasury:approve scope approves nothing,
and an offledger key cannot take part at all — see
Keys.
A worked example of the whole flow, for the case it fits best, is in AI agent payments.
How a key gets its role
Every key registered against an identity carries a stake, set by
Varnir.Keys:
role | Stake | Propose? | Verify? |
|---|---|---|---|
| — (the onboarding key) | 100 | Sends outright, no approval | Yes — but never its own |
dual | 80 | Yes | Yes — but never its own |
verifier | 80 | No — its sends are refused | Yes — but never its own |
proposer | 0 | Yes | No |
readonly | 9000 | No | No |
A stake-80 key holds dual status: it can raise a transfer and confirm
one. What it cannot do is confirm the transfer it raised itself. The proposal
records which key created it, and a confirmation signed by that same key is
rejected with Cannot verify your own proposal.
That is what makes a peer model work. Give two services a stake-80 key each and either can spend with the other's agreement, with no privileged approver above them. A single stake-80 key on its own can still propose — it simply has nobody to confirm it, which fails closed rather than open.
Both halves are signed for the same identity, so the identity cannot tell them apart; only the key can. That is why the check is on the key hash rather than on the stream.
The owner key confirms too, on any proposal it did not raise itself: it
carries no spend policy and sends unilaterally anyway, so excluding it from
confirmation would only strand a proposal nobody else could clear, without
adding safety. proposedBy !== confirmingKey binds the owner key exactly as
it binds a stake-80 one.
Stake is not the whole test. A read or offledger key is refused by
role, before the confirm branch is reached and whatever stake it carries —
which matters because the legacy read-only key sits at stake 9000. Treasury
authority means stake 80 or more and neither of those two roles; the SDK
applies the same rule client-side before it submits anything.
The identity also carries requiresTreasury, which decides when approval
kicks in: "-1" never, "0" always, or a raw amount, above which approval is
required. It can be a single value or an object keyed network.token to set
different limits per asset.
Both are set through addKey, called with the identity's own stake-100 key —
the only key that can change what another key may do:
// Arms approval and names the key that can grant it, in one transaction.
await owner.addKey({
publicKeyHex: approverKey.publicKeyHex,
role: 'dual',
approvalThreshold: '0',
});
await owner.addKey({publicKeyHex: agentKey.publicKeyHex, role: 'proposer'});
// Later, without touching the keys:
await owner.setApprovalThreshold('25000000');
Add the treasury key before the spender key. Until an identity has one, a stake-0 key's send is not turned into a proposal — it is rejected outright.
Proposing
A proposing key calls send exactly as it always would:
const result = await proposer.send({chain: 'niles', to: 'TSSM...', amount: '250'});
if (result.proposal) {
// Nothing has moved. The ledger stored the request instead.
console.log('Awaiting approval:', result.proposal.id);
} else {
console.log('Sent:', result.umid);
}
Always check result.proposal. The call succeeded and umid is a real
transaction id either way — the transaction just succeeded at recording a
proposal rather than at sending. Reporting "sent" off the back of a umid
alone will be wrong for exactly the transfers that matter most, the large ones.
A third outcome: OUTCOME_UNKNOWN. A proposing send can also throw
after the transaction has already committed, if the proposal stream it just
created can't be read back in time (read-after-write lag, more likely under
Activeledger v5's P2P commit). That's worse than an ordinary held send: the
caller doesn't even know yet whether a proposal exists to approve, or whether
the co-sign threshold was somehow missed and the transfer went straight
through. Never resend — poll listPendingApprovals
or getTransferProposal with the id named in the error until the picture
clears. See the error taxonomy.
Finding what needs approving
The contract registers each proposal on the identity's own stream, so an approver polls the one document it already reads:
const pending = await treasury.listPendingApprovals();
Or have it told to you as they appear:
const stop = treasury.watchPendingApprovals(
async ({id, amount, to}) => {
console.log(`${amount} to ${to} awaiting approval`);
},
{intervalMs: 15_000, onError: (e) => console.error('poll failed', e)},
);
// later: stop();
watchPendingApprovals polls rather than holding a connection open. That is
deliberate: an approver is usually an automated process that can neither host
a callback URL nor be relied on to keep a socket alive, and the identity
stream is consensus state rather than a cache, so asking it always works. The
contract also emits a treasury-pending event, so this can be backed by a
real push later without your code changing.
The list is a summary. The proposal stream stays the authority on the terms,
and entries are checked against it before being reported, so one already
confirmed or voided never shows up as outstanding work. Each entry also
carries expiresAt and isExpired, copied from the proposal when it was
raised, so a listing can show a dead proposal as dead without reading every
proposal stream — see Expiry.
Confirming
const proposal = await treasury.getTransferProposal(id);
// Inspect proposal.request — amount, to, network — then:
const sent = await treasury.confirmTransfer(id);
confirmTransfer executes the transfer on the terms stored in the proposal.
Anything the confirming transaction claims about amount, destination, network
or token is overwritten from the stored record before the contract acts, so a
treasury key cannot redirect a transfer or change its size. That is what makes
it safe to give this key to something you trust less than your own.
One thing the proposal does not fix is which of your wallets pays: the
stored request carries the amount and destination, not the wallet. The
confirmer chooses, and by default confirmTransfer picks one that can cover it
(see wallet selection).
Proposals are single-use. Confirming one twice is rejected as Approved Already rather than sending twice, so retrying after an ambiguous timeout is
safe. A proposal that has been voided is rejected as
Proposal Voided, and one past its expiry as Proposal Expired.
Expiry
A proposal is confirmable for 24 hours from the moment it was raised.
Varnir.CryptoTransfer stamps an absolute ISO instant, expiresAt, onto the
proposal stream and onto the identity's pending entry, computed from its
consensus timestamp rather than any node's wall clock. Past that instant the
contract refuses a confirmation with Proposal Expired. Nothing runs on a
timer: expiry makes a proposal unusable, it does not remove it, and
voiding is still what clears the entry.
A proposal recording no expiresAt at all reads as expired. That is
deliberate rather than a gap — those are the proposals raised before a
lifetime existed, which are exactly the unbounded ones this refusal is for,
and there is nothing honest on the stream to infer a deadline from. The cost
of failing closed is re-raising a proposal; the cost of failing open is the
payment nobody meant to release.
This matters more than a housekeeping deadline would, because confirming a proposal skips the spending-policy check. The contract takes the terms as vetted when the proposal was raised, and executes exactly what it stored: a held proposal is a pre-authorised payment at the original amount and recipient, ignoring whatever limits the identity's keys carry now. Tightening or removing the proposing key's policy afterwards does not bind a proposal already raised — a different key can still confirm it.
Twenty-four hours covers an approver asleep in another timezone and nothing longer. It is not configurable.
The SDK reads the same rule so callers do not have to:
const proposal = await treasury.getTransferProposal(id);
if (proposal?.isExpired) {
// Past `expiresAt`, or carrying none at all. Nothing can confirm it now.
}
getTransferProposal returns expiresAt (the stored instant, or null for a
proposal raised before expiry existed) and isExpired (the contract's own
rule applied for you, so missing, unparsable and past all read the same way).
confirmTransfer checks it before submitting and throws EXPIRED rather than
spending a round trip on a rejection the answer to which is already known.
The ledger remains the enforcement; this only saves the trip.
Alongside those, a proposal now also carries proposedBy (the key hash that
raised it — what the self-confirmation check compares against), and voidedBy
and voidedAt, below.
Rejecting a proposal
voidTransfer retires a held proposal without executing it:
const {umid} = await treasury.voidTransfer(id);
It moves no money and cannot: the contract's void branch returns before it
reaches any amount or treasury logic, and a void carries no amount, network,
token or destination to begin with. What it does is stamp the proposal
terminal — voided, voidedAt, voidedBy — and delete the entry from the
identity's pendingTreasury summary, so it stops showing up in
listPendingApprovals. A later confirmation of a voided proposal is refused
with Proposal Voided; the point of voiding is that nothing can resurrect a
payment somebody decided would not happen.
Two keys may void:
- the key that raised the proposal (
proposedBy), and - any key that could have confirmed it — treasury authority, the same test confirming uses, shared between the two so they cannot disagree.
The proposer is included on purpose. The party that raised a proposal in error
is the one who most wants it gone, and it still cannot confirm its own — it
can only withdraw it. A read or offledger key can do neither.
Voiding is single-use, like confirming. The SDK throws ALREADY_SETTLED for a
proposal already confirmed or already voided, NOT_FOUND if the id is not a
proposal on this network, and NOT_OWNER if the calling key is neither the
proposer nor a treasury key — each checked before anything is submitted.
An expired proposal can still be voided. Expiry stops it being confirmed; voiding is what takes it off the pending list.
Current limits
Worth knowing before you design around this:
- One confirmation is enough. Any key with treasury authority other than the proposer can execute it; the count required is not configurable. M-of-N approval — "two of these three must agree" — is not supported yet.
- The lifetime is fixed. 24 hours, the same for every proposal and every identity; there is no per-identity or per-proposal setting.
- Nothing sweeps expired proposals. An expired proposal is unconfirmable but its entry stays on the identity stream until somebody voids it.
- Polling, not push.
treasury-pendingandtreasury-voidedare emitted but nothing forwards them to subscribers yet.
Reading the roles back
listLedgerKeys() returns every authority on the identity
straight off its stream — public key, type, stake, label, the
transaction that attached it, a derived role, isOwner for the stake-100
onboarding key, and the off-ledger scopes and on-ledger spend policy stored
against each one. addKey attaches a key with its role, scopes and policy;
removeKey detaches one (the identity's own owner key excepted — the SDK
refuses that rather than submitting a no-op).