Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
47e0b29
feat(token): add confidential note token core
0xisk Jul 28, 2026
c986882
docs(token): complete the note core doc comments
0xisk Jul 29, 2026
e1f6494
test(concurrency): add a conflict replay harness
0xisk Jul 29, 2026
09b4b16
test(privacy): add note token privacy claims
0xisk Jul 29, 2026
113731c
refactor(token): order note core circuits caller-first
0xisk Jul 29, 2026
bbde9bb
test(token): mirror circuit order, close live gaps
0xisk Jul 29, 2026
703d4cc
test(test-utils): add a compiler-metadata reader
0xisk Jul 29, 2026
3fed295
test(token): pin the note core's wire compatibility
0xisk Jul 29, 2026
ab7d1c4
test(token): add note core property tests
0xisk Jul 29, 2026
d6d6565
test(token): add note core sequence invariants
0xisk Jul 29, 2026
c749f52
fix(test): tolerate zero-trimmed digests
0xisk Jul 29, 2026
0fdb902
test(token): generate the privacy suite's inputs
0xisk Jul 29, 2026
d13be55
test(token): enumerate the concurrency matrix
0xisk Jul 29, 2026
975725e
Merge branch 'main' into feat/confidential-note-token-core
0xisk Jul 29, 2026
c3a88e2
fix(test-utils): enforce the indexer poll deadline
0xisk Jul 29, 2026
bb3bdfd
Merge branch 'main' into feat/confidential-note-token-core
0xisk Jul 30, 2026
f5689c3
test(token): normalise digest widths in the shape test
0xisk Sep 8, 2026
6896040
fix(token): tag mint nonces apart from outputs
0xisk Sep 8, 2026
6145aad
feat(token): bind output nonces to their spend
0xisk Sep 8, 2026
4cf665d
test(token): pin spendability under a pinned seed
0xisk Sep 8, 2026
5f23a4a
feat(token): reject duplicate output nonces
0xisk Sep 8, 2026
fac487f
refactor(token): make the tree depth a module generic
0xisk Sep 8, 2026
cc42c77
docs(token): restructure the module header
0xisk Sep 8, 2026
e005c66
docs(token): tighten circuit doc blocks
0xisk Sep 8, 2026
a7a907b
docs(token): state wallet identity and recovery duties
0xisk Sep 8, 2026
c0650f2
docs(token): rename the header Vocabulary section
0xisk Sep 8, 2026
3a951b0
refactor(token): scope witness names to the module
0xisk Sep 8, 2026
eadd480
fix(test-utils): keep the indexer timeout as the cause
0xisk Sep 8, 2026
b92ca26
fix(test-utils): scope awaitPublishedTxs to one contract
0xisk Sep 8, 2026
fead3c9
feat(test-utils): validate contract-info against the unions
0xisk Sep 8, 2026
b83fa1e
docs(test-utils): drop stale artifact counts
0xisk Sep 8, 2026
98f0ff8
fix(test-utils): keep the rejection as the mismatch cause
0xisk Sep 8, 2026
c0472cd
feat(test-utils): return the rejection from expectRejection
0xisk Sep 8, 2026
7034d89
fix(test-utils): label cause-chain entries by index
0xisk Sep 8, 2026
96ae161
fix(test-utils): reject duplicate or empty party names
0xisk Sep 8, 2026
fc54aea
feat(test-utils): return the attempt from race
0xisk Sep 8, 2026
c096a7d
test(test-utils): cover race with an in-memory harness
0xisk Sep 8, 2026
1cb449f
test(test-utils): pin DryReplayHarness replay semantics
0xisk Sep 8, 2026
0009a31
feat(test-utils): let a concurrency spec pick the deployer
0xisk Sep 8, 2026
768ee43
Merge remote-tracking branch 'origin/main' into feat/confidential-not…
0xisk Sep 8, 2026
6a46cd0
build(token): raise the note module to language 0.26
0xisk Sep 8, 2026
9c0db6f
refactor(test-utils): port the dry replay harness to runtime 0.19
0xisk Sep 8, 2026
b861303
test(token): port the note suites to simulator 0.4
0xisk Sep 8, 2026
f4cdd2a
build(token): re-measure note circuits on compiler 0.34
0xisk Sep 8, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add Confidential Fungible Token (#653)
- Add EcdhMask (#655)
- Add Confidential Note Fungible Token core (#723)

### Changed

Expand Down
460 changes: 460 additions & 0 deletions contracts/src/token/ConfidentialNoteFungibleToken.compact

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/**
* Compatibility claims for the ConfidentialNoteFungibleToken core.
*
* Every other suite compares the module against itself, so all of them stay green
* when the WIRE FORMAT moves: rename a domain tag or reorder a hash preimage and
* every digest moves together, keeping relative assertions consistent.
*
* This suite pins absolute values and the shape of the published state, the two
* things an outside party depends on. A holder rebuilds a commitment and derives a
* nullifier to spend; move either and their note is unspendable. A client reads
* the ledger by slot and calls circuits by name; move a slot index or unexport a
* field and it reads the wrong thing.
*
* SO A FAILURE HERE IS NOT A TEST TO FIX, in order of likelihood:
*
* 1. Revert. Most failures are accidental.
* 2. Accept deliberately. Pre-release nothing is deployed to break, as with the
* `OZ:cnt:` to `OZ:note:` rename. Regenerate in the same commit and say so.
* 3. Post-release, it is a breaking change needing a migration.
*
* Never regenerate a value without deciding which of the three it is.
*
* PROVENANCE. Every value below is byte-identical under compiler 0.31.0 (CI) and
* 0.31.1 (current local), on language 0.23.0 and runtime 0.16.0. Digests, layout,
* and circuit surface alike.
*
* Recorded, not asserted. Those compilers differ in name and agree on every byte,
* so a pinned `compiler-version` would fail with nothing broken. The enforceable
* pin is `.github/actions/setup/action.yml`. On a toolchain bump, rebuild under
* both compilers and diff instead of assuming.
*
* Circuit complexity (k, rows) is not pinned here; it needs a non-`SKIP_ZK`
* build. See OpenZeppelin/compact-contracts#750.
*/

import { beforeEach, describe, expect, it } from 'vitest';
import {
type CircuitSurface,
circuitSurface,
type Exhaustive,
ledgerSlots,
type NameOf,
readContractInfo,
} from '#test-utils/compiler/contractInfo.js';
import type {
Circuits,
Ledger,
ProvableCircuits,
} from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js';
import { pureCircuits as core } from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js';
import { ConfidentialNoteFungibleTokenSimulator } from './simulators/ConfidentialNoteFungibleTokenSimulator.js';
import type { Note } from './witnesses/ConfidentialNoteFungibleTokenWitnesses.js';

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

/** A deterministic 32-byte secret key from a label. */
const secretKey = (label: string): Uint8Array => {
const sk = new Uint8Array(32);
sk.set(new TextEncoder().encode(label));
return sk;
};

const ALICE_SK = secretKey('ALICE');
const ALICE = core.derivePk(ALICE_SK);
const BOB = core.derivePk(secretKey('BOB'));

/** Stands in for wallet randomness, the only non-deterministic mint input. */
const FIXED_SEED = secretKey('FIXED-NONCE-SEED');

/** Lowercase `0x…` rendering, so a failed vector prints readably. */
const hex = (bytes: Uint8Array): string =>
`0x${Buffer.from(bytes).toString('hex')}`;

/** The one note every digest below is taken over. */
const NOTE: Note = { value: 100n, nonce: 7n };

// ---------------------------------------------------------------------------
// Digests
// ---------------------------------------------------------------------------

/**
* Domain-separated hashes. The tags are permanent parts of the format:
* `OZ:note:commit`, `OZ:note:null`, `OZ:note:nonce:core`, `OZ:note:out`,
* `OZ:note:chg`.
*
* `derivePk` has no tag of its own, and is pinned because every commitment is
* taken over its output.
*/
describe('ConfidentialNoteFungibleToken compatibility: digests', () => {
// Pure circuits: no deployment, so these run on either backend.

it('should derive the pinned pk from a known secret', () => {
expect(core.derivePk(ALICE_SK)).toBe(
327106606165982063573363806696144765309444401206486966729313816924943346449n,
);
});

it('should commit a known note to the pinned digest', () => {
expect(hex(core.commitOf(NOTE, ALICE))).toBe(
'0x7ef9ff74353b2baa237f53d015dde72177fa05beb954226d2d290dbffebdc772',
);
});

it('should nullify a known note to the pinned digest', () => {
expect(hex(core.nullifierOf(NOTE))).toBe(
'0xeea890f67c3c07ea1850ba30f4059f45313d9294dcd7fbffce5a393dd69ceff9',
);
});
});

// ---------------------------------------------------------------------------
// Nonce derivation
// ---------------------------------------------------------------------------

/**
* These deploy, since nonce derivation happens inside a circuit. Worth the cost on
* live too: it proves the deployed bytecode derives the same nonces as the local
* artifact, which nothing else here checks.
*/
describe('ConfidentialNoteFungibleToken compatibility: nonce derivation', () => {
let token: ConfidentialNoteFungibleTokenSimulator;

beforeEach(async () => {
token = await ConfidentialNoteFungibleTokenSimulator.create();
token.wallet.nonceSeed = FIXED_SEED;
});

it('should derive the pinned nonce for a minted note', async () => {
const minted = await token._mint(ALICE, 100n);

expect(minted.nonce).toBe(
141839877545769226285799287554416334503102257183126676284284147282857489951n,
);
});

it('should derive the pinned nonce for a change note', async () => {
const minted = await token._mint(ALICE, 100n);
token.wallet.secretKey = ALICE_SK;
token.wallet.inputNote = minted;
token.wallet.pathOverride = undefined;
token.wallet.nonceSeed = FIXED_SEED;

const [, change] = await token.transfer(BOB, 30n);

// A different slot tag from the output note, which is why one reused seed
// still yields two distinct nonces.
expect(change.nonce).toBe(
55310597546632184040479428936926702560855239758036834627964489285919135708n,
);
});
});

// ---------------------------------------------------------------------------
// Published surface
// ---------------------------------------------------------------------------

/** Emitted with or without keys, so this section runs under `SKIP_ZK`. */
const contractInfo = () =>
readContractInfo('MockConfidentialNoteFungibleToken');

describe('ConfidentialNoteFungibleToken compatibility: published surface', () => {
/**
* Every field costs something if it moves. `index` is the slot a client reads.
* `storage` decides semantics: `HistoricMerkleTree` accepts a recently-current
* root where `MerkleTree` accepts only the current one, which is what lets
* concurrent spends coexist with mints. `depth` fixes capacity and is part of
* the serialized form. `exported` decides whether clients see the slot.
*
* Asserted whole, so an ADDED or REMOVED slot fails too.
*/
it('should keep the pinned ledger layout', () => {
expect(ledgerSlots(contractInfo())).toStrictEqual([
{
name: '_commitments',
index: 0,
exported: true,
storage: 'HistoricMerkleTree',
depth: 32,
type: { 'type-name': 'Bytes', length: 32 },
},
{
name: '_nullifiers',
index: 1,
exported: true,
storage: 'Set',
type: { 'type-name': 'Bytes', length: 32 },
},
]);
});

/**
* `proof` is the load-bearing flag: a circuit touching no ledger state has an
* empty public transcript, gets no verifier key, and cannot be called on a
* deployed instance. `_spenderPk` and `_inputNote` are in that class, which is
* why the functional suite skips them on live. Flipping one changes what a
* client may do without changing any behaviour a test would notice.
*
* Keyed on `Circuits`, the generated type, so TS rejects this table if a circuit
* is added, removed, or renamed. Sorted by name because dispatch is by name.
*/
const SURFACE: Exhaustive<
NameOf<Circuits<never>>,
Pick<CircuitSurface, 'pure' | 'proof'>
> = {
_burn: { pure: false, proof: true },
_consumeNote: { pure: false, proof: true },
_inputNote: { pure: false, proof: false },
_mint: { pure: false, proof: true },
_mintNote: { pure: false, proof: true },
_spenderPk: { pure: false, proof: false },
_transfer: { pure: false, proof: true },
burn: { pure: false, proof: true },
commitOf: { pure: true, proof: false },
derivePk: { pure: true, proof: false },
nullifierOf: { pure: true, proof: false },
transfer: { pure: false, proof: true },
};

it('should keep the pinned circuit surface', () => {
const expected = Object.entries(SURFACE)
.map(([name, flags]) => ({ name, ...flags }))
.sort((left, right) => left.name.localeCompare(right.name));

expect(circuitSurface(contractInfo())).toStrictEqual(expected);
});

/**
* The JSON and the generated `.d.ts` describe the same contract independently,
* and a client trusts both, so they have to agree. `ProvableCircuits` is the
* compiler's own answer to what a deployed instance accepts.
*/
it('should agree with the generated circuit types on what is callable', () => {
const provable = circuitSurface(contractInfo())
.filter(({ proof }) => proof)
.map(({ name }) => name);

const declared: Exhaustive<NameOf<ProvableCircuits<never>>> = {
_burn: true,
_consumeNote: true,
_mint: true,
_mintNote: true,
_transfer: true,
burn: true,
transfer: true,
};

expect(provable).toStrictEqual(Object.keys(declared).sort());
});

/**
* `Ledger` is the generated reader and holds only EXPORTED slots, so the exported
* subset must be exactly its keys. Unexporting a slot removes it from every
* client's reader while leaving it in the state, a silent break.
*
* The `Core__` prefix comes from the mock importing the module prefixed.
*/
it('should export exactly the slots the generated Ledger type exposes', () => {
const exported = ledgerSlots(contractInfo())
.filter(({ exported }) => exported)
.map(({ name }) => `Core_${name}`);

const declared: Exhaustive<NameOf<Ledger>> = {
Core__commitments: true,
Core__nullifiers: true,
};

expect(exported.sort()).toStrictEqual(Object.keys(declared).sort());
});
});
Loading