Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 95 additions & 18 deletions contracts/src/token/ConfidentialFungibleToken.compact
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,33 @@ pragma language_version >= 0.23.0;
* high-entropy, and secret for every invocation.
*
* Seed REUSE leaks plaintext differences, and the circuit does NOT prevent
* it. The debit and escrow ciphertexts (`_debit`, `approve`, `_spendEscrow`,
* and the re-approve refund) derive randomness from a static domain tag with
* no per-operation nonce, so two operations under one seed share randomness
* and their public ciphertext deltas reveal the amount difference. The credit
* path folds the recipient's memo-list length in as a nonce (defense in
* depth), but that counter RESETS on `clearMemos`, so it is an in-epoch
* backstop only, never a substitute for freshness.
* it. The debit and re-approve-refund ciphertexts (`_debit`, `approve`) derive
* randomness from a static domain tag with no per-op nonce, so two
* operations under one seed share randomness and their public ciphertext deltas
* reveal the amount difference. `_spendEscrow` is the exception: every value it
* derives folds in the spend epoch AND both counterparties, so its randomness
* is unique per spend regardless of the seed. The credit path folds the
* memo-list length in as a nonce (defense in depth), but that counter RESETS on
* `clearMemos`, so it is an in-epoch backstop only, never a substitute for
* freshness.
*
* @notice WHOSE confidentiality a seed protects is not always the caller's. On
* most paths a wallet's seed hygiene guards its own amounts, so the incentive
* and the exposure sit with the same party. The escrow spend path is the
* exception: `_spendEscrow` expands every random value it uses from the
* SPENDER's seed, but all three protect the OWNER. Two re-encrypt their escrow
* and the third masks their remaining allowance. A spender reusing a seed leaks
* a counterparty's balance, not their own, and the owner cannot prevent it by
* rotating their own seed as the leaking call is not theirs.
*
* The accidental case is removed by binding both counterparties into the
* derivation alongside the epoch, which counts per (owner, spender) and so
* restarts for each pair. That covers the two ways one seed repeats randomness
* across calls: one spender drawing on two owners, and two wallets sharing a
* seed drawing on one owner. Nothing stops a spender who deliberately publishes
* their seed, since any construction must let them compute the pad. Wallet
* authors supplying a seed for `transferFrom` / `burnFrom` should understand
* they are protecting the owner they are spending on behalf of.
*
* Seed generation is a WALLET responsibility and OUT OF SCOPE for this
* contract, which cannot enforce it. The witness shipped in this repo
Expand Down Expand Up @@ -174,15 +194,15 @@ pragma language_version >= 0.23.0;
* into the derivations is a possible defense-in-depth hardening; under review, deferred.
*
* Block weight. A deploy bundles every exported entry point's on-chain IR into
* one transaction, and the value circuits are SHA256-heavy (k=16), so a
* full-surface deployment is byte-heavy. Whether it fits is a property of the
* one transaction, and the value circuits are SHA256-heavy (k=16, k=17 for
* `transferFrom`), so a full-surface deployment is byte-heavy. Whether it fits is a property of the
* target network, not the contract: the per-transaction block byte budget is a
* governance-configurable, node-side ledger parameter. On the local ledger-8 dev
* stack the full surface exceeded it and was rejected (`1010: Transaction would
* exhaust the block limits`), while a reduced surface (e.g. receive + transfer,
* dropping allowances and supply accounting) fit. So confirm deployability
* against the target network; if a deployment is over budget there, the lever is
* exposing only the k=16 value operations it needs, not just trimming getters.
* exposing only the value operations it needs, not just trimming getters.
* A deployed contract can also be upgraded by its Contract Maintenance Authority
* (CMA), so an over-budget deployment can start with a lean surface and add
* operations by upgrade instead of fitting the whole surface into one deploy.
Expand Down Expand Up @@ -254,6 +274,20 @@ module ConfidentialFungibleToken {
ownerMemo: EcdhMask_Ciphertext;
}

// Hash preimage for the per-spend randomness `_spendEscrow` derives. The epoch
// counts per (owner, spender) and so restarts for each pair, which is why both
// parties are bound in alongside it. Drop the owner and one spender's first
// spend against two owners repeats `rSpender`; drop the spender and two wallets
// sharing a seed repeat `rOwner` against one owner. Either repeat cancels on
// subtraction, because the colliding pair is encrypted under one key.
struct SpendRandomnessPreimage {
seed: Bytes<32>;
domain: Bytes<32>;
owner: Bytes<32>;
spender: Bytes<32>;
epoch: Field;
}

// ---------------------------------------------------------------------------
// Ledger state
// ---------------------------------------------------------------------------
Expand All @@ -270,6 +304,15 @@ module ConfidentialFungibleToken {
export ledger _memos: Map<Bytes<32>, List<EcdhMask_Ciphertext>>;
export ledger _escrow: Map<Bytes<32>, Map<Bytes<32>, EscrowEntry>>;

// Per-(owner, spender) escrow-spend counter, folded into the randomness
// `_spendEscrow` derives so repeat spends by one spender against one owner
// never share a one-time pad. It restarts for each pair, which is why both
// counterparties are bound in too; see `SpendRandomnessPreimage`.
//
// Kept out of `EscrowEntry`: `approve` replaces that entry wholesale, so a
// counter living there would restart on every re-approve.
export ledger _escrowSpendEpochs: Map<Bytes<32>, Map<Bytes<32>, Counter>>;

export ledger _isInitialized: Boolean;

export sealed ledger _name: Opaque<"string">;
Expand Down Expand Up @@ -345,9 +388,10 @@ module ConfidentialFungibleToken {
* module header). The derivation is deterministic and public, so an observer
* who knows the seed can strip a memo's mask and read the delivered amount, and
* recover balance/escrow amounts from the public ciphertexts. Seed REUSE leaks
* plaintext differences and the circuit does NOT prevent it: only the credit
* path folds a per-recipient nonce (the memo-list length) in as defense in
* depth, and even that resets on `clearMemos`. The seed MUST be fresh,
* plaintext differences and the circuit does NOT prevent it on most paths.
* `_spendEscrow` is an exception (its randomness folds the spend epoch and both
* counterparties); the credit path folds the recipient's memo-list length as
* defense in depth, and that resets on `clearMemos`. The seed MUST be fresh,
* high-entropy, and secret for every invocation.
*/
witness wit_RandomnessSeed(): Bytes<32>;
Expand Down Expand Up @@ -936,7 +980,7 @@ module ConfidentialFungibleToken {
* caller via `approve`) and credits it to `to`, reducing both escrow copies by
* `value`. The amount is hidden.
*
* @circuitInfo k=16, rows=63207
* @circuitInfo k=17, rows=70164
*
* @notice Returns the caller's (spender's) `accountId` for caller-side gating.
*
Expand Down Expand Up @@ -1086,9 +1130,23 @@ module ConfidentialFungibleToken {
"ConfidentialFungibleToken: insufficient allowance");

const seed = wit_RandomnessSeed();
const rSpender = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_spender"));
const rOwner = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_owner"));
const eOwnerMemo = ElGamal_expandRandomness(seed, pad(32, "spend_owner_memo"));

// The seed is the SPENDER's, but all three values protect the OWNER. The
// epoch below plus both counterparties in the preimage are what keep them
// distinct when a seed is reused. See the module header's @notice.
if (!_escrowSpendEpochs.member(disclose(fromAddress))) {
_escrowSpendEpochs.insert(disclose(fromAddress), default<Map<Bytes<32>, Counter>>);
}
if (!_escrowSpendEpochs.lookup(disclose(fromAddress)).member(disclose(spenderId))) {
_escrowSpendEpochs.lookup(disclose(fromAddress)).insert(disclose(spenderId), default<Counter>);
}
_escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)).increment(1);
const spendEpoch =
_escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)) as Field;

const rSpender = _expandSpendRandomness(seed, pad(32, "spend_escrow_spender"), fromAddress, spenderId, spendEpoch);
const rOwner = _expandSpendRandomness(seed, pad(32, "spend_escrow_owner"), fromAddress, spenderId, spendEpoch);
const eOwnerMemo = _expandSpendRandomness(seed, pad(32, "spend_owner_memo"), fromAddress, spenderId, spendEpoch);

const ownerPk = _encryptionKeys.lookup(disclose(fromAddress));
const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender);
Expand Down Expand Up @@ -1119,7 +1177,7 @@ module ConfidentialFungibleToken {
* recipient credit. The building block for a composing contract's
* `burnFrom`; it performs no supply accounting (see `_burn`).
*
* @circuitInfo k=16, rows=36274
* @circuitInfo k=16, rows=43231
*
* Requirements:
*
Expand All @@ -1135,6 +1193,25 @@ module ConfidentialFungibleToken {
return _spendEscrow(fromAddress, value);
}

/**
* @description Derives one per-spend random scalar for `_spendEscrow`. Does
* what `ElGamal_expandRandomness` does, but folds both counterparties and the
* escrow-spend epoch into the preimage as well as the seed and domain tag, so
* each spend gets distinct randomness even when a seed is reused.
*/
pure circuit _expandSpendRandomness(
seed: Bytes<32>, domain: Bytes<32>, owner: Bytes<32>, spender: Bytes<32>,
epoch: Field): Field {
return degradeToTransient(
persistentHash<SpendRandomnessPreimage>(
SpendRandomnessPreimage {
seed: seed, domain: domain, owner: owner, spender: spender,
epoch: epoch
}
)
);
}

/**
* @description Refunds any prior escrow for (ownerId, spender) to the owner's
* main balance and clears the slot, so re-approving replaces rather than
Expand Down
177 changes: 171 additions & 6 deletions contracts/src/token/test/ConfidentialFungibleToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,22 +377,69 @@ describe.skipIf(isLiveBackend())(

// Alice (owner) funds with `amount` and approves Bob (spender) for `cap`.
// Leaves Alice active.
const approveBob = async (amount: bigint, cap: bigint) => {
await registerAll();
const fundAndApprove = async (
owner: typeof ALICE,
amount: bigint,
cap: bigint,
) => {
await cft.privateState.switchIdentity(
ALICE.secretKey,
ALICE.encryptionKey,
owner.secretKey,
owner.encryptionKey,
);
await cft._mint(ALICE.accountId, amount);
await cft._mint(owner.accountId, amount);
// Dual-balance: sweep the minted value into spendable so approve can debit it.
await cft.sweep();
await cft.privateState.cachePlaintext(
await cft.balanceOf(ALICE.accountId),
await cft.balanceOf(owner.accountId),
amount,
);
await cft.approve(BOB.accountId, cap);
};

const approveBob = async (amount: bigint, cap: bigint) => {
await registerAll();
await fundAndApprove(ALICE, amount, cap);
};

// One escrow spend by Bob, returning the entry either side. `allowance` is
// his current remaining, which he must cache to prove the spend.
const spendEscrow = async (
value: bigint,
allowance: bigint,
via: 'transferFrom' | 'burnFrom' = 'transferFrom',
owner = ALICE,
spender = BOB,
) => {
await cft.privateState.switchIdentity(
spender.secretKey,
spender.encryptionKey,
);
const before = await cft.allowance(owner.accountId, spender.accountId);
await cft.privateState.cachePlaintext(before.spenderCt, allowance);
if (via === 'transferFrom') {
await cft.transferFrom(owner.accountId, CHARLIE.accountId, value);
} else {
await cft._burnFrom(owner.accountId, value);
}
const after = await cft.allowance(owner.accountId, spender.accountId);
return { before, after };
};

type SpendPair = Awaited<ReturnType<typeof spendEscrow>>;

// Not plain inequality: an intervening re-approve re-randomizes the escrow
// anyway so that would pass unfixed. Both spends subtract `Enc(value, r)`
// for the same value, so repeated randomness means
// `before1 - after1 == before2 - after2` i.e. `b1 + a2 == b2 + a1`
const expectRerandomized = (a: SpendPair, b: SpendPair) => {
expect(
elgamal.add(a.before.spenderCt, b.after.spenderCt),
).not.toStrictEqual(elgamal.add(b.before.spenderCt, a.after.spenderCt));
expect(elgamal.add(a.before.ownerCt, b.after.ownerCt)).not.toStrictEqual(
elgamal.add(b.before.ownerCt, a.after.ownerCt),
);
};

it('records an allowance and debits the owner balance', async () => {
await approveBob(100n, 40n);

Expand Down Expand Up @@ -706,6 +753,124 @@ describe.skipIf(isLiveBackend())(
await cft._burn(55n);
});

it('re-randomizes every escrow value across spends under one spender seed', async () => {
await approveBob(100n, 40n);

const first = await spendEscrow(10n, 40n); // remaining 30

// Load-bearing: equal remainders across both spends. Differing
// plaintexts would mask a repeated pad
await cft.privateState.switchIdentity(
ALICE.secretKey,
ALICE.encryptionKey,
);
const escrow = await cft.allowance(ALICE.accountId, BOB.accountId);
const refunded = elgamal.add(
await cft.balanceOf(ALICE.accountId),
escrow.ownerCt,
);
await cft.privateState.cachePlaintext(refunded, 60n + 30n);
await cft.approve(BOB.accountId, 40n);

const second = await spendEscrow(10n, 40n); // remaining 30 again

expect(second.after.ownerMemo).not.toStrictEqual(first.after.ownerMemo);
expectRerandomized(first, second);
});

// The memo can't be compared here: consecutive spends leave different
// remainders, so the memos differ on plaintext alone.
it('re-randomizes across consecutive spends on one approval', async () => {
await approveBob(100n, 40n);

const first = await spendEscrow(10n, 40n); // 40 -> 30
const second = await spendEscrow(10n, 30n); // 30 -> 20

expectRerandomized(first, second);
});

it('re-randomizes across spends against different owners under one spender seed', async () => {
await approveBob(100n, 40n);
await fundAndApprove(CHARLIE, 100n, 40n);

const fromAlice = await spendEscrow(10n, 40n, 'burnFrom', ALICE);
const fromCharlie = await spendEscrow(10n, 40n, 'burnFrom', CHARLIE);

// Only the spender copies are comparable: both are encrypted under Bob's
// key, so a repeated `rSpender` makes the two subtracted encryptions
// coincide. The owner copies are under different keys and differ anyway.
expect(
elgamal.add(fromAlice.before.spenderCt, fromCharlie.after.spenderCt),
).not.toStrictEqual(
elgamal.add(fromCharlie.before.spenderCt, fromAlice.after.spenderCt),
);
});

it('re-randomizes across spends against different owners via transferFrom', async () => {
await approveBob(100n, 40n);
await fundAndApprove(CHARLIE, 100n, 40n);

const fromAlice = await spendEscrow(10n, 40n, 'transferFrom', ALICE);
const fromCharlie = await spendEscrow(10n, 40n, 'transferFrom', CHARLIE);

expect(
elgamal.add(fromAlice.before.spenderCt, fromCharlie.after.spenderCt),
).not.toStrictEqual(
elgamal.add(fromCharlie.before.spenderCt, fromAlice.after.spenderCt),
);
});

// Two wallets shipping the same fixed seed is a realistic wallet bug, and the
// owner they both spend from carries the leak.
it('re-randomizes across spends by different spenders on one owner', async () => {
await approveBob(100n, 40n);

await cft.privateState.switchIdentity(
ALICE.secretKey,
ALICE.encryptionKey,
);
await cft.privateState.cachePlaintext(
await cft.balanceOf(ALICE.accountId),
60n,
);
await cft.approve(CHARLIE.accountId, 40n);

const byBob = await spendEscrow(10n, 40n, 'burnFrom', ALICE, BOB);
const byCharlie = await spendEscrow(10n, 40n, 'burnFrom', ALICE, CHARLIE);

// Both owner copies are under Alice's key, so a repeated `rOwner` makes
// the subtracted encryptions coincide.
expect(
elgamal.add(byBob.before.ownerCt, byCharlie.after.ownerCt),
).not.toStrictEqual(
elgamal.add(byCharlie.before.ownerCt, byBob.after.ownerCt),
);
// Both memos mask 30 under Alice's key, so a repeated ephemeral would
// make them byte-identical.
expect(byCharlie.after.ownerMemo).not.toStrictEqual(
byBob.after.ownerMemo,
);
});

// Both entry points reach `_spendEscrow`, so they must share one counter.
it('re-randomizes across one pair spending via both entry points', async () => {
await approveBob(100n, 40n);

const viaTransfer = await spendEscrow(10n, 40n, 'transferFrom');
const viaBurn = await spendEscrow(10n, 30n, 'burnFrom');

expectRerandomized(viaTransfer, viaBurn);
});

it('re-randomizes across escrow burns under one spender seed', async () => {
await approveBob(100n, 40n);

const first = await spendEscrow(10n, 40n, 'burnFrom');
const second = await spendEscrow(10n, 30n, 'burnFrom');

expectRerandomized(first, second);
});

it('re-approve after a partial spend fails if the owner assumes the escrow is untouched', async () => {
await approveBob(100n, 40n);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
// delivered amount from a memo (any size; the ECDH memo channel removed the
// former 2^48 brute-force bound), and can recover balance/escrow amounts from
// the public ciphertexts. Reusing a seed across transactions also leaks
// plaintext differences (the debit and escrow paths have no per-operation
// nonce). Do not copy this seed behavior into a real wallet.
// plaintext differences on the debit path, which has no per-operation nonce
// (`_spendEscrow` does). Do not copy this seed behavior into a real wallet.

import { getRandomValues } from 'node:crypto';
import type {
Expand Down