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);
});