Skip to main content
For the complete documentation index, see llms.txt

Private tip jar tests

이 예제에는 테스트 파일이 두 개 있습니다. src/test/private-tip-jar.sim.test.ts는 컴파일된 항아리를 메모리에서, 네트워크도 증명도 없이 실행하며 1초도 안 걸려 끝납니다. src/test/private-tip-jar.test.ts는 두 사람이 등장하는 전체 이야기를 로컬 devnet에서 실행합니다. 항아리를 소유하고 데모 토큰을 민팅하는 Alice, 그리고 팁을 내는 Bob입니다. 이 파트는 둘 다 살펴봅니다.

Prerequisites​

In memory first​

examples/private-tip-jar에서 메모리 내 테스트를 실행하세요.

yarn test:sim

이들은 Docker도 proving key도 필요 없으므로, 컨트랙트 변경을 확인하는 가장 빠른 방법입니다. 여섯 개 모두 통과합니다.

✓ src/test/private-tip-jar.sim.test.ts (6 tests) 91ms

Test Files 1 passed (1)
Tests 6 passed (6)

테스트는 컴파일된 컨트랙트를 메모리에서 실행하는 midnight-examples의 작은 헬퍼 패키지 @midnight-ntwrk/example-sim을 씁니다. Sim.deploy는 소유자 비밀, 지어낸 토큰 color, 그리고 배포자용으로 지어낸 코인 공개 키로 생성자를 실행합니다.

src/test/private-tip-jar.sim.test.ts
const deploy = () =>
Sim.deploy({
contract: new Contract(witnesses),
ledger,
privateState: createPrivateTipJarPrivateState(OWNER_SK),
args: [TIP_COLOR],
coinPublicKey: OWNER_COIN_PK,
});

메모리에서 코인은 그저 객체일 뿐이고, 시뮬레이터는 누군가 그 값을 치렀는지 확인하지 않습니다. 테스트는 리포의 @midnight-ntwrk/example-coins 패키지에 있는 simCoin으로 어떤 color와 value의 코인이든 지어내며, 이 함수는 코인마다 무작위 nonce를 부여합니다. 이는 실제 네트워크에서 팁을 내는 사람의 클라이언트가 하는 일이기도 한데, devnet 스위트에서 보게 됩니다.

src/test/private-tip-jar.sim.test.ts
// What a tipper's client passes to `tip`: a new coin with a fresh random nonce
// (on the devnet, `freshCoin(color, value)`).
const coin = (value: bigint, color = TIP_COLOR) => simCoin(value, color);

expectRejects는 호출이 기대한 assert 메시지와 함께 실패하는지 확인합니다. private state를 무작위 비밀로 바꾸면 호출자는 소유자가 아니게 됩니다.

src/test/private-tip-jar.sim.test.ts
sim.privateState = createPrivateTipJarPrivateState(bytes32());
expectRejects(() => sim.call('withdraw', key), 'Only the owner can withdraw');

sim.privateState = createPrivateTipJarPrivateState(OWNER_SK);
expectRejects(() => sim.call('withdraw', bytes32()), 'No such tip in the jar');

expect(sim.as(OWNER_COIN_PK).call('withdraw', key).ledger.pot.isEmpty()).toBe(true);

팁을 내는 사람이 팁의 nonce를 고르고 pot은 그것을 키로 삼으므로, 한 테스트는 팁이 pot 키를 재사용해 그 항목을 덮어쓰는 것을 막는 가드를 확인합니다.

src/test/private-tip-jar.sim.test.ts
const first = coin(5n);
sim.call('tip', first);
// Same nonce, different value: a different coin, but the same pot key.
expectRejects(() => sim.call('tip', { ...first, value: 1n }), 'A tip with that nonce is already in the jar');
const [[, held]] = [...sim.ledger().pot];
expect(held.value).toBe(5n);

마지막 메모리 내 테스트는 privacy 테스트입니다. SPEC.md는 항아리의 privacy 불변식을 각각 "절대 체인에 오르지 않는다"는 한 줄로 나열하고, 테스트는 그 각각에 대응하는 항목을 하나씩 가집니다. assertNotInPublicState는 각 값을 두고 public ledger 전체를 뒤져, 하나라도 찾으면 실패합니다.

src/test/private-tip-jar.sim.test.ts
const l = sim.ledger();
expect(Object.keys(l).sort()).toEqual(['owner', 'pot', 'tipColor']);
// Control: the tipped coin itself is public by design, and is found.
expect(findInPublicState(l, tips[0]!.nonce)).toMatch(/^pot\[\d\]\.key$/);
assertNotInPublicState(l, {
ownerSecretKey: OWNER_SK,
ownerCoinPublicKey: Buffer.from(OWNER_COIN_PK, 'hex'),
tipperCoinPublicKey0: tippers[0]!,
tipperCoinPublicKey1: tippers[1]!,
tipperWalletCoinNonce0: walletCoinNonces[0]!,
tipperWalletCoinNonce1: walletCoinNonces[1]!,
});

제어 줄이 중요합니다. 이 줄은 검색이 실제로 공개된 것, 즉 팁으로 낸 코인 자신의 nonce를 찾아내는지 확인하므로, privacy 테스트가 통과한다는 것은 비밀이 정말로 없다는 뜻이지 검색이 엉뚱한 곳을 본 것이 아니라는 뜻입니다. tipperWalletCoinNonce 항목은 팁을 내는 사람 지갑에 있는 코인을 대신합니다. 시뮬레이터에는 지갑이 없으므로, 여기서는 tip이 pot에 그 외의 무엇도 넣지 않는다는 것만 보여 줍니다. devnet 스위트는 같은 불변식을 Bob의 실제 코인에 대해 확인합니다.

메모리 내 테스트가 보여 줄 수 없는 것은 실제 코인이 실제 지갑 사이를 오가는 모습입니다. 그것은 devnet이 있어야 합니다.

Run the devnet suite​

로컬 네트워크를 시작하고, 테스트 지갑이 수수료를 치를 DUST를 가질 때까지 기다린 다음, 스위트를 실행하세요.

yarn env:up
yarn wait:dust
yarn test:local

yarn env:up은 compose.yml에서 Midnight node, indexer, proof server를 시작합니다. proof server는 트랜잭션마다 영지식 증명을 생성합니다. 이 페이지를 따라가는 동안 네트워크는 켜 두세요. 성공하는 호출마다 실제 증명을 생성하므로, 스위트가 몇 분 걸릴 것으로 예상하세요.

끝나면, 거부를 기대하는 테스트까지 포함해 10개 모두 통과합니다.

✓ src/test/private-tip-jar.test.ts (10 tests) 160668ms
✓ deploys the demo token and mints Bob one coin to tip with 41757ms
✓ Alice deploys the jar for that token; only a hash of her key is stored 18922ms
✓ Bob tips twice from his one coin; the pot holds exactly the coins he made 49260ms
✓ the owner withdraws every tip into her own shielded wallet 49034ms

Test Files 1 passed (1)
Tests 10 passed (10)

Vitest는 300ms보다 오래 걸리는 테스트만 나열하므로, 여섯 개의 테스트는 각자의 줄 없이 통과합니다. ledger를 읽기만 하는 privacy 테스트와 다섯 개의 거부 테스트입니다. 거부된 호출은 몇 밀리초밖에 걸리지 않습니다. assert가 실패하면 호출자 자신의 기기에서 circuit이 멈추므로, 증명이 생성되지 않고 아무것도 제출되지 않습니다. 성공한 트랜잭션은 각각 20~25초 걸리는데, 대부분은 증명 시간입니다. 첫 테스트는 컨트랙트를 배포하고 민팅하며, 팁 테스트와 인출 테스트는 각각 트랜잭션을 두 개씩 제출하므로 더 오래 걸립니다.

끝나면 네트워크를 내리세요.

yarn env:down

The test harness​

테스트 파일에서 Everything above is generated boilerplate로 시작하는 주석 위쪽은 모두 공유 하네스로, midnight-examples의 모든 예제에서 동일합니다. src/config.ts는 각 네트워크의 엔드포인트를 담고, src/wallet.ts는 지갑을 만들고 동기화하며, src/providers.ts는 MidnightJS provider를 묶습니다. local에서는 Alice와 Bob이 devnet의 사전 자금 지갑이므로 따로 설정할 것이 없습니다.

두 컨트랙트가 바꾸는 것은 한 가지, 바로 provider입니다. provider 세트에는 ZK 설정, 즉 한 컨트랙트의 proving key가 들어가므로, 스위트는 사용하는 지갑과 컨트랙트 쌍마다 provider 세트를 하나씩 만듭니다.

src/test/private-tip-jar.test.ts
aliceJarProv = buildProviders(aliceWallet, zkConfigPath, config);
bobJarProv = buildProviders(bobWallet, zkConfigPath, config);
aliceTokenProv = buildProviders(aliceWallet, tipTokenZkConfigPath, config);

Alice는 두 컨트랙트 모두와, Bob은 항아리와만 상대합니다. 각 provider 세트는 자신만의 private state 저장소도 갖게 되므로, Alice의 항아리 비밀과 Bob의 무작위 키는 결코 섞이지 않습니다.

The story​

devnet 테스트는 순서대로 실행되며, 각 테스트는 바로 앞 테스트 위에 쌓입니다.

1. Deploy the token and mint Bob a coin​

Alice가 데모 토큰을 배포한 다음, 토큰의 도메인 구분자와 새 주소로부터 그 color를 계산합니다.

src/test/private-tip-jar.test.ts
tokenAddress = deployed.deployTxData.public.contractAddress;
tipColorHex = rawTokenType(TOKEN_DOMAIN, tokenAddress);
tipColorBytes = encodeRawTokenType(tipColorHex);

TOKEN_DOMAIN은 tip-token.compact가 민팅에 쓰는 것과 똑같은 pad(32, "private-tip-jar:demo-token:")를 TypeScript로 만든 것입니다.

그런 다음 Alice는 Bob에게 100짜리 코인 하나를 민팅합니다.

src/test/private-tip-jar.test.ts
const bobCoinPk = bobWallet.getCoinPublicKey();
await (submitCallTx<TipTokenContract, 'mint'>)(aliceTokenProv, {
compiledContract: CompiledTipTokenContract,
contractAddress: tokenAddress,
circuitId: 'mint',
args: [{ bytes: encodeCoinPublicKey(bobCoinPk) }, MINTED, new Uint8Array(randomBytes(32))],
// Alice builds an output for Bob's wallet, so she needs his encryption key.
additionalCoinEncPublicKeyMappings: new Map([[bobCoinPk, bobWallet.getEncryptionPublicKey()]]),
});

Alice의 트랜잭션은 Bob의 지갑을 위한 shielded 코인을 만들고, 그 코인은 Bob의 지갑이 찾을 수 있도록 Bob에게 암호화됩니다. MidnightJS는 Alice 자신의 암호화 키는 알지만 Bob의 것은 모릅니다. additionalCoinEncPublicKeyMappings가 Bob의 코인 공개 키를 그의 암호화 키에 매핑합니다. 이것이 없으면 트랜잭션을 만드는 동안 Unable to resolve encryption public key for recipient와 함께 호출이 실패합니다.

Alice가 코인의 nonce를 골랐으므로 그녀는 그것을 알고, 또한 항아리를 소유합니다. 테스트는 그 nonce를 기록해 두어, privacy 테스트가 나중에 그것이 pot에 결코 도달하지 않음을 확인할 수 있게 합니다.

2. Deploy the jar​

Alice는 그 color에 대해, 자신의 private state에 새 무작위 소유자 비밀을 담아 항아리를 배포합니다.

src/test/private-tip-jar.test.ts
const deployed: DeployedContract<Contract> = await (deployContract<Contract>)(aliceJarProv, {
compiledContract: CompiledPrivateTipJarContract,
privateStateId: ALICE_JAR_STATE_ID,
initialPrivateState: createPrivateTipJarPrivateState(aliceOwnerSk),
args: [tipColorBytes],
});

그런 다음 테스트는 ledger의 owner가, 컨트랙트의 pure circuit으로 오프체인에서 계산한 그녀 비밀의 ownerKey와 정확히 같은지 확인합니다.

src/test/private-tip-jar.test.ts
expect(hex(state.owner)).toEqual(hex(pureCircuits.ownerKey(aliceOwnerSk)));

3. Bob tips twice​

Bob은 40을, 이어서 60을, 둘 다 Alice가 민팅해 준 단 하나의 100짜리 코인에서 팁으로 냅니다. 각 팁은 새 코인으로, 테스트가 @midnight-ntwrk/example-coins의 freshCoin으로 만들어 냅니다. 항아리의 color, 팁의 value, 그리고 무작위 nonce로 이루어집니다. 그것은 Bob의 지갑에서 온 코인이 아닙니다. 그의 지갑은 tip 트랜잭션의 균형을 맞추는 동안 그 값을 치르는데, 민팅된 코인을 소비하고 거스름돈을 새 nonce 아래로 돌려받습니다. 그래서 Bob은 정확한 value의 코인을 먼저 만들 필요가 없고, 민팅된 코인의 nonce를 아는 Alice는 두 팁 중 어느 것의 nonce도 본 적이 없습니다.

src/test/private-tip-jar.test.ts
const first = freshCoin(tipColorHex, FIRST_TIP);
tipNonces.push(first.nonce);
await bobTips(first);

각 팁은 평범한 호출입니다.

src/test/private-tip-jar.test.ts
async function bobTips(coin: ShieldedCoinArg): Promise<void> {
await (submitCallTx<Contract, 'tip'>)(bobJarProv, {
compiledContract: CompiledPrivateTipJarContract,
contractAddress: jarAddress,
privateStateId: BOB_JAR_STATE_ID,
circuitId: 'tip',
args: [coin],
});
}

submitCallTx는 트랜잭션이 받아들여지면, Bob의 지갑이 거스름돈을 보기 조금 전에 반환됩니다. 두 번째 팁 전에 테스트는 그의 잔액이 60이 될 때까지 기다린 다음, 지금 그의 지갑에 있는 코인의 nonce를 기록하고 민팅된 코인이 사라졌는지 확인합니다.

src/test/private-tip-jar.test.ts
await waitForTokenBalance(bobWallet, MINTED - FIRST_TIP);
const change = await tokenCoins(bobWallet);
for (const c of change) bobWalletNonces.push(c.nonce);
// The issued coin is spent; what is left is change under a new nonce.
const issued = new Set(bobIssuedNonces.map(hex));
expect(change.some((c) => issued.has(hex(c.nonce)))).toBe(false);

tokenCoins는 지갑에서 항아리 color의, 쓸 수 있는 shielded 코인을 읽습니다. 테스트는 nonce를 읽는 데만 이것을 씁니다. 이 코인 중 어느 것도 tip에 넘기지 않습니다.

src/test/private-tip-jar.test.ts
async function tokenCoins(w: MidnightWalletProvider): Promise<ShieldedCoinArg[]> {
const state = await w.wallet.waitForSyncedState();
return state.shielded.availableCoins
.filter((c) => c.coin.type === tipColorHex)
.map((c) => ({
nonce: Uint8Array.from(Buffer.from(c.coin.nonce, 'hex')),
color: tipColorBytes,
value: c.coin.value,
}));
}

두 팁이 끝나면 pot은 코인 두 개를 보관합니다. 그 값은 공개이고, 각 pot 항목은 Bob이 그 코인을 위해 뽑은 nonce로 키가 매겨진 팁 코인 자체입니다.

src/test/private-tip-jar.test.ts
// Tip VALUES are public by design: the contract must hold whole coins to
// spend them later. What they don't carry is who paid them.
const values = [...state.pot].map(([, coin]) => coin.value).sort();
expect(values).toEqual([FIRST_TIP, SECOND_TIP].sort());
// Each pot entry is the tip coin itself, under the nonce Bob drew for it.
expect([...state.pot].map(([key]) => hex(key)).sort()).toEqual(tipNonces.map(hex).sort());
for (const [key, coin] of state.pot) {
expect(hex(coin.color)).toEqual(tipColorHex);
expect(hex(key)).toEqual(hex(coin.nonce));
}

4. The privacy test​

이는 메모리 내 privacy 테스트의 devnet 버전으로, 실제 팁이 오간 뒤 실제 ledger에 대해 실행됩니다. collectPublicBytes는 항아리의 public state에 있는 모든 바이트 문자열을, 그것이 나온 필드로 라벨을 달아 모읍니다.

src/test/private-tip-jar.test.ts
function collectPublicBytes(l: Ledger): Labeled[] {
const out: Labeled[] = [
{ label: 'owner', bytes: l.owner },
{ label: 'tipColor', bytes: l.tipColor },
];
for (const [key, coin] of l.pot) {
out.push({ label: 'pot.key', bytes: key });
out.push({ label: 'pot.coin.nonce', bytes: coin.nonce });
out.push({ label: 'pot.coin.color', bytes: coin.color });
}
return out;
}

어떤 값이 그 목록에 없다면, 컨트랙트는 그것을 체인에 올린 적이 없습니다. 그런 다음 테스트는 설계가 체인 밖에 두기로 약속한 모든 것을 나열하는데, Alice가 민팅한 코인의 nonce와 Bob의 지갑에 있는 코인의 nonce도 포함됩니다.

src/test/private-tip-jar.test.ts
const secrets: Labeled[] = [
{ label: "Bob's wallet coin public key", bytes: encodeCoinPublicKey(bobWallet.getCoinPublicKey()) },
{ label: "Bob's jar secret", bytes: bobSk },
{ label: "Alice's owner secret", bytes: aliceOwnerSk },
{ label: "Alice's wallet coin public key", bytes: encodeCoinPublicKey(aliceWallet.getCoinPublicKey()) },
...bobIssuedNonces.map((n) => ({ label: 'nonce of the coin Alice minted to Bob', bytes: n })),
...bobWalletNonces.map((n) => ({ label: "nonce of a coin in Bob's wallet", bytes: n })),
];

그리고 그중 어느 것도 어떤 공개 필드에도 나타나지 않음을 확인합니다. findLeak는 혹시 비밀이 더 넓은 값 안에 끼워 넣어진 경우를 대비해 부분 문자열도 대조합니다.

src/test/private-tip-jar.test.ts
for (const s of secrets) {
const leak = findLeak(pool, s.bytes);
expect(leak, `${s.label} found in public field ${leak}`).toBeNull();
}

컨트랙트에 ledger 필드를 추가했다면, 그것을 collectPublicBytes에도 추가하게 됩니다. 그 목록에서 빠진 필드는 privacy 테스트가 볼 수 없는 필드입니다.

5. The rejections​

다섯 개의 테스트가 항아리의 각 assert를 확인합니다.

  • 잘못된 토큰. Bob이 무작위 color의 지어낸 코인으로 팁을 내면, 호출은 Tips must be paid in the jar's token과 함께 거부됩니다.
  • 값 0. color는 맞지만 value가 0인 코인은 A tip must be worth something과 함께 거부됩니다.
  • 재사용된 pot 키. Bob이 이미 pot 키인 nonce를 가진 코인을 다른 값으로 팁을 냅니다. 이는 A tip with that nonce is already in the jar와 함께 거부되고, pot은 여전히 코인 두 개를 보관합니다.
  • 소유자가 아님. Bob이 자신의 private state에 자신의 무작위 비밀을 담아 withdraw를 호출합니다. 이는 Only the owner can withdraw와 함께 거부되고, pot과 Bob의 잔액은 그대로입니다.
  • 그런 팁 없음. 인출이 끝난 뒤, Alice가 이미 인출한 키와 무작위 키를 시도합니다. 둘 다 No such tip in the jar와 함께 거부됩니다.

팁 거부가 지어낸 코인을 넘길 수 있는 이유는 세 가드가 모두 receiveShielded 전에 실행되기 때문입니다. 호출은 Bob의 지갑이 무엇에든 값을 치르기 전, 트랜잭션을 만드는 동안 실패합니다.

6. The owner withdraws​

Alice가 pot을 읽고 각 코인을 차례로 인출합니다.

src/test/private-tip-jar.test.ts
for (const [key] of [...(await queryLedger()).pot]) {
await aliceWithdraws(key);
withdrawnKeys.push(key);
}

expect((await queryLedger()).pot.isEmpty()).toBe(true);
await syncWallet(logger, aliceWallet.wallet, syncTimeoutMs);
expect(await tokenBalance(aliceWallet)).toEqual(MINTED);

pot은 비어 있고, 이제 100 토큰 전부가 Alice의 shielded 지갑에 들어 있습니다. 이것이 처음부터 끝까지의 전체 shielded 팁입니다. Bob에게 민팅되고, 그의 지갑이 값을 치러 항아리가 받아 저장한 새 코인이 되고, 소유자에게 지불됩니다. 그 어느 지점에서도 항아리의 public state는 Bob을 가리키는 어떤 것도 담지 않았습니다.

Next​

이제 Midnight의 두 종류 토큰을 모두 봤습니다. Private Party의 unshielded NIGHT, 그리고 여기의 shielded 토큰입니다. 학습 경로의 다음 단계는 Shielded Chips입니다. 발행자와 소각 경로를 갖춘 네이티브 shielded 토큰, 코인을 보관하고 지불하는 컨트랙트, 그리고 여러 변형 사이의 privacy 절충을 다룹니다.