Skip to main content
Continues the witness-derived keypair pattern from Approval and Access Control. The spender authenticates by proving knowledge of their witness secret.

The Contract

pragma language_version >= 0.23;

import CompactStandardLibrary;

struct UserSecretKey { bytes: Bytes<32>; }
struct UserPublicKey { bytes: Bytes<32>; }
witness getUserSecret(): UserSecretKey;

// Balances and nested allowance map
export ledger _balances: Map<UserPublicKey, Uint<128>>;
export ledger _allowances: Map<UserPublicKey, Map<UserPublicKey, Uint<128>>>;

export circuit deriveUserPublicKey(sk: UserSecretKey): UserPublicKey {
    return UserPublicKey {
        bytes: persistentHash<Vector<2, Bytes<32>>>([
            pad(32, "myapp:user:v1"),
            sk.bytes
        ])
    };
}

// Read remaining allowance
export circuit allowance(
    owner: UserPublicKey,
    spender: UserPublicKey
): Uint<128> {
    if (!_allowances.member(disclose(owner))) { return 0; }
    if (!_allowances.lookup(disclose(owner)).member(disclose(spender))) { return 0; }
    return _allowances.lookup(disclose(owner)).lookup(disclose(spender));
}

// transferFrom: caller authenticates as the *spender*, moves tokens
// from `owner` to `to`, and pays for it with their allowance.
export circuit transferFrom(
    owner: UserPublicKey,
    to: UserPublicKey,
    amount: Uint<128>
): Boolean {
    const spender = disclose(deriveUserPublicKey(getUserSecret()));
    _spendAllowance(owner, spender, amount);
    _transfer(owner, to, amount);
    return true;
}

// Internal: spend (decrement) the allowance, with infinite-approval shortcut
circuit _spendAllowance(
    owner: UserPublicKey,
    spender: UserPublicKey,
    amount: Uint<128>
): [] {
    const current = allowance(owner, spender);
    const MAX_UINT128: Uint<128> = 340282366920938463463374607431768211455 as Uint<128>;
    if (current < MAX_UINT128) {
        assert(current >= amount, "Insufficient allowance");
        _approve(owner, spender, current - amount);
    }
    // current == MAX_UINT128 means "infinite approval"; leave allowance alone.
}

// Internal: write the allowance entry
circuit _approve(
    owner: UserPublicKey,
    spender: UserPublicKey,
    amount: Uint<128>
): [] {
    if (!_allowances.member(disclose(owner))) {
        _allowances.insert(disclose(owner), default<Map<UserPublicKey, Uint<128>>>);
    }
    _allowances.lookup(disclose(owner)).insert(disclose(spender), disclose(amount));
}

// Internal: move tokens from `sender` to `to`
circuit _transfer(
    sender: UserPublicKey,
    to: UserPublicKey,
    amount: Uint<128>
): [] {
    const MAX_UINT128: Uint<128> = 340282366920938463463374607431768211455 as Uint<128>;
    const fromBalance = getBalance(sender);
    assert(fromBalance >= amount, "Insufficient balance");
    const toBalance = getBalance(to);
    assert(toBalance <= MAX_UINT128 - amount, "Balance overflow");
    _balances.insert(disclose(sender), disclose(fromBalance - amount));
    _balances.insert(disclose(to), disclose((toBalance + amount) as Uint<128>));
}

// Helper
circuit getBalance(account: UserPublicKey): Uint<128> {
    if (!_balances.member(disclose(account))) { return 0; }
    return _balances.lookup(disclose(account));
}

How It Works

transferFrom authenticates the spender

export circuit transferFrom(
    owner: UserPublicKey,
    to: UserPublicKey,
    amount: Uint<128>
): Boolean {
    const spender = disclose(deriveUserPublicKey(getUserSecret()));
    _spendAllowance(owner, spender, amount);
    _transfer(owner, to, amount);
    return true;
}
The caller must produce a ZK proof that they hold the secret behind the spender’s stored UserPublicKey. The owner and to are explicit parameters — they are targets of the operation, not authorities.

Why this resists impersonation

_spendAllowance(owner, spender, amount) requires that the inner map _allowances[owner][spender] already holds at least amount. Because the allowance was written by owner against a specific spender public key, and because spender here was just derived from the caller’s witness secret, the caller must actually hold the spender’s secret. An attacker with no secret cannot synthesise a passing proof, no matter what they read from the chain.

Infinite approval

if (current < MAX_UINT128) {
    assert(current >= amount, "Insufficient allowance");
    _approve(owner, spender, current - amount);
}
If the recorded allowance equals MAX_UINT128, treat it as unlimited and skip the decrement. Useful for long-lived integrations (e.g. a DEX).

Partial spending

// Alice approves Bob for 1000 tokens (single transaction)
_approve(alice, bob, 1000);

// Bob calls transferFrom multiple times; the allowance decreases each call
//   transferFrom(alice, recipient1, 300)  → remaining: 700
//   transferFrom(alice, recipient2, 400)  → remaining: 300
//   transferFrom(alice, recipient3, 300)  → remaining: 0
//   transferFrom(alice, recipient4, 1)    → "Insufficient allowance"

Privacy Note

Each transferFrom writes the (owner, spender, amount) tuple to the public ledger. The participant graph is visible to chain observers.

What’s Next

Token Approval

Back to granting approvals

ERC20 Token

Complete token implementation