Skip to main content
ownPublicKey() is not authentication. Anyone reading the chain can copy a public key and supply it as ownPublicKey() in a new transaction. To authenticate the caller, prove knowledge of a witness-held secret whose hash matches a stored public key — the Access Control pattern. This page builds the transfer function on top of that pattern.

The Contract

pragma language_version >= 0.23;

import CompactStandardLibrary;

// Witness-derived keypair used to authenticate the caller.
// The DApp generates the secret per user and keeps it in private state.
struct UserSecretKey { bytes: Bytes<32>; }
struct UserPublicKey { bytes: Bytes<32>; }
witness getUserSecret(): UserSecretKey;

// Balances keyed by the user's authenticated public key
export ledger _balances: Map<UserPublicKey, Uint<128>>;

// Derive the on-chain identifier from the witness secret
export circuit deriveUserPublicKey(sk: UserSecretKey): UserPublicKey {
    return UserPublicKey {
        bytes: persistentHash<Vector<2, Bytes<32>>>([
            pad(32, "myapp:user:v1"),
            sk.bytes
        ])
    };
}

// Transfer tokens from the caller to `to`.
// The caller proves they hold the secret behind their balance entry.
export circuit transfer(to: UserPublicKey, amount: Uint<128>): Boolean {
    const sender = disclose(deriveUserPublicKey(getUserSecret()));
    _transfer(sender, to, amount);
    return true;
}

// Internal transfer logic (no caller authentication; only callable by
// authenticated wrappers above and by the allowance flow).
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: read a user's balance (returns 0 if unset)
export circuit getBalance(account: UserPublicKey): Uint<128> {
    if (!_balances.member(disclose(account))) {
        return 0;
    }
    return _balances.lookup(disclose(account));
}

How It Works

Authentication via witness-derived keypair

export circuit transfer(to: UserPublicKey, amount: Uint<128>): Boolean {
    const sender = disclose(deriveUserPublicKey(getUserSecret()));
    _transfer(sender, to, amount);
    return true;
}
To call transfer, the caller must produce a ZK proof that they know a UserSecretKey whose hash matches the on-chain UserPublicKey they’re spending from. The on-chain balance is keyed by the hash of the secret; reading the chain gives an attacker the hash, not the preimage. Compare with the broken pattern (do not use):
// ❌ Authentication does NOT work this way
const sender = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
ownPublicKey() returns whatever value the prover claims to know. An attacker reads sender from the chain, supplies it as their own ownPublicKey(), and produces a valid proof. See Access Control.

Balance and overflow checks

const fromBalance = getBalance(sender);
assert(fromBalance >= amount, "Insufficient balance");

const toBalance = getBalance(to);
assert(toBalance <= MAX_UINT128 - amount, "Balance overflow");
Read both balances, assert the sender has enough, and assert the recipient’s new balance won’t overflow Uint<128>. See Overflow Protection.

Cast back to Uint<128> on store

_balances.insert(disclose(to), disclose((toBalance + amount) as Uint<128>));
Compact arithmetic widens the result type. After the overflow assertion, the sum is explicitly cast back to Uint<128> before being stored.

Privacy Note

This contract publishes the full participant graph. _balances is keyed by UserPublicKey, every transfer discloses both sender and to, and the inserted amount is also disclosed. Anyone reading the ledger sees the complete history of who paid whom and how much.This is the account-model privacy profile. For payments where senders, recipients, and amounts must be private, use Midnight’s shielded token primitives (sendShielded, mintShieldedToken, etc.) — they’re built on UTXOs and ZK commitments and don’t leak the participant graph. The account-model pattern shown here is appropriate for registries where participants are intentionally public (game leaderboards, public reward programs, etc.).

What’s Next

Token Approval

Grant permission for delegated transfers

ERC20 Token

Complete token with all features