Skip to content

Commit

Permalink
feat: scan silent block
Browse files Browse the repository at this point in the history
  • Loading branch information
notTanveer committed Jan 28, 2025
1 parent 19398bc commit eb68b35
Show file tree
Hide file tree
Showing 6 changed files with 260 additions and 22 deletions.
60 changes: 47 additions & 13 deletions packages/core/src/scanning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,15 @@ const processTweak = (
return 0; // No counter increment
};

export const scanOutputs = (
scanPrivateKey: Buffer,
function scanOutputsUsingSecret(
ecdhSecret: Uint8Array,
spendPublicKey: Buffer,
sumOfInputPublicKeys: Buffer,
inputHash: Buffer,
outputs: Buffer[],
labels?: LabelMap,
): Map<string, Buffer> => {
const ecdhSecret = secp256k1.publicKeyTweakMul(
sumOfInputPublicKeys,
secp256k1.privateKeyTweakMul(scanPrivateKey, inputHash),
true,
);

// output to tweak data map
): Map<string, Buffer> {
const matches = new Map<string, Buffer>();
let n = 0;
let counterIncrement = 0;

do {
const tweak = createTaggedHash(
'BIP0352/SharedSecret',
Expand All @@ -108,4 +98,48 @@ export const scanOutputs = (
} while (counterIncrement > 0 && outputs.length > 0);

return matches;
}

export const scanOutputs = (
scanPrivateKey: Buffer,
spendPublicKey: Buffer,
sumOfInputPublicKeys: Buffer,
inputHash: Buffer,
outputs: Buffer[],
labels?: LabelMap,
): Map<string, Buffer> => {
const ecdhSecret = secp256k1.publicKeyTweakMul(
sumOfInputPublicKeys,
secp256k1.privateKeyTweakMul(scanPrivateKey, inputHash),
true,
);

return scanOutputsUsingSecret(ecdhSecret, spendPublicKey, outputs, labels);
};

export const scanOutputsWithTweak = (
scanPrivateKey: Buffer,
spendPublicKey: Buffer,
scanTweak: Buffer,
outputs: Buffer[],
labels?: LabelMap,
): Map<string, Buffer> => {
if (scanTweak.length === 33) {
// Use publicKeyTweakMul for compressed pubkey
const ecdhSecret = secp256k1.publicKeyTweakMul(
scanTweak,
scanPrivateKey,
true,
);
return scanOutputsUsingSecret(
ecdhSecret,
spendPublicKey,
outputs,
labels,
);
} else {
throw new Error(
`Expected scanTweak to be either 33-byte compressed public key, got ${scanTweak.length}`,
);
}
};
42 changes: 42 additions & 0 deletions packages/core/test/fixtures/scanning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,45 @@ export const testData = [
expected: {},
},
];

export const scanTweakVectors = [
{
description: 'single matching output',
scanPrivateKey:
'38658693c017c46fd6b8bb94b8766c123cd5baf6026338305b6f59f82b36f9c0',
spendPublicKey:
'02833085c9a716d36b467552c00d6aa8bd42e39adbe98b05bc203110177192f702',
tweak: '02ccd442a997b40661a9a1e233884986a9c970f5da3b68514c6ea2533b708e2ae1',
outputs: [
'025a90a5fad7ab4d32e41fd02de786f7af3ecbe85bd18784e51e89a97c8693ca3c',
],
expectedTweakHex:
'635aaddb7a1f7f64a6b78ddf47772ae987f6e29b79bb4eebbf1826f21af25e39',
},
{
description: 'no matching outputs',
scanPrivateKey:
'38658693c017c46fd6b8bb94b8766c123cd5baf6026338305b6f59f82b36f9c0',
spendPublicKey:
'02833085c9a716d36b467552c00d6aa8bd42e39adbe98b05bc203110177192f702',
tweak: '02ccd442a997b40661a9a1e233884986a9c970f5da3b68514c6ea2533b708e2ae1',
outputs: [
'03aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
],
expectedTweakHex: null,
},
{
description: 'multiple matching outputs',
scanPrivateKey:
'38658693c017c46fd6b8bb94b8766c123cd5baf6026338305b6f59f82b36f9c0',
spendPublicKey:
'02833085c9a716d36b467552c00d6aa8bd42e39adbe98b05bc203110177192f702',
tweak: '02ccd442a997b40661a9a1e233884986a9c970f5da3b68514c6ea2533b708e2ae1',
outputs: [
'025a90a5fad7ab4d32e41fd02de786f7af3ecbe85bd18784e51e89a97c8693ca3c',
'025a90a5fad7ab4d32e41fd02de786f7af3ecbe85bd18784e51e89a97c8693ca3c',
],
expectedTweakHex:
'635aaddb7a1f7f64a6b78ddf47772ae987f6e29b79bb4eebbf1826f21af25e39',
},
];
34 changes: 31 additions & 3 deletions packages/core/test/scanning.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { scanOutputs } from '../src';
import { LabelMap, scanOutputs, scanOutputsWithTweak } from '../src';
import { Buffer } from 'buffer';
import { testData } from './fixtures/scanning';
import { testData, scanTweakVectors } from './fixtures/scanning';

describe('Scanning', () => {
it.each(testData)(
Expand All @@ -20,7 +20,7 @@ describe('Scanning', () => {
Buffer.from(sumOfInputPublicKeys, 'hex'),
Buffer.from(inputHash, 'hex'),
outputs.map((output) => Buffer.from(output, 'hex')),
labels,
labels as LabelMap,
);

expect(result).toStrictEqual(
Expand All @@ -33,4 +33,32 @@ describe('Scanning', () => {
);
},
);

it.each(scanTweakVectors)(
'should scan using scan tweak - $description',
({
scanPrivateKey,
spendPublicKey,
tweak,
outputs,
expectedTweakHex,
}) => {
const res = scanOutputsWithTweak(
Buffer.from(scanPrivateKey, 'hex'),
Buffer.from(spendPublicKey, 'hex'),
Buffer.from(tweak, 'hex'),
outputs.map((o) => Buffer.from(o, 'hex')),
);

if (!expectedTweakHex) {
expect(res.size).toBe(0);
} else {
expect(res.size).toBeGreaterThan(0);
for (const [output, foundTweak] of res) {
expect(foundTweak.toString('hex')).toBe(expectedTweakHex);
expect(output).toBeDefined();
}
}
},
);
});
91 changes: 87 additions & 4 deletions packages/wallet/src/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ import { fromOutputScript, toOutputScript } from 'bitcoinjs-lib/src/address';
import { ECPairFactory } from 'ecpair';
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
import { encrypt, decrypt } from 'bip38';
import { createOutputs, encodeSilentPaymentAddress } from '@silent-pay/core';
import {
createOutputs,
encodeSilentPaymentAddress,
SilentBlock,
scanOutputsWithTweak,
} from '@silent-pay/core';
import { NetworkInterface, DbInterface, Coin, CoinSelector } from './index.ts';
import { bitcoin } from 'bitcoinjs-lib/src/networks';

initEccLib(ecc);
const ECPair = ECPairFactory(ecc);
Expand Down Expand Up @@ -307,16 +313,19 @@ export class Wallet {
return tx.getId();
}

getCoinType(): number {
return this.network.network.bech32 === bitcoin.bech32 ? 0 : 1;
}

async generateSilentPaymentAddress(): Promise<string> {
let address = await this.db.getSilentPaymentAddress();
if (address) return address;

const coinType = this.network.network.bech32 === 'bc' ? 0 : 1;
const spendKey = this.masterKey.derivePath(
`m/352'/${coinType}'/0'/0'/0`,
`m/352'/${this.getCoinType()}'/0'/0'/0`,
);
const scanKey = this.masterKey.derivePath(
`m/352'/${coinType}'/0'/1'/0`,
`m/352'/${this.getCoinType()}'/0'/1'/0`,
);

address = encodeSilentPaymentAddress(
Expand All @@ -327,4 +336,78 @@ export class Wallet {
await this.db.saveSilentPaymentAddress(address);
return address;
}

public matchSilentBlockOutputs(
silentBlock: SilentBlock,
scanPrivateKey: Buffer,
spendPublicKey: Buffer,
): Coin[] {
const matchedUTXOs: Coin[] = [];

for (const transaction of silentBlock.transactions) {
const outputs = transaction.outputs;

if (outputs.length === 0) continue;

const outputPubKeys = outputs.map((output) =>
Buffer.from('02' + output.pubKey, 'hex'),
);

const scanTweak = Buffer.from(transaction.scanTweak, 'hex');

const matches = scanOutputsWithTweak(
scanPrivateKey,
spendPublicKey,
scanTweak,
outputPubKeys,
);

if (matches.size === 0) continue;

for (const pubKeyHex of matches.keys()) {
const output = outputs.find(
(output) => output.pubKey === pubKeyHex.slice(2),
);
if (output) {
matchedUTXOs.push(
new Coin({
txid: transaction.txid,
vout: output.vout,
value: output.value,
address: payments.p2tr({
pubkey: toXOnly(
Buffer.from('02' + output.pubKey, 'hex'),
),
network: this.network.network,
}).address,
status: {
isConfirmed: true,
},
}),
);
}
}
}

return matchedUTXOs;
}

async scanSilentBlock(silentBlock: SilentBlock): Promise<void> {
const scanKey = this.masterKey.derivePath(
`m/352'/${this.getCoinType()}'/0'/1'/0`,
);
const spendKey = this.masterKey.derivePath(
`m/352'/${this.getCoinType()}'/0'/0'/0`,
);

const matchedUTXOs = this.matchSilentBlockOutputs(
silentBlock,
scanKey.privateKey,
spendKey.publicKey,
);

if (matchedUTXOs.length) {
await this.db.saveUnspentCoins(matchedUTXOs);
}
}
}
17 changes: 17 additions & 0 deletions packages/wallet/test/helpers/silent-block.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const parsedSilentBlock = {
type: 0,
transactions: [
{
txid: '7d77c249a6ade81248e1a2ba28e1128e3beee0a3727d6cc0b1bed13e07f12147',
scanTweak:
'02ccd442a997b40661a9a1e233884986a9c970f5da3b68514c6ea2533b708e2ae1',
outputs: [
{
pubKey: '5a90a5fad7ab4d32e41fd02de786f7af3ecbe85bd18784e51e89a97c8693ca3c',
value: 4000000,
vout: 0,
},
],
},
],
};
38 changes: 36 additions & 2 deletions packages/wallet/test/wallet.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as fs from 'fs';
import { BitcoinRpcClient } from './helpers/bitcoin-rpc-client';
import { Wallet } from '../src';
import { WalletDB } from '@silent-pay/level';
import { EsploraClient } from '@silent-pay/esplora';
import { WalletDB } from '@silent-pay/level/src';
import { EsploraClient } from '@silent-pay/esplora/src';
import { parsedSilentBlock } from './helpers/silent-block.fixtures';

describe('Wallet', () => {
let wallet: Wallet;
Expand Down Expand Up @@ -155,6 +156,39 @@ describe('Wallet', () => {
expect(tx).toBeDefined();
});

it('should match UTXOs from a silent block without relying on db', async () => {
const scanKey = wallet['masterKey'].derivePath("m/352'/1'/0'/1'/0");
const spendKey = wallet['masterKey'].derivePath("m/352'/1'/0'/0'/0");

expect(scanKey.privateKey).toBeDefined();

const matchedUTXOs = wallet.matchSilentBlockOutputs(
parsedSilentBlock,
scanKey.privateKey!,
spendKey.publicKey,
);
expect(matchedUTXOs.length).toBeGreaterThan(0);
expect(matchedUTXOs[0]).toHaveProperty('txid');
});

it.each(parsedSilentBlock.transactions)(
'should scan silent block transaction %s and update UTXOs',
async (transaction) => {
await wallet.scanSilentBlock(parsedSilentBlock);
const utxos = await walletDB.getUnspentCoins();

expect(utxos).toContainEqual(
expect.objectContaining({
txid: transaction.txid,
vout: transaction.outputs[0].vout,
value: transaction.outputs[0].value,
address: expect.stringMatching(/^bcrt1p/),
status: { isConfirmed: true },
}),
);
},
);

afterAll(async () => {
await wallet.close();
fs.rmSync('./test/wallet', { recursive: true, force: true });
Expand Down

0 comments on commit eb68b35

Please sign in to comment.