Add delegate recursion and reinscription UI

This commit is contained in:
recovery committed 2026-10-05 03:57:01 +00:00
1 parent 9f27897967
commit 5d1a1ae866
10 files changed
+327 -31

No files matched your search

+130 -5
View File
@@ -74,13 +74,16 @@ test('tampering with reveal payload, control block or value is rejected', () =>
assert.deepEqual(candidate.snapshot(),before);
}
});
test('collectibles are protected and subsequent inscriptions use change', () => {
test('ordinary signed spends preserve inscriptions 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/);
const sat = record.inscription.sat;
chain.submitPackage([Tx.encode(spend).hex]);
assert.equal(ins.list().find(i=>i.id===first).sat,sat);
assert.equal(chain.satLocation(sat).txid,Tx.util.getTxid(spend));
chain.mine(wallet.script); assert.equal(ins.list().length,2);
});
test('corrupted persistence is rejected rather than silently reset', () => {
@@ -222,13 +225,14 @@ test('metadata repeated tag 5 chunks reassemble from witness; body, parent and m
const metadata = { protocol: 'example', text: '🌱'.repeat(300), arbitrary: [1,true,null] };
const id = ins.inscribe({ contentType: 'image/png', bytes: new Uint8Array(MAX_BYTES).fill(128) }, parent, metadata);
const record = chain.transactions.get(id.slice(0,-2));
const parts = Script.decode(record.tx.vin[0].witness[1]);
const chunks=[]; let i=9;
const witness = record.tx.vin.find(i => i.witness.length === 3).witness;
const parts = Script.decode(witness[1]);
const chunks=[]; let i=11;
while (parts[i] === '05') { chunks.push(parts[i+1]); i+=2; }
assert.ok(chunks.length > 1);
assert.ok(chunks.every(chunk => chunk.length / 2 <= 520));
assert.deepEqual(decodeMetadata(Buffer.from(chunks.join(''),'hex')),metadata);
assert.deepEqual(readEnvelope(record.tx.vin[0].witness).metadata, metadata);
assert.deepEqual(readEnvelope(witness).metadata, metadata);
assert.equal(ins.list().find(x=>x.id===id).parent,parent);
assert.deepEqual(decodeLab(encodeLab({wallet,chain})).chain.transactions.get(record.txid).inscription.metadata,metadata);
const max = 'x'.repeat(MAX_METADATA_BYTES - 3);
@@ -328,3 +332,124 @@ test('two auto miners re-read deadline under shared lock; manual mining uses ide
await assert.rejects(mutateLab(lab=>mineLab(lab,false),{...io,save:()=>{throw new Error('quota');}}),/quota/);
assert.equal(decodeLab(stored).chain.blocks.length,3);
});
import { encodePointer, decodePointer } from '../src/adapters/inscription.js';
import { resolveContent } from '../src/adapters/local-ord-api.js';
import { recursivePath } from '../src/recursion.js';
test('delegate tag 11 uses parent serialization and accepts future ids before signing', () => {
const {ins,chain,wallet} = setup();
const delegate = '0123456789abcdef'.repeat(4) + 'i256';
const id = ins.inscribe('fallback', null, {protocol:'delegate'}, {delegate});
const witness = chain.transactions.get(id.slice(0,-2)).tx.vin[0].witness;
assert.ok(witness[1].includes('010b22' + serializeParent(delegate)));
assert.equal(readEnvelope(witness).delegate, delegate);
assert.equal(new LocalOrdAPI(chain).request(`/content/${id}`).status,404);
wallet.sign = () => { throw new Error('must not sign'); };
assert.throws(() => ins.inscribe('bad',null,undefined,{delegate:'invalid'}), /Invalid parent/);
});
test('delegates resolve A to B to C content/type while identity and metadata stay A', () => {
const {ins,chain} = setup();
const c = ins.inscribe({contentType:'text/html',bytes:new TextEncoder().encode('<p>terminal</p>')});
const b = ins.inscribe('b',null,undefined,{delegate:c});
const a = ins.inscribe('a',null,{identity:'a'},{delegate:b});
const api = new LocalOrdAPI(chain), response = api.request(`/content/${a}`);
assert.equal(response.inscriptionId,a); assert.equal(response.contentId,c);
assert.equal(response.contentType,'text/html'); assert.equal(new TextDecoder().decode(response.body),'<p>terminal</p>');
assert.deepEqual(api.request(`/r/inscription/${a}`).body.metadata,{identity:'a'});
assert.deepEqual(api.request(`/r/inscription/${b}`).body.delegatedBy,[a]);
});
test('forward delegate becomes available and loops have deterministic 508 responses', () => {
// Resolution graph fixture: a future transaction id cannot be precomputed from
// a mutually hash-dependent cycle of actual Bitcoin transactions.
const index = { events: [{id:'a',delegate:'b'}] };
const read = () => ({bytes:Uint8Array.of(42),contentType:'image/png'});
assert.equal(resolveContent(index,'a',read).status,404);
index.events.push({id:'b',delegate:null});
assert.deepEqual(resolveContent(index,'a',read).body,Uint8Array.of(42));
index.events[1].delegate='a';
assert.deepEqual(resolveContent(index,'a',read),{status:508,contentType:'application/json',body:{error:'Delegate loop detected.'}});
index.events[0].delegate='a'; assert.equal(resolveContent(index,'a',read).status,508);
});
test('FIFO sats, parent preserving spends, same-sat reinscription stack and raw rebuild', () => {
const {ins,chain,wallet} = setup();
const a = ins.inscribe('original',null,{protocol:'stack'});
const sat = ins.list().find(i=>i.id===a).sat;
for (let n=0;n<3;n++) ins.inscribe('child '+n,a);
assert.equal(ins.list().find(i=>i.id===a).sat,sat);
assert.equal(chain.satLocation(sat).offset,0);
const b = ins.inscribe('second',null,{protocol:'stack'},{reinscribe:a});
const c = ins.inscribe('third',null,undefined,{reinscribe:b});
ins.inscribe('stack child',a);
const api = new LocalOrdAPI(chain), stack = api.request(`/local/sat/${sat}`).body;
assert.deepEqual(stack.inscriptions,[a,b,c]);
for (const [n,id] of [a,b,c].entries()) {
const event=api.request(`/r/inscription/${id}`).body;
assert.equal(event.sat,sat); assert.equal(event.reinscriptionPosition,n);
assert.deepEqual(event.location,stack.location);
}
assert.equal(new TextDecoder().decode(api.request(`/content/${a}`).body),'original');
assert.deepEqual(api.request(`/local/utxo/${stack.location.txid}:${stack.location.vout}`).body.inscriptions,[a,b,c]);
chain.mine(wallet.script);
const restored = new LocalChain(chain.snapshot());
assert.deepEqual(new MetadataIndex(restored).events,new MetadataIndex(chain).events);
assert.equal(new InscriptionAdapter(restored,wallet).list().find(i=>i.id===b).sat,sat);
});
for (const [pointer, encoded] of [[0,''],[255,'ff'],[256,'0001'],[99999,'9f8601']]) {
test(`pointer ${pointer}: minimal LE encoding and output position/fallback`, () => {
assert.equal(encodePointer(pointer),encoded); assert.equal(decodePointer(encoded),String(pointer));
const {ins,chain} = setup(); const id=ins.inscribe('point',null,undefined,{pointer});
const record=chain.transactions.get(id.slice(0,-2));
assert.equal(readEnvelope(record.tx.vin[0].witness).pointer,String(pointer));
const event=new MetadataIndex(chain).events[0];
assert.equal(event.location.offset,pointer < POSTAGE ? pointer : 0);
assert.equal(event.sat,pointer < POSTAGE ? pointer : 0);
});
}
test('recursive path whitelist rejects all URL, traversal and mutation forms', () => {
const id='a'.repeat(64)+'i0';
for(const path of [`/content/${id}`,`/r/inscription/${id}`,`/r/metadata/${id}`,`/r/parents/${id}`,`/r/children/${id}`,`/r/tx/${'a'.repeat(64)}`,'/r/blockheight','/r/blockhash/2','/r/blockinfo/2',`/r/blockinfo/${'a'.repeat(64)}`]) assert.equal(recursivePath(path),true,path);
for(const path of ['https://example.com','//example.com','/\\example.com','/r/../local/protocols','/r/blockheight?x=1','/r/blockheight#x','/local/metadata-index','/content/%2f','/r/blockheight/','/r/blockheight\n']) assert.equal(recursivePath(path),false,path);
});
test('a real future delegate resolves after its independently funded reveal arrives', () => {
const {chain,ins} = setup(), futureWallet=TestWallet.create();
chain.mine(futureWallet.script);
const future = new LocalChain(chain.snapshot());
const target = new InscriptionAdapter(future,futureWallet).inscribe('arrived later');
const id = ins.inscribe('waiting',null,undefined,{delegate:target});
const api = new LocalOrdAPI(chain);
assert.equal(api.request(`/content/${id}`).status,404);
chain.submitPackage(future.mempool);
assert.equal(new TextDecoder().decode(api.request(`/content/${id}`).body),'arrived later');
});
test('multiple input ranges flow FIFO across outputs and protect inscribed fee tails atomically', () => {
const {chain,wallet,ins}=setup();
const first=ins.inscribe('offset',null,undefined,{pointer:700});
const coin=chain.coins(wallet.script).find(c=>c.inscription?.id===first);
const funding=chain.coins(wallet.script).find(c=>!c.inscription);
const spend=Tx.create({vin:[getVin(coin.txid,coin.vout,coin.value,coin.scriptPubKey),getVin(funding.txid,funding.vout,funding.value,funding.scriptPubKey)],vout:[getVout(500,wallet.script),getVout(funding.value,wallet.script)]});
spend.vin.forEach((input,i)=>{input.witness=[wallet.sign(spend,undefined,i)];});
chain.submitPackage([Tx.encode(spend).hex]);
const sat=chain.transactions.get(first.slice(0,-2)).inscription.sat;
assert.deepEqual(chain.satLocation(sat),{txid:Tx.util.getTxid(spend),vout:1,offset:200});
const moved=chain.coins(wallet.script).find(c=>c.inscription?.id===first);
assert.deepEqual(moved.ranges[0],[500,1000]);
assert.equal(moved.ranges[1][0],1500);
chain.mine(wallet.script);
const before=chain.snapshot();
// Put a late inscribed sat in the fee tail on a separate small output.
const late=ins.inscribe('late',null,undefined,{pointer:999});
const tail=chain.coins(wallet.script).find(c=>c.inscription?.id===late);
const burn=Tx.create({vin:[getVin(tail.txid,tail.vout,tail.value,tail.scriptPubKey)],vout:[getVout(500,wallet.script)]});
burn.vin[0].witness=[wallet.sign(burn)];
const after=chain.snapshot();
assert.throws(()=>chain.submitPackage([Tx.encode(burn).hex]),/fee loss/);
assert.deepEqual(chain.snapshot(),after); assert.ok(after.mempool.length>before.mempool.length);
});
+60 -2
View File
@@ -163,7 +163,7 @@ test('MIME upload, reset only after success, and parent/child navigation', async
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()); });
page.on('request', r => { if (/^https?:/.test(r.url()) && !r.url().startsWith('http://127.0.0.1:4178')) remote.push(r); });
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>');
@@ -172,7 +172,7 @@ test('HTML preview cannot execute, navigate or fetch remote resources', async ({
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([]);
for (const request of remote) await expect.poll(() => request.failure()?.errorText).toMatch(/csp|ERR_BLOCKED_BY_CSP/i);
await expect(page).toHaveURL('http://127.0.0.1:4178/');
});
@@ -256,3 +256,61 @@ test('two tabs race one auto deadline under Web Locks', async ({page,context}) =
await page.locator('#miner-interval').selectOption('0');
await expect(other.locator('#miner-interval')).toHaveValue('0');
});
test('recursive local fetch, delegate identity, external blocking and worker DOM isolation', 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);});
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#payload').fill('local recursive body'); await page.locator('#review').click(); await page.locator('#sign').click();
const content=await page.locator('#parent option').nth(1).getAttribute('value');
await page.locator('#mime-type').selectOption('text/html');
await page.locator('#metadata').fill('{"protocol":"recursive","name":"source"}');
await page.locator('#payload').fill(`<p>Isolated recursion</p><script>
const result={id:ord.id,content:await (await fetch('/content/${content}')).text(),height:await (await fetch('/r/blockheight')).json(),metadata:await (await fetch('/r/metadata/'+ord.id)).json()};
try{await fetch('https://example.com/leak');result.external='escaped'}catch{result.external='blocked'}
try{parent.document.body.innerHTML='escaped';result.dom='escaped'}catch{result.dom='blocked'}
try{await new Promise((ok,bad)=>{const x=new XMLHttpRequest();x.open('GET','https://example.com/xhr');x.onload=ok;x.onerror=bad;x.send()});result.xhr='escaped'}catch{result.xhr='blocked'}
try{importScripts('https://example.com/script.js');result.import='escaped'}catch{result.import='blocked'}
ord.render(JSON.stringify(result));
</script>`);
await page.locator('#review').click(); await page.locator('#sign').click();
const source=await page.locator('#parent option').nth(2).getAttribute('value');
const output=page.frameLocator('.collectible iframe').locator('#recursive-output');
await expect(output).toContainText('local recursive body');
await expect(output).toContainText('"height":1');
await expect(output).toContainText('"external":"blocked"');
await expect(output).toContainText('"dom":"blocked"');
await expect(output).toContainText('"xhr":"blocked"');
await expect(output).toContainText('"import":"blocked"');
await expect(output).toContainText(source);
await page.locator('#mime-type').selectOption('text/plain'); await page.locator('#payload').fill('delegating');
await page.locator('#delegate').fill(source); await page.locator('#metadata').fill('{"name":"requested"}');
await page.locator('#review').click(); await page.locator('#sign').click();
const delegated=await page.locator('#parent option').nth(3).getAttribute('value');
const delegatedOutput=page.frameLocator('.collectible:first-child iframe').locator('#recursive-output');
await expect(delegatedOutput).toContainText(delegated);
await expect(delegatedOutput).toContainText('a1646e616d6569726571756573746564');
await expect(page.locator('.collectible:first-child')).toContainText('Delegate');
await expect(page.locator('.collectible').nth(1)).toContainText('Delegated by');
// Chromium emits request events for CSP-denied XHR attempts; none reached the network.
for (const request of remote) expect(request.failure()?.errorText).toMatch(/csp|ERR_BLOCKED_BY_CSP/i);
await expect(page.locator('h1')).toContainText('Bitcoin universe');
});
test('recursive template and reinscription stack persist across mining and reload', async ({page}) => {
await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click();
await page.locator('#recursive-demo').click(); await page.locator('#review').click(); await page.locator('#sign').click();
const id=await page.locator('#parent option').nth(1).getAttribute('value');
await expect(page.frameLocator('.collectible iframe').locator('#recursive-output')).toContainText(id);
await page.locator('#reinscribe').selectOption(id); await page.locator('#mime-type').selectOption('text/plain');
await page.locator('#payload').fill('second on same sat'); await page.locator('#review').click();
await expect(page.locator('#review-parent')).toContainText(`Reinscribe: ${id}`);
await page.locator('#sign').click();
await expect(page.locator('.collectible')).toHaveCount(2);
await expect(page.locator('.collectible').first()).toContainText('stack position 1');
await expect(page.locator('.collectible').last()).toContainText('stack position 0');
await expect(page.locator('.collectible').first().getByRole('link',{name:/Sat history/})).toHaveCount(2);
await page.locator('#mine').click(); await page.reload();
await expect(page.locator('.collectible')).toHaveCount(2);
await expect(page.frameLocator('.collectible iframe').locator('#recursive-output')).toContainText('"height": 2');
});