Extend local inscription lab with MIME bodies and parent provenance

This commit is contained in:
Codex committed 2026-10-05 01:23:21 +00:00
1 parent 83a19b51ff
commit acd2652046
12 files changed
+383 -95

No files matched your search

+105 -4
View File
@@ -1,9 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Tx, Script } from '@cmdcode/tapscript';
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 } from '../src/adapters/inscription.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() {
@@ -40,8 +41,8 @@ test('signed commit/reveal, owned collectible, confirmation, raw tx/block inspec
});
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(71)]) assert.throws(()=>validateText(bad));
assert.doesNotThrow(()=>validateText('🌱'.repeat(70)));
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/);
@@ -88,3 +89,103 @@ test('corrupted persistence is rejected rather than silently reset', () => {
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);
});
+49 -3
View File
@@ -84,9 +84,9 @@ test('wallet backup reload and rejected oversized or invalid input',async({page}
const address=await page.locator('#address').inputValue();
const [download]=await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Save test wallet'}).click()]);
await page.getByRole('button',{name:'Mine a block'}).click();
await page.locator('#payload').fill('🌱'.repeat(71));
await page.locator('#payload').fill('🌱'.repeat(4097));
await page.getByRole('button',{name:'Review inscription'}).click();
await expect(page.getByRole('status')).toContainText('at most 280');
await expect(page.getByRole('status')).toContainText('at most 16384');
await expect(page.locator('#pending')).toHaveText('0');
await page.locator('#load-wallet').setInputFiles({name:'bad.json',mimeType:'application/json',buffer:Buffer.from('{"network":"mainnet"}')});
await expect(page.getByRole('status')).toContainText('Only Super Testnet');
@@ -121,4 +121,50 @@ test('storage failure leaves the displayed and persisted chain unchanged', async
await expect(page.locator('#height')).toHaveText('0');
await page.getByRole('button',{name:'Mine a block'}).click();
await expect(page.locator('#balance')).toHaveText('50.00000000');
});
});
test('MIME upload, reset only after success, and parent/child navigation', async ({ page }) => {
await page.goto('/');
await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#payload').fill('parent'); await page.locator('#review').click(); await page.locator('#sign').click();
await expect(page.locator('#payload')).toHaveValue('');
await expect(page.locator('#byte-count')).toHaveText('0 / 16384 bytes');
const parent = await page.locator('#parent option').nth(1).getAttribute('value');
await page.locator('#parent').selectOption(parent);
await page.locator('#body-file').setInputFiles({ name: 'art.svg', mimeType: 'image/svg+xml', buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="orange"/></svg>') });
await page.locator('#review').click();
await expect(page.locator('#review-preview iframe')).toHaveAttribute('sandbox', '');
await page.evaluate(() => { window.originalSetItem = Storage.prototype.setItem; Storage.prototype.setItem = () => { throw new Error('quota'); }; });
await page.locator('#sign').click();
await expect(page.getByRole('status')).toContainText('No changes were saved');
await expect(page.locator('#review-panel')).toBeVisible();
await expect(page.locator('#parent')).toHaveValue(parent);
expect(await page.locator('#body-file').evaluate(e => e.files.length)).toBe(1);
await page.evaluate(() => { Storage.prototype.setItem = window.originalSetItem; });
await page.locator('#sign').click();
await expect(page.locator('.collectible')).toHaveCount(2);
await expect(page.locator('#review-panel')).toBeHidden();
await expect(page.locator('#parent')).toHaveValue('');
await expect(page.locator('#byte-count')).toHaveText('0 / 16384 bytes');
expect(await page.locator('#body-file').evaluate(e => e.files.length)).toBe(0);
await page.locator('.collectible').getByRole('link', { name: /^Parent / }).click();
await expect(page.locator('#inspector pre').first()).toHaveText('parent');
await page.locator('#inspector').getByRole('link', { name: /^Child / }).click();
await expect(page.locator('#inspector')).toContainText('image/svg+xml');
await page.reload();
await expect(page.locator('#inspector iframe')).toBeVisible();
});
test('HTML preview cannot execute, navigate or fetch remote resources', async ({ page }) => {
const remote = [];
page.on('request', r => { if (/^https?:/.test(r.url()) && !r.url().startsWith('http://127.0.0.1:4178')) remote.push(r.url()); });
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#mime-type').selectOption('text/html');
await page.locator('#payload').fill('<meta http-equiv="refresh" content="0;url=https://example.com"><script>parent.unwanted=true</script><img src="https://example.com/a"><iframe src="https://example.com"></iframe><p style="background:url(https://example.com/b)" onclick="parent.unwanted=true">safe words</p><a href="https://example.com">link</a>');
await page.locator('#review').click();
await expect(page.frameLocator('#review-preview iframe').locator('p')).toHaveText('safe words');
await page.locator('#sign').click();
await expect(page.frameLocator('.collectible iframe').locator('p')).toHaveText('safe words');
expect(await page.evaluate(() => window.unwanted)).toBeUndefined();
expect(remote).toEqual([]);
await expect(page).toHaveURL('http://127.0.0.1:4178/');
});