Skip to content

Commit 1e1c57b

Browse files
committed
fix: support DelegateVtxo and server timelocks in Ark signature verification
The initial Ark offchainAddr verification only tried DefaultVtxo.Script with the default 144-block timelock. Real wallets using arkade.computer use a 605184-second server timelock and delegation via delegate.arkade.money, producing DelegateVtxo addresses with 3 taproot leaves instead of 2. - Fetch ASP server's unilateralExitDelay and try both default and server timelocks - Fetch delegate pubkeys and try DelegateVtxo.Script in addition to DefaultVtxo - Cache server info to avoid repeated network calls - Update Jest mock with BIP68 timelock encoding and DelegateVtxo support - Add tests for server-timelock and DelegateVtxo address verification
1 parent 3ffd830 commit 1e1c57b

3 files changed

Lines changed: 259 additions & 35 deletions

File tree

src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts

Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,17 @@ export class ReadonlyWallet {}
4444

4545
const { p2tr, taprootListToTree, TAPROOT_UNSPENDABLE_KEY } = require('@scure/btc-signer');
4646
const { bech32m } = require('bech32');
47+
const bip68 = require('bip68');
4748

4849
const TAP_LEAF_VERSION = 0xc0;
4950

51+
interface RelativeTimelock {
52+
value: bigint;
53+
type: 'seconds' | 'blocks';
54+
}
55+
56+
const DEFAULT_TIMELOCK: RelativeTimelock = { value: 144n, type: 'blocks' };
57+
5058
class _ArkAddress {
5159
readonly serverPubKey: Uint8Array;
5260
readonly vtxoTaprootKey: Uint8Array;
@@ -89,31 +97,93 @@ function buildForfeitScript(pubKey: Uint8Array, serverPubKey: Uint8Array): Uint8
8997
return buf;
9098
}
9199

92-
function buildExitScript(pubKey: Uint8Array): Uint8Array {
93-
// <144 blocks> OP_CHECKSEQUENCEVERIFY OP_DROP <pubkey> OP_CHECKSIG
94-
const buf = new Uint8Array(1 + 2 + 1 + 1 + 1 + 32 + 1);
95-
buf[0] = 0x02;
96-
buf[1] = 0x90;
97-
buf[2] = 0x00; // 144 LE
98-
buf[3] = 0xb2; // OP_CHECKSEQUENCEVERIFY
99-
buf[4] = 0x75; // OP_DROP
100-
buf[5] = 0x20;
101-
buf.set(pubKey, 6);
102-
buf[38] = 0xac;
103-
return buf;
100+
function minimalScriptNum(n: number): Uint8Array {
101+
if (n === 0) return new Uint8Array(0);
102+
const negative = n < 0;
103+
let abs = Math.abs(n);
104+
const bytes: number[] = [];
105+
while (abs > 0) {
106+
bytes.push(abs & 0xff);
107+
abs >>= 8;
108+
}
109+
if (bytes[bytes.length - 1] & 0x80) {
110+
bytes.push(negative ? 0x80 : 0x00);
111+
} else if (negative) {
112+
bytes[bytes.length - 1] |= 0x80;
113+
}
114+
return new Uint8Array(bytes);
115+
}
116+
117+
function buildExitScript(pubKey: Uint8Array, csvTimelock: RelativeTimelock = DEFAULT_TIMELOCK): Uint8Array {
118+
// <sequence> OP_CHECKSEQUENCEVERIFY OP_DROP <pubkey> OP_CHECKSIG
119+
const sequence = bip68.encode(
120+
csvTimelock.type === 'blocks'
121+
? { blocks: Number(csvTimelock.value) }
122+
: { seconds: Number(csvTimelock.value) },
123+
);
124+
const seqBytes = minimalScriptNum(sequence);
125+
const pushOp = seqBytes.length === 1 ? [] : [seqBytes.length]; // OP_N for 1 byte, else explicit push
126+
const seqPush = seqBytes.length === 1 ? [seqBytes[0]] : [...pushOp, ...seqBytes];
127+
128+
const buf = new Uint8Array(seqPush.length + 1 + 1 + 1 + 32 + 1);
129+
let offset = 0;
130+
for (const b of seqPush) buf[offset++] = b;
131+
buf[offset++] = 0xb2; // OP_CHECKSEQUENCEVERIFY
132+
buf[offset++] = 0x75; // OP_DROP
133+
buf[offset++] = 0x20; // push 32 bytes
134+
buf.set(pubKey, offset);
135+
offset += 32;
136+
buf[offset++] = 0xac; // OP_CHECKSIG
137+
return buf.slice(0, offset);
138+
}
139+
140+
function buildMultisigScript(pubkeys: Uint8Array[]): Uint8Array {
141+
// <pk1> CHECKSIGVERIFY ... <pkN> CHECKSIG
142+
const parts: number[] = [];
143+
for (let i = 0; i < pubkeys.length; i++) {
144+
parts.push(0x20); // push 32 bytes
145+
parts.push(...pubkeys[i]);
146+
parts.push(i < pubkeys.length - 1 ? 0xad : 0xac); // CHECKSIGVERIFY / CHECKSIG
147+
}
148+
return new Uint8Array(parts);
149+
}
150+
151+
function buildTaprootTree(scripts: Uint8Array[]): Uint8Array {
152+
// Reverse odd-length script arrays (VtxoScript base class behavior)
153+
const list = scripts.length % 2 !== 0 ? scripts.slice().reverse() : scripts;
154+
const tapTree = taprootListToTree(list.map((s: Uint8Array) => ({ script: s, leafVersion: TAP_LEAF_VERSION })));
155+
const payment = p2tr(TAPROOT_UNSPENDABLE_KEY, tapTree, undefined, true);
156+
return payment.tweakedPubkey;
104157
}
105158

106159
class _DefaultVtxoScript {
107160
readonly tweakedPublicKey: Uint8Array;
161+
readonly scripts: Uint8Array[];
162+
163+
constructor(options: { pubKey: Uint8Array; serverPubKey: Uint8Array; csvTimelock?: RelativeTimelock }) {
164+
const { pubKey, serverPubKey, csvTimelock } = options;
165+
this.scripts = [buildForfeitScript(pubKey, serverPubKey), buildExitScript(pubKey, csvTimelock)];
166+
this.tweakedPublicKey = buildTaprootTree(this.scripts);
167+
}
168+
}
169+
170+
class _DelegateVtxoScript {
171+
readonly tweakedPublicKey: Uint8Array;
108172

109-
constructor(options: { pubKey: Uint8Array; serverPubKey: Uint8Array }) {
110-
const { pubKey, serverPubKey } = options;
111-
const scripts = [buildForfeitScript(pubKey, serverPubKey), buildExitScript(pubKey)];
112-
const tapTree = taprootListToTree(scripts.map((s: Uint8Array) => ({ script: s, leafVersion: TAP_LEAF_VERSION })));
113-
const payment = p2tr(TAPROOT_UNSPENDABLE_KEY, tapTree, undefined, true);
114-
this.tweakedPublicKey = payment.tweakedPubkey;
173+
constructor(options: {
174+
pubKey: Uint8Array;
175+
serverPubKey: Uint8Array;
176+
delegatePubKey: Uint8Array;
177+
csvTimelock?: RelativeTimelock;
178+
}) {
179+
const { pubKey, serverPubKey, delegatePubKey, csvTimelock } = options;
180+
const defaultVtxo = new _DefaultVtxoScript({ pubKey, serverPubKey, csvTimelock });
181+
const delegateScript = buildMultisigScript([pubKey, delegatePubKey, serverPubKey]);
182+
const allScripts = [...defaultVtxo.scripts, delegateScript];
183+
this.tweakedPublicKey = buildTaprootTree(allScripts);
115184
}
116185
}
117186

118187
export const ArkAddress = _ArkAddress;
119188
export const DefaultVtxo = { Script: _DefaultVtxoScript };
189+
export const DelegateVtxo = { Script: _DelegateVtxoScript };

src/integration/blockchain/ark/__tests__/ark.service.spec.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArkAddress, DefaultVtxo } from '@arkade-os/sdk';
1+
import { ArkAddress, DefaultVtxo, DelegateVtxo } from '@arkade-os/sdk';
22
import { secp256k1 } from '@noble/curves/secp256k1';
33
import { sha256 } from '@noble/hashes/sha2';
44
import { ArkService } from '../ark.service';
@@ -86,4 +86,94 @@ describe('ArkService', () => {
8686
expect(result).toBe(false);
8787
});
8888
});
89+
90+
// --- OFFCHAIN ADDRESS WITH SERVER TIMELOCK --- //
91+
92+
describe('verifySignature with server timelock', () => {
93+
const serverTimelock = { value: 605184n, type: 'seconds' as const };
94+
95+
const serverVtxoScript = new DefaultVtxo.Script({
96+
pubKey: xOnlyKey,
97+
serverPubKey: serverKey,
98+
csvTimelock: serverTimelock,
99+
});
100+
const serverOffchainAddr = new ArkAddress(serverKey, serverVtxoScript.tweakedPublicKey, 'ark').encode();
101+
102+
beforeEach(() => {
103+
// Mock fetch to return the server's unilateralExitDelay
104+
jest.spyOn(global, 'fetch').mockResolvedValue({
105+
json: async () => ({ unilateralExitDelay: '605184' }),
106+
} as Response);
107+
});
108+
109+
afterEach(() => {
110+
jest.restoreAllMocks();
111+
});
112+
113+
it('should verify a signature against an offchainAddr with server timelock', async () => {
114+
const message = 'test message for server timelock verification';
115+
const signature = signMessage(message);
116+
117+
const result = await service.verifySignature(message, serverOffchainAddr, signature);
118+
119+
expect(result).toBe(true);
120+
});
121+
122+
it('should reject a wrong signature against server-timelock offchainAddr', async () => {
123+
const signature = signMessage('original message');
124+
125+
const result = await service.verifySignature('different message', serverOffchainAddr, signature);
126+
127+
expect(result).toBe(false);
128+
});
129+
});
130+
131+
// --- DELEGATE VTXO ADDRESS --- //
132+
133+
describe('verifySignature with DelegateVtxo address', () => {
134+
const serverTimelock = { value: 605184n, type: 'seconds' as const };
135+
const delegateKey = Buffer.alloc(32, 0xdd);
136+
137+
const delegateVtxoScript = new DelegateVtxo.Script({
138+
pubKey: xOnlyKey,
139+
serverPubKey: serverKey,
140+
delegatePubKey: delegateKey,
141+
csvTimelock: serverTimelock,
142+
});
143+
const delegateAddr = new ArkAddress(serverKey, delegateVtxoScript.tweakedPublicKey, 'ark').encode();
144+
145+
beforeEach(() => {
146+
jest.spyOn(global, 'fetch').mockImplementation(async (url: string | URL | Request) => {
147+
const urlStr = typeof url === 'string' ? url : url.toString();
148+
if (urlStr.includes('/v1/info')) {
149+
return { json: async () => ({ unilateralExitDelay: '605184' }) } as Response;
150+
}
151+
if (urlStr.includes('/v1/delegator/info')) {
152+
return { json: async () => ({ pubkey: '00' + Buffer.from(delegateKey).toString('hex') }) } as Response;
153+
}
154+
throw new Error(`Unexpected fetch: ${urlStr}`);
155+
});
156+
});
157+
158+
afterEach(() => {
159+
jest.restoreAllMocks();
160+
});
161+
162+
it('should verify a signature against a DelegateVtxo offchainAddr', async () => {
163+
const message = 'test message for delegate verification';
164+
const signature = signMessage(message);
165+
166+
const result = await service.verifySignature(message, delegateAddr, signature);
167+
168+
expect(result).toBe(true);
169+
});
170+
171+
it('should reject a wrong signature against DelegateVtxo offchainAddr', async () => {
172+
const signature = signMessage('original message');
173+
174+
const result = await service.verifySignature('different message', delegateAddr, signature);
175+
176+
expect(result).toBe(false);
177+
});
178+
});
89179
});

src/integration/blockchain/ark/ark.service.ts

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { Injectable } from '@nestjs/common';
2-
import { ArkAddress, DefaultVtxo } from '@arkade-os/sdk';
2+
import { ArkAddress, DefaultVtxo, DelegateVtxo } from '@arkade-os/sdk';
33
import { secp256k1 } from '@noble/curves/secp256k1';
44
import { sha256 } from '@noble/hashes/sha2';
5+
import { GetConfig } from 'src/config/config';
56
import { Bech32mService } from '../shared/bech32m/bech32m.service';
67
import { ArkClient, ArkTransaction } from './ark-client';
78

9+
type CsvTimelock = { value: bigint; type: 'seconds' | 'blocks' } | undefined;
10+
811
@Injectable()
912
export class ArkService extends Bech32mService {
1013
readonly defaultPrefix = 'ark';
1114

1215
private readonly client: ArkClient;
16+
private serverInfo: { exitDelay: bigint; delegatePubKeys: Uint8Array[] } | null | undefined;
1317

1418
constructor() {
1519
super();
@@ -28,22 +32,28 @@ export class ArkService extends Bech32mService {
2832
const decoded = ArkAddress.decode(address);
2933
const messageHash = sha256(new TextEncoder().encode(message));
3034
const signatureBytes = Buffer.from(signatureHex, 'hex');
31-
32-
for (let recovery = 0; recovery <= 3; recovery++) {
33-
try {
34-
const sig = secp256k1.Signature.fromBytes(signatureBytes, 'compact').addRecoveryBit(recovery);
35-
const xOnlyKey = sig.recoverPublicKey(messageHash).toBytes(true).slice(1);
36-
37-
const vtxoScript = new DefaultVtxo.Script({
38-
pubKey: xOnlyKey,
39-
serverPubKey: decoded.serverPubKey,
40-
});
41-
42-
if (Buffer.from(vtxoScript.tweakedPublicKey).equals(Buffer.from(decoded.vtxoTaprootKey))) {
43-
return true;
35+
const csvTimelocks = await this.getCsvTimelocks();
36+
const delegatePubKeys = await this.getDelegatePubKeys();
37+
38+
for (const csvTimelock of csvTimelocks) {
39+
for (let recovery = 0; recovery <= 3; recovery++) {
40+
try {
41+
const sig = secp256k1.Signature.fromBytes(signatureBytes, 'compact').addRecoveryBit(recovery);
42+
const xOnlyKey = sig.recoverPublicKey(messageHash).toBytes(true).slice(1);
43+
44+
const baseOpts = { pubKey: xOnlyKey, serverPubKey: decoded.serverPubKey, ...(csvTimelock && { csvTimelock }) };
45+
46+
// Try DefaultVtxo (non-delegated address)
47+
if (this.tweakedKeyMatches(new DefaultVtxo.Script(baseOpts), decoded.vtxoTaprootKey)) return true;
48+
49+
// Try DelegateVtxo for each known delegate pubkey
50+
for (const delegatePubKey of delegatePubKeys) {
51+
if (this.tweakedKeyMatches(new DelegateVtxo.Script({ ...baseOpts, delegatePubKey }), decoded.vtxoTaprootKey))
52+
return true;
53+
}
54+
} catch {
55+
continue;
4456
}
45-
} catch {
46-
continue;
4757
}
4858
}
4959
} catch {
@@ -53,6 +63,60 @@ export class ArkService extends Bech32mService {
5363
return false;
5464
}
5565

66+
private tweakedKeyMatches(vtxoScript: { tweakedPublicKey: Uint8Array }, target: Uint8Array): boolean {
67+
return Buffer.from(vtxoScript.tweakedPublicKey).equals(Buffer.from(target));
68+
}
69+
70+
private async getCsvTimelocks(): Promise<CsvTimelock[]> {
71+
const timelocks: CsvTimelock[] = [undefined];
72+
73+
const info = await this.getServerInfo();
74+
if (info) {
75+
timelocks.push({
76+
value: info.exitDelay,
77+
type: info.exitDelay < 512n ? 'blocks' : 'seconds',
78+
});
79+
}
80+
81+
return timelocks;
82+
}
83+
84+
private async getDelegatePubKeys(): Promise<Uint8Array[]> {
85+
const info = await this.getServerInfo();
86+
return info?.delegatePubKeys ?? [];
87+
}
88+
89+
private async getServerInfo(): Promise<{ exitDelay: bigint; delegatePubKeys: Uint8Array[] } | null> {
90+
if (this.serverInfo !== undefined) return this.serverInfo;
91+
92+
try {
93+
const { arkServerUrl } = GetConfig().blockchain.ark;
94+
95+
const infoRes = await fetch(`${arkServerUrl}/v1/info`);
96+
const info = await infoRes.json();
97+
const exitDelay = BigInt(info.unilateralExitDelay);
98+
99+
// Fetch known delegate pubkeys
100+
const delegatePubKeys: Uint8Array[] = [];
101+
const delegatorUrls = ['https://delegate.arkade.money'];
102+
for (const url of delegatorUrls) {
103+
try {
104+
const res = await fetch(`${url}/v1/delegator/info`);
105+
const data = await res.json();
106+
if (data?.pubkey) delegatePubKeys.push(Buffer.from(data.pubkey, 'hex').subarray(1));
107+
} catch {
108+
// delegator unreachable
109+
}
110+
}
111+
112+
this.serverInfo = { exitDelay, delegatePubKeys };
113+
return this.serverInfo;
114+
} catch {
115+
this.serverInfo = null;
116+
return null;
117+
}
118+
}
119+
56120
async isHealthy(): Promise<boolean> {
57121
return this.client.isHealthy();
58122
}

0 commit comments

Comments
 (0)