Skip to Content
Supra Layer 1SupraMVMFungible Asset Allowances

Fungible Asset Allowances

supra_framework::fungible_asset_allowance is available on Devnet only. It is not yet on Testnet or Mainnet.

This article describes how an application lets one account spend a bounded amount of another account’s fungible asset, using supra_framework::fungible_asset_allowance. The model is the ERC-20 approve / transferFrom pair: the asset owner (the grantor) records an allowance for a spender, and the spender moves the asset without the grantor’s signer, a SignerCapability or the issuer’s TransferRef. Every allowance has an expiry, and the grantor can change or revoke it at any time.

The module also accepts a grant the grantor signed off chain, so a grantor can authorise a spending limit without submitting a transaction.

Separately from grants, the module settles a one-shot transfer authorization: the payer signs, off chain, an authorization for one named facilitator to move up to a maximum amount to one named recipient, once. This is the shape of the x402 upto payment scheme, and with the full amount settled, of x402 exact. See One-Shot Settlement.

Requirements

  1. The chain runs a framework that carries the module, with fungible_asset_allowance::initialize executed. The module is not in aptosvm-v1.16_supra-v1.8.17 or earlier, and settlement is in aptosvm-v1.16_supra-v1.9.0.rc18 and later. New chains run initialize at genesis. On an existing chain the framework upgrade script runs it. Until then every write that creates grant state and every settlement abort with EMODULE_NOT_INITIALIZED; revoke does nothing, and decrease, transfer_from and transfer_from_store abort with EALLOWANCE_NOT_FOUND.
  2. The asset is a framework fungible asset with a Metadata object. Every function below takes the metadata object’s address.
  3. The grantor’s balance sits in a FungibleStore the grantor owns directly, and a payer’s in its primary store for the asset. For SUPRA, that is the paired fungible asset’s primary store, not CoinStore<SupraCoin>. Balances still held as Coin are not reachable through an allowance or a settlement.

The examples use the supra CLI. Each entry function is also callable from any SDK that can submit an entry-function transaction, and each view through POST /rpc/v3/view. A supra command without --profile <name> runs against the active profile; --rpc-url <URL> sends it to that node in place of the profile’s.

Moving SUPRA to the Primary Store

An account funded by a transfer or by the faucet holds its SUPRA as a Coin in CoinStore<SupraCoin>. The module reads only fungible stores, so for such a grantor transfer_from aborts with ESOURCE_STORE_MISSING, and so does settle_upto for such a payer. The account moves its balance to its primary store once, with its own signer:

supra move tool run \ --function-id 0x1::coin::migrate_to_fungible_store \ --type-args 0x1::supra_coin::SupraCoin \ --profile grantor --accept-defaults

Run it for each grantor and payer before the examples below. It moves the whole CoinStore balance, after which coin::balance reports the same total.

Terms

TermMeaning
grantorThe account whose asset is spent.
spenderThe account permitted to move it.
grantOne allowance, identified by (grantor, spender, metadata). Holds a remaining amount, an expiry and a scope.
live grantA grant whose expiry is in the future, whose remaining amount is above zero, and whose generation matches the grantor’s current one. Only live grants can be spent.
generationA counter per grantor. revoke_all advances it, which voids every grant made under the old value.
scopeWhich of the grantor’s stores of the asset the grant may be spent from.
grant nonceA u64 a signed grant names, usable once per grantor.
payerThe account that signs a one-shot transfer authorization and whose primary store it debits.
facilitatorThe only account that may settle an authorization: the one the payer named in it.
authorizationAn UptoAuthorization: a payer’s signed permission for a facilitator to move up to max_amount to one recipient, once.
settlement nonceThe 32 bytes an authorization names. A payer’s record of it blocks any authorization carrying it from being settled until the record is removed.

Granting an Allowance

The grantor calls increase. It creates the grant if there is no live one and otherwise adds to it and sets its expiry to expiry_secs. expiry_secs is a Unix time in seconds; it must be later than the current block time and at most 366 days ahead.

supra move tool run \ --function-id 0x1::fungible_asset_allowance::increase \ --args address:<SPENDER> address:<METADATA> u64:<AMOUNT> u64:<EXPIRY_SECS> u8:<SCOPE> \ --profile grantor --accept-defaults

Read the current block time from the node rather than the host clock: GET /rpc/v3/block returns the latest block, whose header.timestamp.microseconds_since_unix_epoch is in microseconds, so divide it by 1,000,000 for expiry_secs.

Check the result:

supra move tool view \ --function-id 0x1::fungible_asset_allowance::allowance \ --args address:<GRANTOR> address:<SPENDER> address:<METADATA>

The view returns [remaining, expiry_secs, scope], or ["0", "0", 0] when no grant is live. The u64 values are JSON strings and the u8 scope is a JSON number.

Choosing a Scope

scope states which of the grantor’s stores of the asset the spender may withdraw from:

ValueNameCovers
0SCOPE_PRIMARY_STOREThe grantor’s primary store for the asset, and nothing else.
1SCOPE_ANY_OWNED_STOREAny store of the asset the grantor owns directly, including the primary store.

Scope 0 suits a grantor that holds the asset in its primary store, which is where primary_fungible_store transfers and most wallet balances land. It also bounds the grant if the grantor later creates other stores of the same asset: a spender that knows a store address cannot reach it.

Scope 1 suits a grantor that keeps the asset in stores it created itself — a treasury splitting one asset across purpose-built stores — and a spender that is expected to name those stores through transfer_from_store.

increase against a live grant requires the scope the grant already has and otherwise aborts with EALLOWANCE_SCOPE_MISMATCH. replace is how the scope of an existing grant changes.

Changing a Grant Without a Race

ERC-20’s approve has a known race: a spender who sees a reduction coming can spend the old allowance first and then the new one. The module has no bare overwrite. Use one of:

  • decrease(spender, metadata, by) — subtracts, saturating at zero, keeping the expiry and scope. A result of zero removes the grant. by = 0 leaves the grant unchanged, and the call aborts with EALLOWANCE_NOT_FOUND when no grant is live.
  • replace(spender, metadata, expected_remaining, amount, expiry_secs, scope) — sets the grant to amount with scope scope only if its live remaining amount is exactly expected_remaining. Read the value from the allowance view first. If a spend lands in between, the call aborts with EALLOWANCE_MISMATCH and nothing changes. Pass amount = 0 to remove the grant under the same condition.
  • revoke(spender, metadata) — removes the grant unconditionally.
  • revoke_all() — voids every grant the grantor has made, to every spender and for every asset, in one transaction. It also voids every signed grant and every settlement authorization the grantor produced under the old generation.
supra move tool run \ --function-id 0x1::fungible_asset_allowance::replace \ --args address:<SPENDER> address:<METADATA> u64:<EXPECTED_REMAINING> u64:<NEW_AMOUNT> \ u64:<EXPIRY_SECS> u8:<SCOPE> \ --profile grantor --accept-defaults

Spending an Allowance

The spender calls transfer_from. It moves amount from the grantor’s primary store to the recipient’s primary store, creating the recipient’s store if needed, and charges the grant. The primary store is inside both scopes, so this works under either.

supra move tool run \ --function-id 0x1::fungible_asset_allowance::transfer_from \ --args address:<GRANTOR> address:<METADATA> address:<RECIPIENT> u64:<AMOUNT> \ --profile spender --accept-defaults

transfer_from_store<T>(store, to_store, amount) does the same between two named stores. The address of an account’s primary store is returned by 0x1::primary_fungible_store::primary_store_address, as shown in Recognising Account Operations. The grant consulted is the one made by the source store’s direct owner, so a store held through an intermediate object is spendable only under a grant made by that object. Under scope 0 the source store must be the grantor’s primary store; a spend against any other store aborts with ESTORE_OUT_OF_SCOPE.

supra move tool run \ --function-id 0x1::fungible_asset_allowance::transfer_from_store \ --type-args 0x1::fungible_asset::FungibleStore \ --args address:<SOURCE_STORE> address:<DESTINATION_STORE> u64:<AMOUNT> \ --profile spender --accept-defaults

Points that affect an integration:

  • Hooks run. The asset’s dispatchable withdraw and deposit functions execute as they do for an ordinary transfer. If the withdraw hook takes a fee, the grant is charged the amount the source store was actually debited, so the fee counts against the allowance. Leave room for it.
  • Frozen stores are rejected, on either side, as for fungible_asset::transfer.
  • Aborts. A spend above the live remaining amount aborts with EINSUFFICIENT_ALLOWANCE; a spend with no live grant aborts with EALLOWANCE_NOT_FOUND; a spend from a store outside the grant’s scope aborts with ESTORE_OUT_OF_SCOPE. Read the code from the committed transaction’s vm_status, which reads Move abort in 0x1::fungible_asset_allowance: ....

Off-Chain Signed Grants

A grantor can authorise a grant by signature instead of a transaction. Anyone may submit the signature with apply_signed_grant, and the spender then spends the grant with transfer_from as for any other grant. The grantor pays no gas.

The grantor signs with an ed25519 or multi-ed25519 key, and the public key must derive the grantor’s authentication key. For an account with an Account resource that is the key recorded there. For an address with no Account resource, such as one that has only received assets, it is the address itself, so the address must be the one the key derives. Accounts with other key schemes use the on-chain functions above.

1. Build the payload

The grantor’s client gathers:

FieldValue
grantor, spender, metadataAddresses.
expected_remainingThe live remaining amount the grantor expects, from the allowance view. 0 for a fresh grant.
amountThe remaining amount the grant is set to.
expiry_secsUnix seconds, in the future and within 366 days of block time. Required even when amount is 0.
nonceA u64 at or above the grantor’s min_nonce that the grantor has not used before. Check with the is_nonce_used view.
generationThe grantor’s current generation, from the generation view.
scope0 or 1, as for increase.

generation and scope are part of the signed payload: a submitter cannot widen the scope the grantor authorised, and a signature made before a revoke_all no longer applies. The chain id is filled in on chain, so a signature made for one network is not valid on another.

2. Get the bytes to sign

A grantor whose key is in a supra CLI profile signs with:

supra move allowance sign-grant \ --spender <SPENDER> --metadata <METADATA> --expected-remaining <EXPECTED_REMAINING> \ --amount <AMOUNT> --expiry-secs <EXPIRY_SECS> --nonce <NONCE> --generation <GENERATION> \ --scope <SCOPE> --profile <GRANTOR_PROFILE> > grant.json

The grantor is the profile’s account address. The command reads the bytes to sign from the grant_signing_message view, checks them against the payload it encodes for the profile’s network, and signs them with the profile’s ed25519 key. It writes the result to standard output as one JSON document, and its password prompt and notices to standard error, so > grant.json captures the result alone. A grant of 1 SUPRA (--metadata 0xa --amount 100000000) signed on a localnet produced:

{ "function_id": "0x1::fungible_asset_allowance::apply_signed_grant", "message": { "chain_id": 255, "grantor": "0xded46df83b18540b0bd144cdea61f99e2d4dd28c274d12560d777bd01f6272f9", "spender": "0x8cc2cb17b506e9c0e291f52b48210874198316f38f0a0a96eba87cad0e1a89ba", "metadata": "0xa", "expected_remaining": "0", "amount": "100000000", "expiry_secs": "1792144764", "nonce": "1", "generation": "0", "scope": 0 }, "account_scheme": 0, "public_key": "0xb533465b288449342a10eee983bad49df56baf545c5096b282cd428b45767939", "signature": "0x3e012984c0f2134b91cfe660b286db6e73c5bfe8d62ccee7cdcc3a49959057313e6d06fff023870acd456a0dc785790e64348c1bce594557e4128cf6367b0804", "args": [ "address:0xded46df83b18540b0bd144cdea61f99e2d4dd28c274d12560d777bd01f6272f9", "address:0x8cc2cb17b506e9c0e291f52b48210874198316f38f0a0a96eba87cad0e1a89ba", "address:0xa", "u64:0", "u64:100000000", "u64:1792144764", "u64:1", "u64:0", "u8:0", "u8:0", "hex:0xb533465b288449342a10eee983bad49df56baf545c5096b282cd428b45767939", "hex:0x3e012984c0f2134b91cfe660b286db6e73c5bfe8d62ccee7cdcc3a49959057313e6d06fff023870acd456a0dc785790e64348c1bce594557e4128cf6367b0804" ] }
FieldUse in step 3
function_idThe entry function to submit, apply_signed_grant.
messageThe signed fields, for the submitter to check. chain_id is the network’s.
account_scheme<SCHEME>.
public_key<PUBLIC_KEY>.
signature<SIGNATURE>.
argsThe whole --args list of apply_signed_grant, in order, with every value set.

The result holds the signed fields, the public key and the signature, and no secret. The grantor gives grant.json to whoever submits it in step 3, such as the spender.

A wallet or SDK that holds the grantor’s key reads the same bytes from the view:

supra move tool view \ --function-id 0x1::fungible_asset_allowance::grant_signing_message \ --args address:<GRANTOR> address:<SPENDER> address:<METADATA> u64:<EXPECTED_REMAINING> \ u64:<AMOUNT> u64:<EXPIRY_SECS> u64:<NONCE> u64:<GENERATION> u8:<SCOPE>

The view returns the exact byte string to sign. It is the BCS encoding of ed25519::SignedMessage<SignedGrant>, which embeds the struct’s type, so the signature cannot be replayed against any other framework message. Sign those bytes with the grantor’s key as a plain ed25519 (or multi-ed25519) signature; do not hash them first. The bytes are the view’s result value, a 0x-prefixed hex string. PUBLIC_KEY in the next step is that key’s public key.

A grantor can also set a grant on chain with its own signer, as in Granting an Allowance.

3. Submit

supra move tool run \ --function-id 0x1::fungible_asset_allowance::apply_signed_grant \ --args address:<GRANTOR> address:<SPENDER> address:<METADATA> u64:<EXPECTED_REMAINING> \ u64:<AMOUNT> u64:<EXPIRY_SECS> u64:<NONCE> u64:<GENERATION> u8:<SCOPE> \ u8:<SCHEME> hex:<PUBLIC_KEY> hex:<SIGNATURE> \ --profile server --accept-defaults

With the grant.json that sign-grant wrote, args is that argument list, and the submitter passes it whole:

supra move tool run \ --function-id 0x1::fungible_asset_allowance::apply_signed_grant \ --args $(jq -r '.args[]' grant.json) \ --profile server --accept-defaults

jq -r '.args[]' prints one argument per line, and the shell splits the unquoted substitution into one word per argument. No argument contains a space.

apply_signed_grant takes no signer: whoever submits the transaction pays for it, and the grant is attributed to the address in the payload. SCHEME is 0 for ed25519 and 1 for multi-ed25519. The signature is checked against the grantor’s current authentication key, so a signature made before a key rotation is no longer valid after it. A signature that fails the check, or names a scheme other than 0 or 1, aborts with EINVALID_SIGNATURE. On success the grant exists exactly as if the grantor had called replace with the same values, and the nonce is recorded. Submitting the same signature again aborts with ENONCE_ALREADY_USED while the expiry, generation and nonce floor checks still admit it; once the expiry has passed, after revoke_all, or once the floor has passed the nonce, an earlier check aborts first (EINVALID_EXPIRY, EGENERATION_MISMATCH or ENONCE_BELOW_FLOOR).

The spender then spends the grant with transfer_from or transfer_from_store, as in Spending an Allowance.

Reading EALLOWANCE_MISMATCH

apply_signed_grant writes the grant only while the live remaining amount of the grantor’s grant for that spender and asset equals expected_remaining. It does not compare the expiry or scope of the grant, so a grant that has returned to the same remaining amount accepts the signature. A grant that is not live reads as 0, so a signature with expected_remaining of 0 is applicable whenever no grant is live, including after a grant is spent, revoked or expires.

A signature that has not been submitted therefore stays applicable whenever the live remaining amount equals its expected_remaining, until its nonce is recorded or falls below the floor, its expiry passes, or the grantor calls revoke_all. A grantor who no longer wants an outstanding signature to apply cancels it with cancel_nonce or invalidate_nonces_below; see Cancelling a Signature Before It Is Submitted.

Any change to the live remaining amount between signing and submission produces EALLOWANCE_MISMATCH, whether it comes from a spend, increase, decrease, replace, revoke or the grant expiring. For example:

  • A signature carrying expected_remaining of 0 while a previous grant for the same spender and asset still has a live remainder.
  • A signature carrying a non-zero expected_remaining whose grant expires between signing and submission, since a grant that is no longer live reads as 0.

The remedy is a fresh signature against the current value from the allowance view. A server that batches submissions should read the view immediately before it asks the payer to sign, and handle EALLOWANCE_MISMATCH by asking for a new signature.

Allocating nonces

Recorded nonces are held as a bitmap, one storage slot per block of 128 consecutive nonces. Only the first nonce recorded in a block creates a slot; the rest of that block carries no storage fee.

Allocate nonces densely from a low counter — take the next value from the min_nonce view, or from a client-side counter seeded from it — rather than drawing them at random. Random u64 nonces put each one in a block of its own, so every signed grant pays for a fresh storage slot. A block can be reclaimed only after the floor passes it, which for random nonces voids every signature below that floor.

Cancelling a Signature Before It Is Submitted

Three entry functions withdraw signatures the grantor has handed out, at three granularities:

  • cancel_nonce(nonce) — records one nonce without installing a grant, which voids the one signature carrying it and leaves the grantor’s other outstanding signatures usable. The nonce must be at or above the grantor’s floor and not already recorded; otherwise the call aborts with ENONCE_BELOW_FLOOR or ENONCE_ALREADY_USED, and the signature is inadmissible either way.
  • invalidate_nonces_below(new_floor) — raises the grantor’s nonce floor to new_floor, which voids every signature naming a lower nonce whatever its expiry, generation or amount. new_floor must be above the current floor, otherwise the call aborts with EFLOOR_NOT_ADVANCED. A grantor that allocates nonces from a counter voids its whole outstanding batch in one call, by setting the floor to the counter’s current value. The grants themselves are untouched.
  • revoke_all() — voids every grant and every signature at once, by advancing the generation.
supra move tool run \ --function-id 0x1::fungible_asset_allowance::invalidate_nonces_below \ --args u64:<NEW_FLOOR> \ --profile grantor --accept-defaults

The is_nonce_used(grantor, nonce) view reports true both for a nonce that has been recorded and for one below the floor, so a submitter can test admissibility with one call.

One-Shot Settlement

A payer signs an UptoAuthorization off chain, and the facilitator it names settles it once with settle_upto, moving any amount up to the authorization’s maximum from the payer’s primary store to the recipient’s. Settling consumes the authorization whatever amount is settled, including zero. Settlement does not read or write any grant, and the payer pays no gas.

In x402 terms, the client is the payer, the facilitator settles, pay_to is the resource server’s payTo, and the authorization is the upto scheme’s payment payload. The facilitator’s /verify maps to the verify_upto view and its /settle to settle_upto. Settling max_amount in full serves the exact scheme with the same calls.

The payer signs with an ed25519 or multi-ed25519 key, and the key rules are those of Off-Chain Signed Grants: a payer with no Account resource, such as a new wallet that has only received the asset, signs with the key its address derives from.

1. Build the authorization

FieldValue
payerThe account whose primary store is debited.
facilitatorThe account that will submit settle_upto. No other account can settle the authorization.
pay_toThe account whose primary store is credited. The store is created if absent.
metadataThe asset’s metadata object address.
max_amountThe most the payer’s store may be debited, including any fee the asset’s withdraw hook takes.
valid_afterUnix seconds. The authorization can be settled from this second.
valid_beforeUnix seconds. The authorization can no longer be settled from this second. At most 3600 (MAX_UPTO_SPAN_SECS) after valid_after.
nonce32 bytes, drawn at random per authorization. See Nonces, cancellation and removal.
generationThe payer’s current generation, from the generation view. revoke_all voids authorizations signed under an earlier one.

The chain id is filled in on chain, so an authorization made for one network is not valid on another. Every field is part of the signed payload, so a facilitator that changes the recipient, the maximum, the validity interval or any other field fails the signature check.

2. Get the bytes to sign

A payer whose key is in a supra CLI profile signs with:

supra move allowance sign-upto \ --facilitator <FACILITATOR> --pay-to <PAY_TO> --metadata <METADATA> \ --max-amount <MAX_AMOUNT> --valid-after <VALID_AFTER> --valid-before <VALID_BEFORE> \ --generation <GENERATION> --profile <PAYER_PROFILE> > upto.json

The payer is the profile’s account address. Without --nonce, the command draws 32 random bytes and reports them as message.nonce. The payer keeps that value: cancel_upto takes it to cancel the authorization before it is settled (see Nonces, cancellation and removal). The command checks and signs the view’s bytes as sign-grant does, and writes its result to standard output in the same form. An authorization for up to 0.5 SUPRA (--metadata 0xa --max-amount 50000000), valid for 600 seconds and signed on a localnet, produced:

{ "function_id": "0x1::fungible_asset_allowance::settle_upto", "message": { "chain_id": 255, "payer": "0xd665a2956f7928bdc2753c22d9f6780de7a387cdc03cb6fa2e54bf2d4a23e64c", "facilitator": "0xc2fe8c83494834c335ca3cb32c2a4354444dec05fa9ee764c637680499e64815", "pay_to": "0x8cc2cb17b506e9c0e291f52b48210874198316f38f0a0a96eba87cad0e1a89ba", "metadata": "0xa", "max_amount": "50000000", "valid_after": "1792058426", "valid_before": "1792059026", "nonce": "0xf93fd5e27dc0c320620771cb231d43df1c4f73642a7acef11a2405678404755b", "generation": "0" }, "account_scheme": 0, "public_key": "0x98b8be18b0a91189b2252f3ebe251f9905d5f912f2358ee57c1f913bc959aa0f", "signature": "0x82ff26db0dfa5d9e63717335d0f9ac5f78ba7d69cf48aa66344c0bd51a72ad5687a297d174a01d14f022fcb0005a9b07fae683f4055ad4a1d111b67b0dd0ec0b", "args": [ "address:0xd665a2956f7928bdc2753c22d9f6780de7a387cdc03cb6fa2e54bf2d4a23e64c", "address:0x8cc2cb17b506e9c0e291f52b48210874198316f38f0a0a96eba87cad0e1a89ba", "address:0xa", "u64:50000000", "u64:1792058426", "u64:1792059026", "hex:0xf93fd5e27dc0c320620771cb231d43df1c4f73642a7acef11a2405678404755b", "u64:0", "u8:0", "hex:0x98b8be18b0a91189b2252f3ebe251f9905d5f912f2358ee57c1f913bc959aa0f", "hex:0x82ff26db0dfa5d9e63717335d0f9ac5f78ba7d69cf48aa66344c0bd51a72ad5687a297d174a01d14f022fcb0005a9b07fae683f4055ad4a1d111b67b0dd0ec0b", "u64:<AMOUNT>" ] }

account_scheme, public_key and signature are <SCHEME>, <PUBLIC_KEY> and <SIGNATURE> in steps 3 and 4. args is the whole --args list of settle_upto except the amount: its last entry, u64:<AMOUNT>, is the placeholder the facilitator replaces with the amount it settles. The result holds no secret. The payer gives upto.json to the facilitator, which uses it in steps 3 and 4.

A wallet or SDK that holds the payer’s key reads the same bytes from the view:

supra move tool view \ --function-id 0x1::fungible_asset_allowance::upto_signing_message \ --args address:<PAYER> address:<FACILITATOR> address:<PAY_TO> address:<METADATA> \ u64:<MAX_AMOUNT> u64:<VALID_AFTER> u64:<VALID_BEFORE> hex:<NONCE> u64:<GENERATION>

The view returns the BCS encoding of ed25519::SignedMessage<UptoAuthorization>. Sign those bytes with the payer’s key as a plain ed25519 (or multi-ed25519) signature, without hashing them first, as in step 2 of Off-Chain Signed Grants.

3. Verify before settling

verify_upto takes the arguments settle_upto will take, with the facilitator’s address in place of its signer, and reports what the settlement would do without aborting:

supra move tool view \ --function-id 0x1::fungible_asset_allowance::verify_upto \ --args address:<FACILITATOR> address:<PAYER> address:<PAY_TO> address:<METADATA> \ u64:<MAX_AMOUNT> u64:<VALID_AFTER> u64:<VALID_BEFORE> hex:<NONCE> u64:<GENERATION> \ u8:<SCHEME> hex:<PUBLIC_KEY> hex:<SIGNATURE> u64:<AMOUNT>

With the upto.json that sign-upto wrote, those arguments are the facilitator’s own address followed by args, with <AMOUNT> replaced by the amount to settle:

AMOUNT=25000000 supra move tool view \ --function-id 0x1::fungible_asset_allowance::verify_upto \ --args address:<FACILITATOR> \ $(jq -r --arg amount "$AMOUNT" '.args[] | sub("<AMOUNT>"; $amount)' upto.json)

It returns [true, "0"] when the settlement would succeed, and otherwise false with the full abort status settle_upto would raise, category included, for example 65565 (0x1001d, EUPTO_NONCE_USED). It applies settle_upto’s admission rules, which are, in order:

  1. initialize has run (EMODULE_NOT_INITIALIZED).
  2. nonce is 32 bytes (EUPTO_INVALID_NONCE).
  3. valid_after is at or before the current block time (EUPTO_NOT_YET_VALID).
  4. The current block time is before valid_before (EUPTO_EXPIRED).
  5. valid_before - valid_after is at most 3600 (EUPTO_SPAN_TOO_LONG).
  6. amount is at most max_amount (EUPTO_AMOUNT_EXCEEDS_MAX).
  7. generation is the payer’s current generation (EGENERATION_MISMATCH).
  8. The payer has no record of nonce (EUPTO_NONCE_USED).
  9. The signature verifies over the authorization with the facilitator set to the caller (EINVALID_SIGNATURE).
  10. The issuer has not blocked delegated spending of the asset (EALLOWANCES_DISABLED).
  11. For a non-zero amount, the payer has a primary store of the asset (ESOURCE_STORE_MISSING).

For a non-zero amount it then reports a frozen payer store as EUPTO_STORE_FROZEN, a payer balance below amount as EUPTO_INSUFFICIENT_BALANCE, and a frozen recipient store as EUPTO_STORE_FROZEN. settle_upto aborts inside 0x1::fungible_asset in those three cases rather than with these codes. The view does not run the asset’s withdraw or deposit hooks, so for an asset with hooks a settlement can still abort after [true, "0"]: with EUPTO_AMOUNT_EXCEEDS_MAX when a withdraw-hook fee takes the debit above max_amount, or with the hook’s own code.

4. Settle

The facilitator submits:

supra move tool run \ --function-id 0x1::fungible_asset_allowance::settle_upto \ --args address:<PAYER> address:<PAY_TO> address:<METADATA> u64:<MAX_AMOUNT> \ u64:<VALID_AFTER> u64:<VALID_BEFORE> hex:<NONCE> u64:<GENERATION> \ u8:<SCHEME> hex:<PUBLIC_KEY> hex:<SIGNATURE> u64:<AMOUNT> \ --profile facilitator --accept-defaults

With upto.json, the arguments are args with <AMOUNT> replaced:

supra move tool run \ --function-id 0x1::fungible_asset_allowance::settle_upto \ --args $(jq -r --arg amount "$AMOUNT" '.args[] | sub("<AMOUNT>"; $amount)' upto.json) \ --profile facilitator --accept-defaults

AMOUNT is the metered amount, from 0 to MAX_AMOUNT. In order, the call records the nonce as settled until VALID_BEFORE, withdraws AMOUNT from the payer’s primary store, requires that the store was debited at most MAX_AMOUNT, deposits into PAY_TO’s primary store, and emits UptoSettled. With AMOUNT of 0 it records the nonce and moves nothing. Any abort reverts the whole call, so a settlement that fails leaves the nonce unrecorded. A Move module can call settle_upto_internal with the same arguments, which returns the amount the payer’s store was debited.

UptoSettled carries payer, facilitator, pay_to and metadata as account and metadata addresses, the nonce, max_amount, amount (what the payer’s store was debited) and received (what reached the recipient’s deposit), which is what an x402 settlement response reports.

Several authorizations from one payer can be outstanding at once and settled in any order, since each names its own nonce.

Nonces, cancellation and removal

A settlement nonce must be unique among the payer’s unexpired authorizations. Drawing 32 random bytes per authorization meets that. Settling records the nonce against the payer until the authorization’s valid_before; once the block time reaches it, anyone may remove the record, and the same nonce may then be recorded again. An authorization whose own valid_before has passed is refused whether or not its record still exists.

A payer withdraws an authorization before it is settled in one of three ways:

  • cancel_upto(nonce, valid_before), from the payer’s signer, records nonce as cancelled, so no authorization carrying it can be settled until the record is removed. Pass the latest valid_before among the authorizations being cancelled; the record lasts until the later of that value and the current block time. It aborts with EUPTO_NONCE_USED when the payer already has a record of nonce, in which case no authorization carrying it can be settled either.
  • A payer without gas signs a second authorization with the same nonce and max_amount of 0, and has it settled for 0. This works only if the second authorization’s valid_before is at or after the original’s: the record the second leaves lasts until its own valid_before, and the original must have expired by the time the record can be removed. Two authorizations that share a nonce and both have a nonzero max_amount are mutually exclusive only when their valid_before is equal.
  • revoke_all() advances the payer’s generation, which voids every authorization and every signed grant made under the old one. A key rotation voids every signature made with the old key.

upto_nonce_state(payer, nonce) returns 0 when the payer has no record, 1 for a settled record and 2 for a cancelled one. A removed record also reads 0, so to decide whether an authorization can still be settled, compare its valid_before with the current block time as well, or call verify_upto.

Each record is a table item whose storage deposit is paid by the gas payer of the transaction that writes it, which is the facilitator for a settlement and the payer for cancel_upto. Removal refunds the deposit to the gas payer of the removing transaction:

  • remove_expired_upto_nonce(payer, nonce) removes one record. It aborts with EUPTO_NONCE_NOT_FOUND when there is none and with EUPTO_NONCE_NOT_EXPIRED before its valid_before.
  • remove_expired_upto_nonces(payers, nonces) removes the record named by each position of the two lists that has reached its valid_before, and skips positions with no record or an unexpired one. It aborts with EUPTO_LENGTH_MISMATCH when the lists differ in length.

A facilitator recovers its deposits by keeping the payer and nonce of each settlement and, once their valid_before has passed, removing them in batches with remove_expired_upto_nonces.

supra move tool run \ --function-id 0x1::fungible_asset_allowance::remove_expired_upto_nonces \ --args 'address:["<PAYER_1>","<PAYER_2>"]' 'hex:["<NONCE_1>","<NONCE_2>"]' \ --profile facilitator --accept-defaults

x402 exact Without the Framework Module

x402’s Aptos scheme for exact has the client sign a transaction calling 0x1::primary_fungible_store::transfer, with the facilitator as its fee payer, and the facilitator submits it. SupraMVM admits a sender with no Account resource only in a fee-payer transaction with sequence number 0, signed with the key its address is derived from; executing that transaction creates the sender’s Account resource. The features this relies on, fee-payer transactions (FEE_PAYER_ENABLED, 22) and a sponsored sender with no Account resource (SPONSORED_AUTOMATIC_ACCOUNT_CREATION, 34), are enabled on Mainnet and Testnet. Nodes up to and including supra_node_v11.7.0 refuse such a transaction at submission, and later releases admit it. Mainnet and Testnet run supra_node_v11.7.0, so on them the flow works only for a sender that already has an Account resource. Devnet runs a later build, which admits a sender with no Account resource.

The flow needs nothing from fungible_asset_allowance, and its replay protection is the payer’s sequence number, so one payer’s payments are ordered, and a Move transaction must expire within a bound the node sets after its submission. The bound configured on Devnet’s node is listed in the description of the Submit transaction (v4) operation on the Devnet API page, https://rpc-devnet.supra.com/docs . It cannot serve upto, since the amount is fixed when the client signs.

Issuer Controls

The issuer of an asset can block delegated spending of it. The asset is one the issuer created; creating one is outside this page. The signer that owns the metadata object, directly or through intermediate objects, calls:

supra move tool run \ --function-id 0x1::fungible_asset_allowance::set_allowances_disabled \ --args address:<METADATA> bool:true \ --profile issuer --accept-defaults

false unblocks it again. The function requires the signer to own the metadata object. 0x1 owns the metadata object of SUPRA (0xa), so delegated spending of SUPRA can be blocked only by a governance proposal executed with the framework signer. The issuer of an asset paired with a coin calls set_paired_allowances_disabled<CoinType> from Move instead; it is not an entry function and needs the coin’s FreezeCapability. Grants can still be recorded while an asset is disabled; only transfer_from, transfer_from_store and settle_upto are refused, with EALLOWANCES_DISABLED. A Move package creating an asset can call disable_allowances(&constructor_ref) at creation. The allowances_disabled(metadata) view reports the setting.

Reading State and Events

Views, all under 0x1::fungible_asset_allowance:

ViewReturns
allowance(grantor, spender, metadata)[remaining, expiry_secs, scope], or ["0", "0", 0] if not live.
generation(grantor)The grantor’s current generation.
is_nonce_used(grantor, nonce)Whether a signed grant naming this nonce can no longer be admitted, because the nonce is recorded or below the floor.
min_nonce(grantor)The lowest nonce a signed grant may still name.
nonce_bucket_bits(grantor, bucket)The recorded-nonce bits of one block of 128 nonces. 0 means the block holds nothing.
reclaimable_bucket_limit(grantor)The exclusive upper bound on reclaimable block indices.
allowances_disabled(metadata)Whether the issuer has blocked delegated spending.
grant_signing_message(...)The bytes a grantor signs for a signed grant.
upto_signing_message(...)The bytes a payer signs for an authorization.
verify_upto(...)[true, "0"] when settle_upto with these arguments would succeed, otherwise false and the abort status.
upto_nonce_state(payer, nonce)0 with no record, 1 for a settled record, 2 for a cancelled one.
allowances_object_address(grantor)The object that holds the grantor’s grants.
control_object_address(metadata)The object that holds the asset’s issuer setting.

Via the REST API. In the examples, <RPC_HOST> is the RPC host with its scheme, such as https://rpc-devnet.supra.com:

curl --request POST --url '<RPC_HOST>/rpc/v3/view' \ --header 'Content-Type: application/json' \ --data '{"function":"0x1::fungible_asset_allowance::allowance", "type_arguments":[], "arguments":["<GRANTOR>","<SPENDER>","<METADATA>"]}'

Events, emitted as module events with these types, let a wallet show and revoke allowances and let an indexer rebuild delegated-spending history:

EventEmitted whenFields
GrantChangedA grant is created, changed or removed.grantor, spender, metadata, remaining, expiry_secs, generation, scope. Removal reports remaining = 0.
GrantConsumedA transfer_from or transfer_from_store succeeds.grantor, spender, metadata, store, to, transferred, charged, remaining. store and to are store addresses.
NonceConsumedA grant nonce is recorded, by apply_signed_grant or cancel_nonce.grantor, nonce, installed_grant. installed_grant is false for cancel_nonce.
UptoSettledA settle_upto succeeds.payer, facilitator, pay_to, metadata, nonce, max_amount, amount (debited), received (credited). payer and pay_to are account addresses.
UptoCancelledcancel_upto runs.payer, nonce.
NoncesInvalidatedinvalidate_nonces_below runs.grantor, min_nonce (the new floor).
AllGrantsRevokedrevoke_all runs.grantor, generation (the new value).
AllowancesDisabledChangedAn issuer changes the setting.metadata, disabled.

A grant recorded in GrantChanged is live only while its generation equals the grantor’s latest AllGrantsRevoked.generation (or 0 if none) and its expiry_secs is in the future. An indexer that shows allowances should apply both tests rather than trust the last remaining it saw.

NonceConsumed is the only event naming a consumed grant nonce; nothing else in the stream reports one. Settlement nonces appear in UptoSettled and UptoCancelled. Removing a settlement nonce record emits no event.

Storage and Reclamation

Grants live on an object derived from the grantor’s address, and issuer settings on an object derived from the metadata address. Whoever writes first creates the object and pays its storage deposit; for a signed grant that is the submitter. Table entries for grants and for blocks of recorded nonces are paid for the same way.

Two entry functions let anyone delete entries that can no longer be used, and the transaction’s gas payer receives the storage refund:

  • remove_dead(grantor, spender, metadata) — a grant that is expired, fully spent, or from an old generation. Aborts with EGRANT_STILL_LIVE otherwise.
  • remove_used_nonce_bucket(grantor, bucket) — one block of 128 recorded nonces that the grantor’s nonce floor has passed entirely. Aborts with EBUCKET_NOT_INVALIDATED while any nonce the block covers is still at or above the floor, and with EBUCKET_NOT_FOUND when the block holds no record.

A reclaimer needs no record of which nonces a grantor used. It reads reclaimable_bucket_limit(grantor) and calls remove_used_nonce_bucket for each index below that bound; the indices that hold nothing abort with EBUCKET_NOT_FOUND and can be skipped, and nonce_bucket_bits(grantor, bucket) identifies them in advance, reading 0.

Blocks become reclaimable only as the floor advances, so a grantor that wants its nonce storage released calls invalidate_nonces_below once it has moved past a range of nonces.

Settlement nonce records are items of one table held by the module’s registry at 0x1, keyed by payer and nonce. Nonces, cancellation and removal covers who pays for them and how they are removed.

Abort Codes

All in 0x1::fungible_asset_allowance. The full status code is the category prefix followed by the code, for example 0x10002 for EINSUFFICIENT_ALLOWANCE.

CodeStatusNameWhen
10x60001EALLOWANCE_NOT_FOUNDNo live grant for this grantor, spender and asset.
20x10002EINSUFFICIENT_ALLOWANCEThe spend, including any hook fee, exceeds the live remaining amount.
30x10003EINVALID_EXPIRYExpiry is not later than the current block time.
40x10004EEXPIRY_TOO_FARExpiry is more than 366 days ahead.
50x30005EALLOWANCE_MISMATCHexpected_remaining does not equal the live remaining amount.
60x10006ENONCE_ALREADY_USEDThis grantor has already recorded this grant nonce.
70x50007EALLOWANCES_DISABLEDThe issuer has blocked delegated spending of this asset.
80x60008ESOURCE_STORE_MISSINGThe grantor or payer has no primary store for this asset.
90x30009EGRANT_STILL_LIVEremove_dead on a live grant.
100x1000aEAMOUNT_CANNOT_BE_ZEROincrease by zero, or a zero spend.
110x5000bENOT_METADATA_OWNERThe signer does not own the metadata object.
120x6000cEMODULE_NOT_INITIALIZEDinitialize has not run on this chain.
130x2000dEALLOWANCE_OVERFLOWincrease would exceed u64.
140x3000eEGENERATION_MISMATCHThe signed grant’s or authorization’s generation is not the signer’s current one.
150x1000fENOT_FUNGIBLE_ASSET_METADATAdisable_allowances on a constructor ref that is not a Metadata object.
160x10010EINVALID_SCOPEscope is neither 0 nor 1.
170x30011EALLOWANCE_SCOPE_MISMATCHincrease on a live grant with a different scope.
180x50012ESTORE_OUT_OF_SCOPEThe source store is outside the grant’s scope.
190x10013ENONCE_BELOW_FLOORThe grant nonce is below the grantor’s nonce floor.
200x10014EFLOOR_NOT_ADVANCEDinvalidate_nonces_below with a floor at or below the current one.
210x30015EBUCKET_NOT_INVALIDATEDremove_used_nonce_bucket on a block the floor has not passed entirely.
220x60016EBUCKET_NOT_FOUNDremove_used_nonce_bucket for a block this grantor has no record of.
230x60017ECOIN_NOT_PAIREDset_paired_allowances_disabled for a coin with no paired fungible asset.
240x10018EUPTO_INVALID_NONCEA settlement nonce is not 32 bytes.
250x30019EUPTO_NOT_YET_VALIDThe block time is before the authorization’s valid_after.
260x3001aEUPTO_EXPIREDThe block time is at or after the authorization’s valid_before.
270x1001bEUPTO_SPAN_TOO_LONGvalid_before is more than 3600 seconds after valid_after.
280x1001cEUPTO_AMOUNT_EXCEEDS_MAXThe amount, or the debit including any withdraw-hook fee, is above max_amount.
290x1001dEUPTO_NONCE_USEDThe payer already has a record of this settlement nonce.
300x1001eEINVALID_SIGNATUREThe scheme is not 0 or 1, the public key does not derive the signer’s authentication key, or the signature does not verify. Applies to apply_signed_grant and settle_upto.
310x3001fEUPTO_NONCE_NOT_EXPIREDremove_expired_upto_nonce before the record’s valid_before.
320x60020EUPTO_NONCE_NOT_FOUNDremove_expired_upto_nonce for a record the payer does not have.
330x10021EUPTO_INSUFFICIENT_BALANCEReported by verify_upto only: the payer’s balance is below the amount.
340x50022EUPTO_STORE_FROZENReported by verify_upto only: the payer’s or the recipient’s primary store is frozen.
350x10023EUPTO_LENGTH_MISMATCHremove_expired_upto_nonces with lists of different lengths.
Last updated on