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')