// node --test : signing, work, canonicalisation and payout checks 'use strict'; const test = require('node:test'); const assert = require('node:assert'); const { execFileSync } = require('child_process'); const nc = require('nanocurrency'); const L = require('../ladder'); const RPC = process.env.NANO_RPC || 'http://127.0.0.1:7076'; const sub = { round: 0, address: null, forecasts: { btc_usd: 0.55, xno_usd: 0.4, nano_blocks: 0.5, x402_stars: 0.5, exact_stars: 0.3, hn_maxitem: 0.5, london_temp: 0.6, x402_core_npm: 0.5 }, nonce: 'test-1' }; test('random seed -> address; signature over MESSAGE verifies, tampered message rejected', async () => { const seed = await nc.generateSeed(); const sk = nc.deriveSecretKey(seed, 0), pk = nc.derivePublicKey(sk); const address = nc.deriveAddress(pk, { useNanoPrefix: true }); assert.match(address, L.ADDRESS_RE); const { hash } = L.messageOf({ ...sub, address }); const sig = L.sign(hash, sk); assert.equal(sig.length, 128); assert.equal(L.validSignature(hash, sig, address), true); const tampered = L.messageOf({ ...sub, address, forecasts: { ...sub.forecasts, btc_usd: 0.56 } }).hash; assert.notEqual(tampered, hash); assert.equal(L.validSignature(tampered, sig, address), false); assert.equal(L.validSignature(hash, sig.replace(/^./, c => (c === 'A' ? 'B' : 'A')), address), false); const other = nc.deriveAddress(nc.derivePublicKey(nc.deriveSecretKey(seed, 1)), { useNanoPrefix: true }); assert.equal(L.validSignature(hash, sig, other), false); }); test('known vector: Nano genesis block signature verifies with Ed25519-Blake2b', () => { const hash = '991CF190094C00F0B68E2E5F75F6BEE95A2E0BD93CEAA4A6734DB9F19B728948'; const sig = '9F0C933C8ADE004D808EA1985FA746A7E95BA2A38F867640F53EC8F180BDFE9E2C1268DEAD7C2664F356E37ABA362BC58E46DBA03E523A7B5A19E4B6EB12BB02'; assert.equal(L.validSignature(hash, sig, 'nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3'), true); assert.equal(L.validSignature(hash, sig, 'nano_1xug1q5t7nxoj3ywwzokiea9jz8fq8qfgzp8pbyfr3co3e5xgj755uofu8ue'), false); }); test('canonical JSON: sorted keys, no whitespace, deterministic', () => { const a = L.canonical({ b: 1, a: { z: 0.5, y: [1, 'x', null, true] }, c: 'q"\n' }); assert.equal(a, '{"a":{"y":[1,"x",null,true],"z":0.5},"b":1,"c":"q\\"\\n"}'); assert.equal(L.canonical(JSON.parse(a)), a); assert.equal(L.messageOf({ ...sub, address: 'nano_1xug1q5t7nxoj3ywwzokiea9jz8fq8qfgzp8pbyfr3co3e5xgj755uofu8ue' }).hash.length, 64); }); test('proof of work: in-process check agrees with nanocurrency and the node RPC', async () => { const hash = L.messageOf({ ...sub, address: 'nano_1xug1q5t7nxoj3ywwzokiea9jz8fq8qfgzp8pbyfr3co3e5xgj755uofu8ue' }).hash; const low = 'ffffff0000000000'; // cheap threshold so the test is fast const work = await nc.computeWork(hash, { workThreshold: low }); assert.equal(L.validWork(hash, work, BigInt('0x' + low)), true); assert.equal(nc.validateWork({ blockHash: hash, work, threshold: low }), true); assert.equal(L.validWork(hash, '0000000000000000', BigInt('0x' + low)) , L.workValue(hash, '0000000000000000') >= BigInt('0x' + low)); try { const r = await (await fetch(RPC, { method: 'POST', body: JSON.stringify({ action: 'work_validate', hash, work, difficulty: low }) })).json(); assert.equal(r.valid, '1', 'node RPC agrees the work is valid at ' + low); assert.equal(L.validWork(hash, work), r.valid_all === '1', 'send-threshold verdict matches node'); } catch (e) { if (!/fetch failed/.test(e.message)) throw e; console.log(' (node RPC not reachable, skipped cross-check)'); } }); test('python client produces the same address, canonical string and a valid signature', () => { const py = ` import sys, json, hashlib src = open('client/sign-and-submit.py').read().split('def main()')[0]; ns = {}; exec(src, ns) sk = bytes.fromhex('${'0'.repeat(63)}1') fields = json.loads(sys.argv[1]); fields['address'] = ns['address_of'](ns['public_key'](sk)) canon = ns['canonical'](fields); msg = hashlib.blake2b(canon.encode(), digest_size=32).digest() print(json.dumps({'address': fields['address'], 'canonical': canon, 'hash': msg.hex().upper(), 'sig': ns['sign'](msg, sk).hex().upper(), 'nums': [ns['es_number'](x) for x in [0.5, 1.0, 0.0, 0.05, 1e-7, 0.123456789]]}))`; const out = JSON.parse(execFileSync('python3', ['-c', py, JSON.stringify(sub)], { cwd: __dirname + '/..' }).toString()); const sk = '0'.repeat(63) + '1'; const address = nc.deriveAddress(nc.derivePublicKey(sk), { useNanoPrefix: true }); assert.equal(out.address, address); const m = L.messageOf({ ...sub, address }); assert.equal(out.canonical, m.canonical); assert.equal(out.hash, m.hash); assert.equal(L.validSignature(m.hash, out.sig, address), true); assert.deepEqual(out.nums, [0.5, 1.0, 0.0, 0.05, 1e-7, 0.123456789].map(x => JSON.stringify(x))); }); test('shape checks and payout table', () => { const round = { id: 0, questions: [{ id: 'a' }, { id: 'b' }] }; const base = { round: 0, address: 'nano_1xug1q5t7nxoj3ywwzokiea9jz8fq8qfgzp8pbyfr3co3e5xgj755uofu8ue', nonce: 'n', work: '0'.repeat(16), signature: '0'.repeat(128), forecasts: { a: 0.1, b: 1 } }; assert.equal(L.checkShape(base, round), null); assert.match(L.checkShape({ ...base, forecasts: { a: 0.1 } }, round), /missing forecast/); assert.match(L.checkShape({ ...base, forecasts: { a: 0.1, b: 1.5 } }, round), /\[0,1\]/); assert.match(L.checkShape({ ...base, forecasts: { a: 0.1, b: 1, c: 0 } }, round), /unknown question/); assert.match(L.checkShape({ ...base, forecasts: { a: '0.1', b: 1 } }, round), /number/); assert.equal(L.brier({ a: 1, b: 0.5 }, { a: 1, b: 0 }), 0.125); const t = L.payoutTable([{ address: 'x', brier: 0.2 }, { address: 'y', brier: 0.1 }, { address: 'z', brier: 0.3 }], '1000'); assert.deepEqual(t.map(r => [r.address, r.rank, r.payout_raw]), [['y', 1, '600'], ['x', 2, '400'], ['z', 3, '0']]); assert.equal(L.payoutTable([{ address: 'x', brier: 0.2 }], '25000000000000000000000000000000')[0].payout_nano, '25'); });