blog·2026-03-22·8 min read

Build a Signature Permit Gate for Aleo Token Registry Flows

Permit flows look dull until the day they become the whole product.

A wallet owner wants to approve one narrow spend without leaving a broad allowance hanging around for weeks. On Aleo, that pattern fits especially well with a registry-driven asset system, because you can keep token logic in one place and move policy into a small controller program.

My bias is simple: standing approvals are sloppy. A signed permit with a nonce and a fixed amount is easier to reason about, easier to test, and much kinder to users.

What we are building

We are going to build signature_permit_gate.aleo, a Leo program that does four jobs:

  • Hash a permit message that binds owner, spender, token_id, amount, nonce, and expires_at.
  • Verify that the owner signed that exact message.
  • Store spendable allowance under a hashed (owner, spender, token_id) key.
  • Burn each nonce once so the same permit cannot be replayed.

The local example is self-contained. In a real registry flow, the same consume_allowance rule is the part you plug into your external authorization path before the token move goes through.

One honest note before we touch code: bootstrap_allowance exists only to make local testing less annoying. It is a dev helper, not a feature. Remove it before you deploy anything real.

Scaffold the project

Start from a blank Leo app.

bash
leo new signature_permit_gate
cd signature_permit_gate

Set the program name to signature_permit_gate.aleo, then replace src/main.leo with the code below.

One Leo quirk matters here: owner is reserved on records, so the helper structs below use grantor instead. The public transition inputs still use owner because that is the term wallets and backends will already expect.

The Leo program

leo
program signature_permit_gate.aleo {
    struct Permit {
        grantor: address,
        spender: address,
        token_id: field,
        amount: u64,
        nonce: u64,
        expires_at: u32,
    }

    struct NonceKey {
        grantor: address,
        nonce: u64,
    }

    struct AllowanceKey {
        grantor: address,
        spender: address,
        token_id: field,
    }

    record PermitReceipt {
        owner: address,
        grantor: address,
        spender: address,
        token_id: field,
        amount: u64,
        nonce: u64,
        expires_at: u32,
    }

    mapping admins: bool => address;
    mapping used_nonces: field => bool;
    mapping allowances: field => u64;

    inline hash_permit(
        owner: address,
        spender: address,
        token_id: field,
        amount: u64,
        nonce: u64,
        expires_at: u32,
    ) -> field {
        let permit: Permit = Permit {
            grantor: owner,
            spender: spender,
            token_id: token_id,
            amount: amount,
            nonce: nonce,
            expires_at: expires_at,
        };

        return BHP256::hash_to_field(permit);
    }

    inline hash_nonce(owner: address, nonce: u64) -> field {
        let key: NonceKey = NonceKey {
            grantor: owner,
            nonce: nonce,
        };

        return BHP256::hash_to_field(key);
    }

    inline hash_allowance(owner: address, spender: address, token_id: field) -> field {
        let key: AllowanceKey = AllowanceKey {
            grantor: owner,
            spender: spender,
            token_id: token_id,
        };

        return BHP256::hash_to_field(key);
    }

    async transition initialize(public admin_addr: address) -> Future {
        return finalize_initialize(admin_addr);
    }

    async function finalize_initialize(admin_addr: address) {
        let exists: bool = Mapping::contains(admins, true);
        assert(!exists);
        Mapping::set(admins, true, admin_addr);
    }

    transition permit_hash(
        public owner: address,
        public spender: address,
        public token_id: field,
        public amount: u64,
        public nonce: u64,
        public expires_at: u32,
    ) -> field {
        return hash_permit(owner, spender, token_id, amount, nonce, expires_at);
    }

    transition allowance_key(
        public owner: address,
        public spender: address,
        public token_id: field,
    ) -> field {
        return hash_allowance(owner, spender, token_id);
    }

    async transition submit_permit(
        public owner: address,
        public spender: address,
        public token_id: field,
        public amount: u64,
        public nonce: u64,
        public expires_at: u32,
        sig: signature,
    ) -> (PermitReceipt, Future) {
        assert(amount > 0u64);

        let message_hash: field = hash_permit(owner, spender, token_id, amount, nonce, expires_at);
        let valid: bool = signature::verify(sig, owner, message_hash);
        assert(valid);

        let nonce_key: field = hash_nonce(owner, nonce);
        let allow_key: field = hash_allowance(owner, spender, token_id);

        let receipt: PermitReceipt = PermitReceipt {
            owner: spender,
            grantor: owner,
            spender: spender,
            token_id: token_id,
            amount: amount,
            nonce: nonce,
            expires_at: expires_at,
        };

        return (receipt, finalize_submit_permit(nonce_key, allow_key, amount));
    }

    async function finalize_submit_permit(nonce_key: field, allow_key: field, amount: u64) {
        let seen: bool = Mapping::get_or_use(used_nonces, nonce_key, false);
        assert(!seen);

        let current: u64 = Mapping::get_or_use(allowances, allow_key, 0u64);
        Mapping::set(used_nonces, nonce_key, true);
        Mapping::set(allowances, allow_key, current + amount);
    }

    async transition bootstrap_allowance(
        public owner: address,
        public spender: address,
        public token_id: field,
        public amount: u64,
    ) -> (PermitReceipt, Future) {
        assert(amount > 0u64);

        let signer: address = self.signer;
        let allow_key: field = hash_allowance(owner, spender, token_id);

        let receipt: PermitReceipt = PermitReceipt {
            owner: spender,
            grantor: owner,
            spender: spender,
            token_id: token_id,
            amount: amount,
            nonce: 0u64,
            expires_at: 0u32,
        };

        return (receipt, finalize_bootstrap_allowance(signer, allow_key, amount));
    }

    async function finalize_bootstrap_allowance(signer: address, allow_key: field, amount: u64) {
        let admin_addr: address = Mapping::get(admins, true);
        assert_eq(signer, admin_addr);

        let current: u64 = Mapping::get_or_use(allowances, allow_key, 0u64);
        Mapping::set(allowances, allow_key, current + amount);
    }

    async transition consume_allowance(
        public owner: address,
        public spender: address,
        public token_id: field,
        public amount: u64,
    ) -> Future {
        assert(amount > 0u64);
        assert_eq(self.signer, spender);

        let allow_key: field = hash_allowance(owner, spender, token_id);
        return finalize_consume_allowance(allow_key, amount);
    }

    async function finalize_consume_allowance(allow_key: field, amount: u64) {
        let current: u64 = Mapping::get_or_use(allowances, allow_key, 0u64);
        assert(current >= amount);
        Mapping::set(allowances, allow_key, current - amount);
    }

    async transition set_admin(public next_admin: address) -> Future {
        let signer: address = self.signer;
        return finalize_set_admin(signer, next_admin);
    }

    async function finalize_set_admin(signer: address, next_admin: address) {
        let current_admin: address = Mapping::get(admins, true);
        assert_eq(signer, current_admin);
        Mapping::set(admins, true, next_admin);
    }
}

How the gate works

Permit is the signed payload. In the public API the signer is still called owner, but inside the helper structs I renamed that field to grantor because Leo reserves owner for records.

  • owner: the account granting permission.
  • spender: the account allowed to use it.
  • token_id: the registry asset this applies to.
  • amount: the approved spend.
  • nonce: replay protection.
  • expires_at: an expiry value you can pass downstream.

submit_permit hashes that struct, verifies the signature, derives a nonce key, derives an allowance key, and then hands state changes to finalize_submit_permit. That split matters. The proof-side work stays in the transition body, while public state updates stay in the finalize block where Leo expects them.

The gate keeps two public mappings:

  • used_nonces marks a permit as spent the first time it lands.
  • allowances tracks how much room is left for a specific owner, spender, and token.

consume_allowance is the piece you actually care about in production. The spender has to be the signer, the amount has to be positive, and the remaining allowance has to cover the request.

One design choice is deliberate: this example carries expires_at through the signed payload and the receipt, but it does not pretend to enforce wall-clock time by itself. The clean place to enforce expiry is the registry-side authorization path that already deals with live chain context.

Local checks

Build first.

bash
leo build

Then run the two pure helpers. They are boring on purpose. If your wallet, backend, and Leo code do not agree on these hashes, stop there and fix that before you wire anything into a registry path.

bash
leo run permit_hash aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 aleo19y2eyc2cycvdqmycqam60l6uexvfj468xcet65jnnzc5pn8g9ufqg2clp2 999field 25u64 1u64 5000u32

leo run allowance_key aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 aleo19y2eyc2cycvdqmycqam60l6uexvfj468xcet65jnnzc5pn8g9ufqg2clp2 999field

For a quick state-path check, initialize the admin slot, seed a test allowance, and consume part of it.

bash
leo execute initialize aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9

leo execute bootstrap_allowance aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 aleo19y2eyc2cycvdqmycqam60l6uexvfj468xcet65jnnzc5pn8g9ufqg2clp2 999field 25u64

leo execute consume_allowance aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 aleo19y2eyc2cycvdqmycqam60l6uexvfj468xcet65jnnzc5pn8g9ufqg2clp2 999field 10u64

After that, switch to the real path:

  1. Have the owner sign the field returned by permit_hash.
  2. Submit that signature to submit_permit.
  3. Call consume_allowance from the permitted spender account.
  4. Fail hard on any mismatch in owner, spender, token, amount, or nonce.

The negative tests matter more than the happy path:

  • Reuse the same nonce. It must fail.
  • Try to spend more than the stored allowance. It must fail.
  • Call consume_allowance from an address that is not the permitted spender. It must fail.
  • Keep owner and spender the same but swap token_id. It must fail.

Permit code is security code wearing application clothes. Treat it that way.

Wiring it into a registry flow

A production flow usually looks like this:

  1. Register the asset with external authorization turned on.
  2. Point that authorization path at your permit controller.
  3. Have the owner sign a permit off-chain.
  4. Submit the permit once so the controller records allowance and burns the nonce.
  5. When the spend request arrives, call consume_allowance before letting the asset move.

That gives you a neat split of duties. The registry keeps custody and token rules. The permit gate handles delegated spend policy.

Next changes I would make

First, move real expiry enforcement into the registry-facing authorization call where chain time is available. Carrying expires_at without a time check is fine for a local demo, but not enough for production.

Second, replace bootstrap_allowance with a wallet or SDK signing path as soon as you have one. The helper is useful for smoke tests, then it should disappear.

Third, decide whether you even want the PermitReceipt record. I like it because it gives private flows a concrete artifact to pass around, but some apps will prefer a slimmer interface with no receipt at all.

That is the whole pattern in a compact form: one signed message, one nonce burn, one allowance bucket, and one spend check before anything valuable moves.

Sources