From 6fc9bc310bf4e792c9259a85bb98db12b70e635b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Strnad?= <43024885+vostrnad@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:40:11 +0200 Subject: [PATCH] Add CSFS and IKEY support --- src/lib/TransactionInput.svelte | 8 ++ src/lib/TransactionList.svelte | 2 + src/lib/server/bitcoin/processing.ts | 18 ++- src/lib/server/bitcoin/script.ts | 3 + .../server/db/entities/transaction-input.ts | 4 + src/lib/server/db/entities/transaction.ts | 6 + src/lib/types.ts | 2 + src/routes/+page.svelte | 22 ++++ src/routes/+page.ts | 2 + src/routes/api/count/+server.ts | 4 + src/routes/api/latest/[page]/+server.ts | 6 + .../__snapshots__/chain-state.test.ts.snap | 118 ++++++++++++++++++ tests/integration/chain-state.test.ts | 20 +++ 13 files changed, 211 insertions(+), 4 deletions(-) diff --git a/src/lib/TransactionInput.svelte b/src/lib/TransactionInput.svelte index e0d523c..28e4b79 100644 --- a/src/lib/TransactionInput.svelte +++ b/src/lib/TransactionInput.svelte @@ -8,6 +8,8 @@ hasApo, hasCtv, hasCat, + hasCsfs, + hasIkey, }: SerializedTransactionInput = $props() let scriptLines = $derived(scriptAsm.split(/ (?=OP_)/)) @@ -42,6 +44,12 @@ {#if hasCat} OP_CAT {/if} + {#if hasCsfs} + OP_CHECKSIGFROMSTACK + {/if} + {#if hasIkey} + OP_INTERNALKEY + {/if}

SIGHASH_ANYPREVOUT + + diff --git a/src/lib/server/bitcoin/processing.ts b/src/lib/server/bitcoin/processing.ts index 087e03d..806a27f 100644 --- a/src/lib/server/bitcoin/processing.ts +++ b/src/lib/server/bitcoin/processing.ts @@ -98,14 +98,20 @@ export const getTransactionEntity = ( }) const hasApo = - type === 'p2tr' && - /(^| )01([\da-f]{64})? OP_CHECKSIG(VERIFY|ADD|)($| )/.test(scriptAsm) + type === InputType.p2tr && + /\b01([\da-f]{64})? OP_CHECKSIG(VERIFY|ADD|)\b/.test(scriptAsm) const hasCtv = scriptAsm.includes('OP_CHECKTEMPLATEVERIFY') - const hasCat = type === 'p2tr' && scriptAsm.includes('OP_CAT') + const hasCat = type === InputType.p2tr && scriptAsm.includes('OP_CAT') - if (hasApo || hasCtv || hasCat) { + const hasCsfs = + type === InputType.p2tr && scriptAsm.includes('OP_CHECKSIGFROMSTACK') + + const hasIkey = + type === InputType.p2tr && scriptAsm.includes('OP_INTERNALKEY') + + if (hasApo || hasCtv || hasCat || hasCsfs || hasIkey) { transactionInputs.push( wrap(new TransactionInput()).assign({ inputIndex, @@ -115,6 +121,8 @@ export const getTransactionEntity = ( hasApo, hasCtv, hasCat, + hasCsfs, + hasIkey, }), ) } @@ -129,6 +137,8 @@ export const getTransactionEntity = ( hasApo: transactionInputs.some((input) => input.hasApo), hasCtv: transactionInputs.some((input) => input.hasCtv), hasCat: transactionInputs.some((input) => input.hasCat), + hasCsfs: transactionInputs.some((input) => input.hasCsfs), + hasIkey: transactionInputs.some((input) => input.hasIkey), }) } else { return undefined diff --git a/src/lib/server/bitcoin/script.ts b/src/lib/server/bitcoin/script.ts index e1f7fba..3c969e6 100644 --- a/src/lib/server/bitcoin/script.ts +++ b/src/lib/server/bitcoin/script.ts @@ -118,6 +118,9 @@ const opcodes = new Map([ // Opcode added by BIP 342 (Tapscript) [0xba, 'OP_CHECKSIGADD'], + [0xcb, 'OP_INTERNALKEY'], + [0xcc, 'OP_CHECKSIGFROMSTACK'], + [0xff, 'OP_INVALIDOPCODE'], ]) diff --git a/src/lib/server/db/entities/transaction-input.ts b/src/lib/server/db/entities/transaction-input.ts index a103c67..27e47e0 100644 --- a/src/lib/server/db/entities/transaction-input.ts +++ b/src/lib/server/db/entities/transaction-input.ts @@ -28,6 +28,10 @@ export class TransactionInput extends CustomBaseEntity { public hasCtv: boolean @Property({ type: 'boolean' }) public hasCat: boolean + @Property({ type: 'boolean' }) + public hasCsfs: boolean + @Property({ type: 'boolean' }) + public hasIkey: boolean @Property({ type: 'string', persist: false }) public get scriptAsm(): string { diff --git a/src/lib/server/db/entities/transaction.ts b/src/lib/server/db/entities/transaction.ts index 5feaad2..d42ebcf 100644 --- a/src/lib/server/db/entities/transaction.ts +++ b/src/lib/server/db/entities/transaction.ts @@ -35,6 +35,12 @@ export class Transaction extends CustomBaseEntity { @Property({ type: 'boolean' }) @Index() public hasCat: boolean + @Property({ type: 'boolean' }) + @Index() + public hasCsfs: boolean + @Property({ type: 'boolean' }) + @Index() + public hasIkey: boolean @ManyToOne(() => Block, { deleteRule: 'cascade' }) public block: Block diff --git a/src/lib/types.ts b/src/lib/types.ts index c844ae5..8a8c987 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -21,4 +21,6 @@ export interface SoftForkFlags { hasApo: boolean hasCtv: boolean hasCat: boolean + hasCsfs: boolean + hasIkey: boolean } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 185b6ac..e435720 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -49,6 +49,28 @@ >BIP347) +

  • + {data.csfs} transactions that use OP_CHECKSIGFROMSTACK (OP_CSFS, + BIP348) +
  • +
  • + {data.ikey} transactions that use OP_INTERNALKEY (OP_IKEY, + BIP349) +
  • diff --git a/src/routes/+page.ts b/src/routes/+page.ts index 0c606a9..4cc8874 100644 --- a/src/routes/+page.ts +++ b/src/routes/+page.ts @@ -6,6 +6,8 @@ export interface Counts { apo: number ctv: number cat: number + csfs: number + ikey: number } export const load: PageLoad = async ({ fetch }) => { diff --git a/src/routes/api/count/+server.ts b/src/routes/api/count/+server.ts index 2e0eff3..da363a9 100644 --- a/src/routes/api/count/+server.ts +++ b/src/routes/api/count/+server.ts @@ -16,6 +16,8 @@ export const GET: RequestHandler = async () => { const apo = await transactionRepository.count({ hasApo: true }) const ctv = await transactionRepository.count({ hasCtv: true }) const cat = await transactionRepository.count({ hasCat: true }) + const csfs = await transactionRepository.count({ hasCsfs: true }) + const ikey = await transactionRepository.count({ hasIkey: true }) return json({ blocks, @@ -23,5 +25,7 @@ export const GET: RequestHandler = async () => { apo, ctv, cat, + csfs, + ikey, }) } diff --git a/src/routes/api/latest/[page]/+server.ts b/src/routes/api/latest/[page]/+server.ts index 54f768c..fd48247 100644 --- a/src/routes/api/latest/[page]/+server.ts +++ b/src/routes/api/latest/[page]/+server.ts @@ -28,6 +28,12 @@ export const GET: RequestHandler = async ({ params, request }) => { case 'cat': filter = { hasCat: true } break + case 'csfs': + filter = { hasCsfs: true } + break + case 'ikey': + filter = { hasIkey: true } + break default: throw error(400, 'Invalid type') } diff --git a/tests/integration/__snapshots__/chain-state.test.ts.snap b/tests/integration/__snapshots__/chain-state.test.ts.snap index f09933e..0cca497 100644 --- a/tests/integration/__snapshots__/chain-state.test.ts.snap +++ b/tests/integration/__snapshots__/chain-state.test.ts.snap @@ -53,13 +53,17 @@ exports[`ChainState > processBlocks > should correctly handle the annex 1`] = ` "blockPosition": 1, "hasApo": false, "hasCat": true, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": false, "hasCat": true, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_CAT", "type": "p2tr", @@ -82,13 +86,17 @@ exports[`ChainState > processBlocks > should process a block with CAT 1`] = ` "blockPosition": 1, "hasApo": false, "hasCat": true, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": false, "hasCat": true, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_CAT", "type": "p2tr", @@ -100,6 +108,72 @@ exports[`ChainState > processBlocks > should process a block with CAT 1`] = ` ] `; +exports[`ChainState > processBlocks > should process a block with CSFS 1`] = ` +[ + { + "block": { + "hash": "0000000000000000000000000000000000000000000000000000000000000000", + "height": 12, + "time": 7200, + }, + "blockPosition": 1, + "hasApo": false, + "hasCat": false, + "hasCsfs": true, + "hasCtv": false, + "hasIkey": false, + "inputs": [ + { + "address": "bc1pp2tr", + "hasApo": false, + "hasCat": false, + "hasCsfs": true, + "hasCtv": false, + "hasIkey": false, + "inputIndex": 0, + "scriptAsm": "OP_CHECKSIGFROMSTACK", + "type": "p2tr", + }, + ], + "txid": "0000000000000000000000000000000000000000000000000000000000000000", + "vsize": 100, + }, +] +`; + +exports[`ChainState > processBlocks > should process a block with IKEY 1`] = ` +[ + { + "block": { + "hash": "0000000000000000000000000000000000000000000000000000000000000000", + "height": 12, + "time": 7200, + }, + "blockPosition": 1, + "hasApo": false, + "hasCat": false, + "hasCsfs": false, + "hasCtv": false, + "hasIkey": true, + "inputs": [ + { + "address": "bc1pp2tr", + "hasApo": false, + "hasCat": false, + "hasCsfs": false, + "hasCtv": false, + "hasIkey": true, + "inputIndex": 0, + "scriptAsm": "OP_INTERNALKEY", + "type": "p2tr", + }, + ], + "txid": "0000000000000000000000000000000000000000000000000000000000000000", + "vsize": 100, + }, +] +`; + exports[`ChainState > processBlocks > should process an empty block 1`] = ` [ { @@ -121,13 +195,17 @@ exports[`ChainState > processBlocks > should process blocks with APO 1`] = ` "blockPosition": 1, "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_1 OP_CHECKSIG", "type": "p2tr", @@ -145,13 +223,17 @@ exports[`ChainState > processBlocks > should process blocks with APO 1`] = ` "blockPosition": 2, "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_PUSHBYTES_33 010000000000000000000000000000000000000000000000000000000000000000 OP_CHECKSIG", "type": "p2tr", @@ -169,13 +251,17 @@ exports[`ChainState > processBlocks > should process blocks with APO 1`] = ` "blockPosition": 1, "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_1 OP_CHECKSIGVERIFY", "type": "p2tr", @@ -193,13 +279,17 @@ exports[`ChainState > processBlocks > should process blocks with APO 1`] = ` "blockPosition": 2, "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_PUSHBYTES_33 010000000000000000000000000000000000000000000000000000000000000000 OP_CHECKSIGVERIFY", "type": "p2tr", @@ -217,13 +307,17 @@ exports[`ChainState > processBlocks > should process blocks with APO 1`] = ` "blockPosition": 1, "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_1 OP_CHECKSIGADD", "type": "p2tr", @@ -241,13 +335,17 @@ exports[`ChainState > processBlocks > should process blocks with APO 1`] = ` "blockPosition": 2, "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": true, "hasCat": false, + "hasCsfs": false, "hasCtv": false, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_PUSHBYTES_33 010000000000000000000000000000000000000000000000000000000000000000 OP_CHECKSIGADD", "type": "p2tr", @@ -270,13 +368,17 @@ exports[`ChainState > processBlocks > should process blocks with CTV 1`] = ` "blockPosition": 1, "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputs": [ { "address": null, "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_CHECKTEMPLATEVERIFY", "type": "bare", @@ -294,13 +396,17 @@ exports[`ChainState > processBlocks > should process blocks with CTV 1`] = ` "blockPosition": 2, "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputs": [ { "address": "3p2sh", "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_CHECKTEMPLATEVERIFY", "type": "p2sh", @@ -318,13 +424,17 @@ exports[`ChainState > processBlocks > should process blocks with CTV 1`] = ` "blockPosition": 1, "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputs": [ { "address": "3p2sh", "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_CHECKTEMPLATEVERIFY", "type": "p2sh-p2wsh", @@ -342,13 +452,17 @@ exports[`ChainState > processBlocks > should process blocks with CTV 1`] = ` "blockPosition": 2, "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputs": [ { "address": "bc1qp2wsh", "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_CHECKTEMPLATEVERIFY", "type": "p2wsh", @@ -366,13 +480,17 @@ exports[`ChainState > processBlocks > should process blocks with CTV 1`] = ` "blockPosition": 3, "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputs": [ { "address": "bc1pp2tr", "hasApo": false, "hasCat": false, + "hasCsfs": false, "hasCtv": true, + "hasIkey": false, "inputIndex": 0, "scriptAsm": "OP_CHECKTEMPLATEVERIFY", "type": "p2tr", diff --git a/tests/integration/chain-state.test.ts b/tests/integration/chain-state.test.ts index f4e5ae0..496b7ff 100644 --- a/tests/integration/chain-state.test.ts +++ b/tests/integration/chain-state.test.ts @@ -135,6 +135,26 @@ describe('ChainState', () => { expect(transactions).toMatchSnapshot() }) + it('should process a block with CSFS', async () => { + await processBlocks([ + createBlock([createTransaction([createP2TRInput('cc')])]), + ]) + + const transactions = await getAllTransactions() + expect(transactions).toHaveLength(1) + expect(transactions).toMatchSnapshot() + }) + + it('should process a block with IKEY', async () => { + await processBlocks([ + createBlock([createTransaction([createP2TRInput('cb')])]), + ]) + + const transactions = await getAllTransactions() + expect(transactions).toHaveLength(1) + expect(transactions).toMatchSnapshot() + }) + it('should correctly handle the annex', async () => { const input = createP2TRInput('7e') input.txinwitness.push('50')