import test from 'node:test'; import assert from 'node:assert/strict'; import { Tx, Script, Tap } from '@cmdcode/tapscript'; import { TestWallet } from '../src/adapters/wallet.js'; import { LocalChain, LocalRPC } from '../src/adapters/chain.js'; import { InscriptionAdapter, validateText, MAX_BYTES, MIME_TYPES, envelope, serializeParent, POSTAGE, FEE } from '../src/adapters/inscription.js'; import { getVin, getVout } from '../src/upstream/primitives.js'; import { encodeLab, decodeLab } from '../src/storage.js'; function setup() { const wallet = TestWallet.create(), chain = new LocalChain(); chain.mine(wallet.script); return { wallet, chain, ins: new InscriptionAdapter(chain, wallet) }; } test('signed commit/reveal, owned collectible, confirmation, raw tx/block inspection and restoration', () => { const { wallet, chain, ins } = setup(); assert.match(wallet.address, /^bcrt1p/); assert.equal(chain.balance(wallet.script), 5e9); const id = ins.inscribe('Hello from v0 🌱'); assert.equal(chain.mempool.length, 2); assert.equal(chain.balance(wallet.script), 5e9 - 1000); assert.equal(ins.list()[0].height, null); assert.equal(ins.list()[0].id, id); assert.deepEqual(new InscriptionAdapter(chain, TestWallet.create()).list(), []); const pendingRestored = decodeLab(encodeLab({wallet,chain})); assert.equal(new InscriptionAdapter(pendingRestored.chain,pendingRestored.wallet).list()[0].id, id); const block = chain.mine(wallet.script); assert.equal(chain.mempool.length, 0); assert.equal(ins.list()[0].height, 2); assert.equal(chain.balance(wallet.script), 1e10 - 1000); const rpc = new LocalRPC(chain); const revealId = id.slice(0,-2); assert.equal(Tx.util.getTxid(rpc.call('getrawtransaction',[revealId])), revealId); assert.ok(rpc.call('getblock',[block.hash]).tx.includes(revealId)); assert.equal(rpc.call('getblockhash',[2]),block.hash); assert.equal(rpc.call('getblockcount'),2); assert.throws(()=>rpc.call('sendrawtransaction'),/Unsupported/); const restored = decodeLab(encodeLab({wallet,chain})); assert.equal(restored.wallet.address,wallet.address); assert.deepEqual(new InscriptionAdapter(restored.chain,restored.wallet).list(),ins.list()); }); test('empty wallet, UTF-8 limit, and untagged/mainnet wallet imports rejected', () => { assert.throws(()=>new InscriptionAdapter(new LocalChain(),TestWallet.create()).inscribe('hi'), /Mine a block/); for (const bad of ['', ' ', '🌱'.repeat(MAX_BYTES / 4 + 1)]) assert.throws(()=>validateText(bad)); assert.doesNotThrow(()=>validateText('🌱'.repeat(MAX_BYTES / 4))); assert.throws(()=>TestWallet.load({secret:'1'.repeat(64)}), /Only Super Testnet/); const backup = TestWallet.create().backup(); backup.network='mainnet'; assert.throws(()=>TestWallet.load(backup), /Only Super Testnet/); }); test('invalid reveal signature rolls back the entire package, and double-spends fail', () => { const {wallet,chain,ins} = setup(); const before = chain.snapshot(); ins.inscribe('atomic'); const [commit,reveal] = chain.mempool; const bad = Tx.decode(reveal); bad.vin[0].witness[0]='00'.repeat(64); const clean = new LocalChain(before); assert.throws(()=>clean.submitPackage([commit,Tx.encode(bad).hex]),/signature|Signature/); assert.deepEqual(clean.snapshot(),before); assert.equal(clean.balance(wallet.script),5e9); clean.submitPackage([commit,reveal]); assert.throws(()=>clean.submitPackage([commit]),/Duplicate/); const spend = Tx.decode(commit); spend.vout[1].value = Number(spend.vout[1].value)-1; assert.throws(()=>clean.submitPackage([Tx.encode(spend).hex]),/spent/); }); test('tampering with reveal payload, control block or value is rejected', () => { const {chain,ins} = setup(); const before=chain.snapshot(); ins.inscribe('original'); const [commit,reveal]=chain.mempool; for (const mutate of [ tx=>{ const script=Script.decode(tx.vin[0].witness[1]); script[8]='6861636b6564'; tx.vin[0].witness[1]=Script.encode(script,false).hex; }, tx=>{ tx.vin[0].witness[2]='c0'+'00'.repeat(32); }, tx=>{ tx.vout[0].value=5000; }, ]) { const tx=Tx.decode(reveal); mutate(tx); const candidate=new LocalChain(before); assert.throws(()=>candidate.submitPackage([commit,Tx.encode(tx).hex])); assert.deepEqual(candidate.snapshot(),before); } }); test('collectibles are protected and subsequent inscriptions use change', () => { const {wallet,chain,ins}=setup(); const first=ins.inscribe('first'); ins.inscribe('second'); assert.equal(ins.list().length,2); const record=chain.transactions.get(first.slice(0,-2)); const spend=Tx.create({vin:[{txid:record.txid,vout:0,sequence:0xffffffff,prevout:record.tx.vout[0]}],vout:[{value:500,scriptPubKey:wallet.script}]}); spend.vin[0].witness=[wallet.sign(spend)]; assert.throws(()=>chain.submitPackage([Tx.encode(spend).hex]),/protects inscription/); chain.mine(wallet.script); assert.equal(ins.list().length,2); }); test('corrupted persistence is rejected rather than silently reset', () => { assert.throws(()=>decodeLab('{bad')); const {chain}=setup(); const snapshot=chain.snapshot(); snapshot.blocks[0].hash='0'.repeat(64); assert.throws(()=>new LocalChain(snapshot),/block hash/); assert.throws(()=>new LocalChain({...snapshot,network:'mainnet'}),/network/); }); test('all MIME bodies round-trip arbitrary bytes, chunk boundaries, cap, and snapshots', () => { const { wallet, chain, ins } = setup(); for (const contentType of MIME_TYPES) { const bytes = Uint8Array.from({ length: MAX_BYTES }, (_, i) => i % 256); const id = ins.inscribe({ contentType, bytes }); const item = ins.list().find(i => i.id === id); assert.deepEqual(item.bytes, Array.from(bytes)); assert.equal(item.contentType, contentType); } const before = chain.snapshot(); assert.throws(() => ins.inscribe({ contentType: 'image/png', bytes: new Uint8Array(MAX_BYTES + 1) }), /at most/); assert.deepEqual(chain.snapshot(), before); const restored = decodeLab(encodeLab({ wallet, chain })); assert.deepEqual(new InscriptionAdapter(restored.chain, restored.wallet).list(), ins.list()); }); test('tag 3 encoding reverses txid and uses minimal little-endian index', () => { const txid = '0123456789abcdef'.repeat(4); const reversed = txid.match(/../g).reverse().join(''); for (const [index, suffix] of [[0,''],[1,'01'],[256,'0001'],[65536,'000001'],[4294967295,'ffffffff']]) assert.equal(serializeParent(`${txid}i${index}`), reversed + suffix); assert.throws(() => serializeParent(`${txid}i4294967296`)); }); test('parent stays owned across children, nested lineage, mining and replay', () => { const { wallet, chain, ins } = setup(); const parent = ins.inscribe('parent'), child = ins.inscribe('child', parent); const grandchild = ins.inscribe('grandchild', child); ins.inscribe('sibling', parent); // Spend its relocated output, not its original reveal. assert.equal(ins.list().length, 4); assert.equal(ins.list().find(i => i.id === parent).parent, null); assert.equal(ins.list().find(i => i.id === child).parent, parent); assert.equal(ins.list().find(i => i.id === grandchild).parent, child); const reveal = chain.transactions.get(child.slice(0,-2)).tx; assert.equal(reveal.vin.length, 2); assert.equal(Number(reveal.vout[1].value), POSTAGE); assert.throws(() => new InscriptionAdapter(chain, TestWallet.create()).prepare('fraud', parent), /owned/); chain.mine(wallet.script); const restored = decodeLab(encodeLab({ wallet, chain })); assert.deepEqual(new InscriptionAdapter(restored.chain, restored.wallet).list(), ins.list()); assert.equal(ins.list().find(i => i.id === parent).height, 2); }); // Build genuinely signed malformed packages so rejection tests exercise provenance, not signature failure. function customReveal(chain, wallet, tag, spentParent, redirect = false) { const coin = chain.coins(wallet.script).find(c => !c.inscription); const script = envelope(wallet.pubkey, 'claim', tag), leaf = Tap.encodeScript(script); const [tapkey, control] = Tap.getPubKey(wallet.pubkey, { target: leaf }); const commit = Tx.create({ vin: [getVin(coin.txid, coin.vout, coin.value, coin.scriptPubKey)], vout: [getVout(POSTAGE + FEE, ['OP_1', tapkey]), getVout(coin.value - POSTAGE - 2 * FEE, wallet.script)] }); commit.vin[0].witness = [wallet.sign(commit)]; const reveal = Tx.create({ vin: [getVin(Tx.util.getTxid(commit), 0, POSTAGE + FEE, ['OP_1', tapkey]), ...(spentParent ? [getVin(spentParent.txid, spentParent.vout, spentParent.value, spentParent.scriptPubKey)] : [])], vout: [getVout(POSTAGE, wallet.script), ...(spentParent ? [getVout(spentParent.value, redirect ? TestWallet.create().script : wallet.script)] : [])] }); reveal.vin[0].witness = [wallet.sign(reveal, leaf), Script.encode(script, false).hex, control]; if (spentParent) reveal.vin[1].witness = [wallet.sign(reveal, undefined, 1)]; return [Tx.encode(commit).hex, Tx.encode(reveal).hex]; } test('false parent claims, untagged parent spends, wrong parent and redirected returns are rejected atomically', () => { const { wallet, chain, ins } = setup(); const parent = ins.inscribe('parent'); const other = ins.inscribe('other'); const coin = chain.coins(wallet.script).find(c => c.inscription?.id === parent); for (const [tag, spent, redirect, reason] of [ [parent, null, false, /actually be spent/], [null, coin, false, /correct tag/], [other, coin, false, /actually be spent/], [parent, coin, true, /preserved/], ]) { const before = chain.snapshot(); assert.throws(() => chain.submitPackage(customReveal(chain, wallet, tag, spent, redirect)), reason); assert.deepEqual(chain.snapshot(), before); assert.equal(ins.list().find(i => i.id === parent).parent, null); } }); test('single bytes and one-byte final chunks preserve exact binary values', () => { const { ins } = setup(); for (const byte of [0, 1, 3, 16, 127, 128, 129, 255]) { for (const size of [1, 521]) { const bytes = new Uint8Array(size).fill(byte); const id = ins.inscribe({ contentType: 'image/png', bytes }); assert.deepEqual(ins.list().find(i => i.id === id).bytes, Array.from(bytes)); } } }); test('legacy text envelopes still decode without changing their bytes', async () => { const { readEnvelope, MIME, textHex } = await import('../src/adapters/inscription.js'); const wallet = TestWallet.create(), text = 'original v0 🌱'; const script = Script.encode([wallet.pubkey, 'OP_CHECKSIG', 'OP_0', 'OP_IF', textHex('ord'), '01', textHex(MIME), 'OP_0', textHex(text), 'OP_ENDIF'], false).hex; const parsed = readEnvelope(['00'.repeat(64), script, 'unused']); assert.equal(parsed.text, text); assert.equal(parsed.parent, null); assert.deepEqual(parsed.bytes, new TextEncoder().encode(text)); }); test('child reveal requires a valid parent-input signature', () => { const { chain, wallet, ins } = setup(); const parent = ins.inscribe('parent'), before = chain.snapshot(); const coin = chain.coins(wallet.script).find(c => c.inscription?.id === parent); const [commit, raw] = customReveal(chain, wallet, parent, coin); const reveal = Tx.decode(raw); reveal.vin[1].witness[0] = '00'.repeat(64); assert.throws(() => chain.submitPackage([commit, Tx.encode(reveal).hex]), /signature/); assert.deepEqual(chain.snapshot(), before); });