deviant/scripts/chipnet-checkers-game-test.mjs
walerikus 518a3ce174 chore: initial project baseline — full Deviant codebase under version control
Brings the whole project into git (previously only the on-ramp feature and
a few parallel files were tracked). Includes: wallet core (src/), CashScript
contracts/, the Capacitor shell (shells/), scripts/, tests, and the docs
corpus (wiki/, snapshots/, Parameters/, Archive/, KnowledgeBase text).

Excluded via .gitignore: node_modules/, dist/, .env (secrets), testnet WIF
key files (.chipnet-*.json, devWallets.ts), KnowledgeBase/RAW media (~9.6 GB),
transcripts/ (session logs), and machine-local config (.claude/*.local,
*.lock). Secret-scanned before commit — no credentials in the tree.

Note: src/swapMath.ts, src/swapBuilder.ts, contracts/OpenMintToken.cash are
legacy (OP_RETURN era) but still imported; committed as-is, flagged for a
separate removal once de-referenced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:38:07 +02:00

219 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* chipnet-checkers-game-test.mjs — BCH AS GAME INFRASTRUCTURE. One move = one
* transaction. Validates contracts/CheckersGame.cash on live chipnet.
*
* The covenant holds a MUTABLE game-state NFT (value == pot). Each move spends
* and recreates it: the chain enforces TURN ORDER + PER-MOVE PLAYER SIGNATURE +
* an immutable hash-chained move log. It does NOT referee checkers rules — the
* game engine (D:\Dev\Checkers) does. Settlement = cooperative 2-of-2, or the
* arbiter (the server's oracle key, as in backend/src/deviant/arbiter.ts).
*
* GAME 1: genesis → NEG (wrong player can't move out of turn) → 4 real moves
* (A,B,A,B) → cooperative settle (A+B agree B wins, pot → B).
* GAME 2: genesis → arbiter settle (server declares A wins, pot → A).
*
* Roles: playerA=A, playerB=B, arbiter+fee+pot funder=C. Fresh each run.
* Requires .chipnet-keys{,-b,-c}.json funded.
*
* Run: node --import tsx scripts/chipnet-checkers-game-test.mjs
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { sha256, hexToBin, binToHex } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/CheckersGame.json'), 'utf8'));
const KEYS = { a: '.chipnet-keys.json', b: '.chipnet-keys-b.json', c: '.chipnet-keys-c.json' };
const POT1 = 60000n, POT2 = 20000n, FEE = 1500n;
const utf8 = (s) => new TextEncoder().encode(s);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const line = (c = '=') => console.log(c.repeat(78));
const step = (t) => { console.log(''); line(); console.log(t); line(); };
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
function p2pkh(pkh) { const o = new Uint8Array(25); o[0] = 0x76; o[1] = 0xa9; o[2] = 0x14; o.set(pkh, 3); o[23] = 0x88; o[24] = 0xac; return o; }
// Compute the next 35-byte commitment EXACTLY as CheckersGame.move() does:
// turn(1) ++ moveCount(2, LE) ++ sha256(prevStateHash(32) ++ moveData)
function nextCommitment(prev, moveData) {
const turn = prev[0];
const count = prev[1] | (prev[2] << 8);
const stateHash = prev.slice(3, 35);
const newCount = count + 1;
const newHash = sha256.hash(new Uint8Array([...stateHash, ...moveData]));
const out = new Uint8Array(35);
out[0] = turn === 0x00 ? 0x01 : 0x00;
out[1] = newCount & 0xff;
out[2] = (newCount >> 8) & 0xff;
out.set(newHash, 3);
return out;
}
async function main() {
line();
console.log('CHIPNET CHECKERS-GAME TEST — BCH as game infrastructure, 1 move = 1 tx');
line();
const A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, KEYS.a), 'utf8')).wif);
const B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, KEYS.b), 'utf8')).wif);
const C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, KEYS.c), 'utf8')).wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sig = { a: new SignatureTemplate(A.privateKey), b: new SignatureTemplate(B.privateKey), c: new SignatureTemplate(C.privateKey) };
const refundDeadline = BigInt(await provider.getBlockHeight()) + 1000n;
const contract = new Contract(ARTIFACT, [A.publicKeyCompressed, B.publicKeyCompressed, C.publicKeyCompressed, refundDeadline], { provider });
console.log(`playerA (A): ${A.cashaddr}`);
console.log(`playerB (B): ${B.cashaddr}`);
console.log(`arbiter (C): ${C.cashaddr}`);
console.log(`contract: ${contract.tokenAddress}`);
async function eligible(w, need) {
let u = await w.getUtxos(); let e = u.filter((x) => x.vout === 0 && !x.token && BigInt(x.satoshis) >= need);
if (e.length) return e[0];
await w.send([{ cashaddr: w.cashaddr, value: Number(need), unit: 'sat' }]); // make a vout-0 output >= need
await sleep(12000);
u = await w.getUtxos(); e = u.filter((x) => x.vout === 0 && !x.token && BigInt(x.satoshis) >= need);
if (!e.length) throw new Error('could not produce a sufficient vout-0 genesis UTXO');
return e[0];
}
async function feeInputs(w, need) {
const bch = (await provider.getUtxos(w.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis));
const p = []; let t = 0n;
for (const x of bch) { p.push(x); t += BigInt(x.satoshis); if (t >= need) break; }
if (t < need) throw new Error('insufficient BCH for fee');
return { p, t };
}
async function findNft(cat, commitHex, tries = 8) {
for (let i = 0; i < tries; i++) {
const u = (await provider.getUtxos(contract.tokenAddress)).find(
(x) => x.token?.category === cat && x.token?.nft?.capability === 'mutable' && x.token?.nft?.commitment === commitHex,
);
if (u) return u;
await sleep(4000);
}
throw new Error(`NFT @ commitment ${commitHex.slice(0, 16)}… not found`);
}
// ---- genesis a game (mutable NFT, value=pot, commitment = turnA|count0|sha256(gameId)) ----
async function genesis(pot, label) {
const gameId = randomBytes(16);
const init = new Uint8Array([0x00, 0x00, 0x00, ...sha256.hash(gameId)]); // 35 bytes
const elig = await eligible(C, pot + 3000n);
const cat = elig.txid;
const r = await C.tokenGenesis(
{ cashaddr: contract.tokenAddress, amount: 0n, value: pot, nft: { capability: 'mutable', commitment: binToHex(init) } },
[], { utxoIds: [elig] },
);
console.log(` ${label}: genesis ${txidOf(r)} — category ${cat.slice(0, 16)}…, pot ${pot}`);
await sleep(10000);
return { cat, commit: binToHex(init), commitBin: init };
}
// ---- one move: mover (whose turn it is) spends+recreates the NFT ----
async function move(game, mover, moverSig, moveStr) {
const md = utf8(moveStr);
const next = nextCommitment(game.commitBin, md);
const nft = await findNft(game.cat, game.commit);
const { p, t } = await feeInputs(mover, FEE + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(nft, contract.unlock.move(md, moverSig));
for (const x of p) tb.addInput(x, moverSig.unlockP2PKH());
tb.addOutput({ to: contract.tokenAddress, amount: BigInt(nft.satoshis), token: { amount: 0n, category: game.cat, nft: { capability: 'mutable', commitment: binToHex(next) } } });
tb.addOutput({ to: mover.cashaddr, amount: t - FEE });
const id = txidOf(await tb.send());
game.commit = binToHex(next); game.commitBin = next;
const turnNow = next[0] === 0x00 ? 'A' : 'B';
const count = next[1] | (next[2] << 8);
console.log(` move "${moveStr}" by ${mover === A ? 'A' : 'B'} → tx ${id.slice(0, 12)}… | now move #${count}, ${turnNow} to play`);
await sleep(8000);
return id;
}
// ================= GAME 1 =================
step('GAME 1 — genesis, turn-enforcement, four real moves, cooperative settle');
const g1 = await genesis(POT1, 'GAME1');
// NEG: it is A's turn (turn byte 0x00). B must NOT be able to move.
console.log(' NEG — B tries to move on A\'s turn (must be REJECTED):');
let negRejected = false;
try {
const md = utf8('h6-g5');
const next = nextCommitment(g1.commitBin, md);
const nft = await findNft(g1.cat, g1.commit);
const { p, t } = await feeInputs(B, FEE + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(nft, contract.unlock.move(md, sig.b)); // WRONG player signs
for (const x of p) tb.addInput(x, sig.b.unlockP2PKH());
tb.addOutput({ to: contract.tokenAddress, amount: BigInt(nft.satoshis), token: { amount: 0n, category: g1.cat, nft: { capability: 'mutable', commitment: binToHex(next) } } });
tb.addOutput({ to: B.cashaddr, amount: t - FEE });
await tb.send();
} catch (e) { negRejected = true; console.log(` rejected (expected): ${(e?.message ?? e).toString().slice(0, 80)}`); }
if (!negRejected) throw new Error('SECURITY: out-of-turn move was ACCEPTED — turn gate broken');
// Four real Russian-checkers opening moves, alternating A,B,A,B.
await move(g1, A, sig.a, 'c3-d4');
await move(g1, B, sig.b, 'f6-e5');
await move(g1, A, sig.a, 'd4-c5');
await move(g1, B, sig.b, 'b6-a5');
// Cooperative settle: A and B agree B wins → pot to B (NFT burned).
step('GAME 1 — cooperative settle (A + B co-sign: B wins) → pot to B');
const nft1 = await findNft(g1.cat, g1.commit);
const bBefore = BigInt((await provider.getUtxos(B.cashaddr)).reduce((s, u) => s + (u.token ? 0 : Number(u.satoshis)), 0));
{
const { p, t } = await feeInputs(C, FEE + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(nft1, contract.unlock.settleCooperative(sig.a, sig.b, p2pkh(B.publicKeyHash)));
for (const x of p) tb.addInput(x, sig.c.unlockP2PKH());
tb.addOutput({ to: B.cashaddr, amount: BigInt(nft1.satoshis) }); // full pot → B
tb.addOutput({ to: C.cashaddr, amount: t - FEE });
const id = txidOf(await tb.send());
console.log(` settled cooperatively → ${id} (pot ${nft1.satoshis} sats to B)`);
await sleep(10000);
}
// ================= GAME 2 =================
step('GAME 2 — genesis, arbiter settle (server oracle declares A wins) → pot to A');
const g2 = await genesis(POT2, 'GAME2');
const nft2 = await findNft(g2.cat, g2.commit);
{
const { p, t } = await feeInputs(C, FEE + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(nft2, contract.unlock.settleArbiter(sig.c, p2pkh(A.publicKeyHash)));
for (const x of p) tb.addInput(x, sig.c.unlockP2PKH());
tb.addOutput({ to: A.cashaddr, amount: BigInt(nft2.satoshis) }); // pot → A
tb.addOutput({ to: C.cashaddr, amount: t - FEE });
const id = txidOf(await tb.send());
console.log(` settled by arbiter → ${id} (pot ${nft2.satoshis} sats to A)`);
await sleep(10000);
}
// ================= VERIFY =================
step('VERIFY');
const bAfter = BigInt((await provider.getUtxos(B.cashaddr)).reduce((s, u) => s + (u.token ? 0 : Number(u.satoshis)), 0));
const g1Gone = !(await provider.getUtxos(contract.tokenAddress)).some((x) => x.token?.category === g1.cat);
const g2Gone = !(await provider.getUtxos(contract.tokenAddress)).some((x) => x.token?.category === g2.cat);
const checks = [
['NEG: out-of-turn move rejected (turn gate holds)', negRejected],
['GAME1: B received the pot (cooperative settle)', bAfter - bBefore >= POT1 - FEE],
['GAME1: game NFT consumed at settlement', g1Gone],
['GAME2: arbiter settlement consumed the NFT', g2Gone],
];
let ok = true;
for (const [l, p] of checks) { console.log(` [${p ? 'PASS' : 'FAIL'}] ${l}`); ok = ok && p; }
step('SUMMARY — BCH as checkers infrastructure (one move = one transaction)');
console.log(' Four moves, four transactions; the chain enforced turn order and');
console.log(' per-move player signatures, and chained an immutable move log in the');
console.log(' NFT commitment. An out-of-turn move was rejected on-chain. The pot');
console.log(' settled cooperatively (2-of-2) and by arbiter (server oracle) — the');
console.log(' same model wired into backend/src/deviant/arbiter.ts. Rules stay in');
console.log(' the engine; the trust infrastructure is on Bitcoin Cash.');
line();
if (!ok) { console.error('VERIFY FAILED'); process.exit(1); }
process.exit(0);
}
main().catch((e) => { console.error('FAILED:', e?.message ?? e); if (e?.stack) console.error(e.stack); process.exit(1); });