Skip to content

Commit

Permalink
fix: implement reclaim and update defaults
Browse files Browse the repository at this point in the history
  • Loading branch information
janniks committed Dec 13, 2024
1 parent 0e4c3aa commit a84863f
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 26 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/internal/src/apiMockingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export function enableFetchLogging() {
.json()
.catch(() => r.clone().text());
const url = input instanceof Request ? input.url : input;
const log = `'${url}': \`${JSON.stringify(response)}\`,`;
const log = `'${url}': \`${r.headers.get('content-type')?.includes('application/json') ? JSON.stringify(response) : response}\`,`;
fs.appendFileSync('network.txt', `${log}\n`);
return r;
};
Expand Down
2 changes: 1 addition & 1 deletion packages/sbtc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sbtc",
"version": "0.2.5",
"version": "0.2.6",
"description": "Library for sBTC.",
"license": "MIT",
"author": "Hiro Systems PBC (https://hiro.so)",
Expand Down
8 changes: 7 additions & 1 deletion packages/sbtc/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ export class SbtcApiClient {
}

async fetchTxHex(txid: string): Promise<string> {
return fetch(`${this.config.btcApiUrl}/tx/${txid}/hex`).then(res => res.text());
return fetch(`${this.config.btcApiUrl}/tx/${txid}/hex`, {
headers: {
Accept: 'text/plain',
'Content-Type': 'text/plain',
'Accept-Encoding': 'identity',
},
}).then(res => res.text());
}

async fetchFeeRates(): Promise<MempoolFeeEstimates> {
Expand Down
205 changes: 186 additions & 19 deletions packages/sbtc/src/transactions/deposit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as btc from '@scure/btc-signer';
import { P2TROut } from '@scure/btc-signer/payment';
import { bytesToHex, hexToBytes } from '@stacks/common';
import * as P from 'micro-packed';
import { UtxoWithTx } from '../api';
Expand All @@ -12,7 +13,11 @@ import {
stacksAddressBytes,
} from '../utils';

const concat = P.utils.concatBytes;
/** Taken from [bip-0341.mediawiki](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#user-content-Constructing_and_spending_Taproot_outputs) and [sbtc](https://github.com/stacks-network/sbtc/blob/a3a927f759871440962d8f8066108e5b0af696a0/sbtc/src/lib.rs#L28) */
export const UNSPENDABLE_PUB = new Uint8Array([
0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e,
0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0,
]);

export function buildSbtcDepositScript(opts: {
maxSignerFee: number;
Expand All @@ -23,36 +28,50 @@ export function buildSbtcDepositScript(opts: {
const recipientBytes = stacksAddressBytes(opts.stacksAddress);
const signersPublicKeyBytes = hexToBytes(opts.signersPublicKey);

if (signersPublicKeyBytes.length !== 32) {
throw new Error('Signers public key must be 32 bytes (schnorr)');
}

return btc.Script.encode([
concat(maxSignerFeeBytes, recipientBytes),
P.utils.concatBytes(maxSignerFeeBytes, recipientBytes),
'DROP',
signersPublicKeyBytes,
'CHECKSIG',
]);
}

export function buildSbtcReclaimScript(opts: { lockTime: number }) {
export function buildSbtcReclaimScript(opts: {
reclaimLockTime: number;
reclaimPublicKey: string;
}) {
const reclaimLockTime =
opts.reclaimLockTime <= 16
? opts.reclaimLockTime // number if can be encoded as a OP_<n>
: btc.ScriptNum().encode(BigInt(opts.reclaimLockTime));
const publicKeyBytes = hexToBytes(opts.reclaimPublicKey);

if (publicKeyBytes.length !== 32) throw new Error('Public key must be 32 bytes (schnorr)');

return btc.Script.encode([
btc.ScriptNum().encode(BigInt(opts.lockTime)),
'CHECKSEQUENCEVERIFY', // sbtc-bridge code does twice with additional optional data before
reclaimLockTime,
'CHECKSEQUENCEVERIFY',
'DROP',
publicKeyBytes,
'CHECKSIG',
]);
}

export function buildSbtcDepositTr(opts: {
function _buildSbtcDepositTr_Opts(opts: {
network: BitcoinNetwork;
stacksAddress: string;
signersPublicKey: string;
maxSignerFee: number;
lockTime: number;
reclaimLockTime: number;
reclaimPublicKey: string;
}) {
const deposit = buildSbtcDepositScript(opts);
const reclaim = buildSbtcReclaimScript(opts);

const UNSPENDABLE_PUB = new Uint8Array([
0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e,
0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0,
]);

return {
depositScript: bytesToHex(deposit),
reclaimScript: bytesToHex(reclaim),
Expand All @@ -66,26 +85,169 @@ export function buildSbtcDepositTr(opts: {
};
}

// function _buildSbtcDepositTr_Raw(opts: {
// network: BitcoinNetwork;
// depositScript: string;
// reclaimScript: string;
// }) {
// return {
// depositScript: opts.depositScript,
// reclaimScript: opts.reclaimScript,

// trOut: btc.p2tr(
// UNSPENDABLE_PUB,
// [{ script: hexToBytes(opts.depositScript) }, { script: hexToBytes(opts.reclaimScript) }],
// opts.network,
// true // allow custom scripts
// ),
// };
// }

export function buildSbtcDepositTr(opts: // | {
// network: BitcoinNetwork;
// depositScript: string;
// reclaimScript: string;
// }
{
network: BitcoinNetwork;
stacksAddress: string;
signersPublicKey: string;
maxSignerFee: number;
reclaimLockTime: number;
reclaimPublicKey: string;
}): {
depositScript: string;
reclaimScript: string;
trOut: P2TROut;
} {
// if ('depositScript' in opts) return _buildSbtcDepositTr_Raw(opts); // disable raw mode for now, since we need additional stuff anyway
return _buildSbtcDepositTr_Opts(opts);
}

// todo: fix types to expose this
// export function buildSbtcReclaimInput(opts: {
// network: BitcoinNetwork;
// amountSats: number | bigint;
// stacksAddress: string;
// signersPublicKey: string;
// maxSignerFee: number;
// lockTime: number;
// publicKey: string;

// txid: string;
// vout: number;
// }) {
// const reclaimScript = buildSbtcReclaimScript(opts);

// return {};
// }

export function buildSbtcReclaimTx({
network = REGTEST,
amountSats,
bitcoinAddress,
stacksAddress,
signersPublicKey,
maxSignerFee,
reclaimLockTime = 144,
reclaimPublicKey,
txid,
vout = 0,
feeRate,
}: {
network?: BitcoinNetwork;
amountSats: number | bigint;
bitcoinAddress: string;

/** The deposit recipient Stacks address (needed to reconstruct the deposit tx) */
stacksAddress: string;
/** The signers public key (aggregated schnorr; needed to reconstruct the deposit tx) */
signersPublicKey: string;
/** The max signer fee (needed to reconstruct the deposit tx) */
maxSignerFee: number;
/** The lock time (needed to reconstruct the deposit tx), defaults to 144 */
reclaimLockTime?: number;
/** The reclaim public key (schnorr; to reconstruct the deposit tx AND sign the reclaim tx) */
reclaimPublicKey: string;

// disable raw mode for now, since we need additional stuff anyway
// depositScript: string;
// reclaimScript: string;

txid: string;
vout?: number;

/** Fee rate in sat/vbyte for the reclaim tx */
feeRate: number;
}) {
const tx = new btc.Transaction({ allowUnknownInputs: true });
if (tx.version < 2) throw new Error('Transaction version must be >= 2');

const deposit = buildSbtcDepositTr({
network,
stacksAddress,
signersPublicKey,
maxSignerFee,
reclaimLockTime,
reclaimPublicKey,
});

if (deposit.trOut.tapLeafScript?.length !== 2) throw new Error('Failed to build deposit taproot');

const reclaimTapLeaf = deposit.trOut.tapLeafScript[1]; // const [reclaimMeta, reclaimBytes] = deposit.trOut.tapLeafScript[1];

if (!reclaimTapLeaf[0] || !reclaimTapLeaf[1]) throw new Error('Failed to build reclaim taproot');

tx.addInput({
txid,
index: vout,
witnessUtxo: {
script: deposit.trOut.script,
amount: BigInt(amountSats),
},
sequence: reclaimLockTime,
tapLeafScript: [reclaimTapLeaf],
});

tx.addOutputAddress(bitcoinAddress, BigInt(amountSats), network);

// hardcoded `tx.vsize` for this tx structure
// we don't need additional inputs, since we're only spending one well-funded input (deposit)
const VSIZE = 126;

const fee = BigInt(Math.ceil(VSIZE * feeRate));
if (fee > BigInt(amountSats)) throw new Error('Fee is higher than reclaim amount');

tx.updateOutput(0, { amount: BigInt(amountSats) - fee });

return tx;
}

export function buildSbtcDepositAddress({
network = REGTEST,
stacksAddress,
signersPublicKey,
maxSignerFee,
reclaimLockTime,
reclaimLockTime = 144,
reclaimPublicKey,
}: {
network: BitcoinNetwork;
stacksAddress: string;
/** Aggregated (schnorr) public key of all signers */
signersPublicKey: string;
maxSignerFee: number;
reclaimLockTime: number;
/** The lock time (needed to reconstruct the deposit tx), defaults to 144 */
reclaimLockTime?: number;
/** The reclaim public key (schnorr; to reconstruct the deposit tx AND sign the reclaim tx) */
reclaimPublicKey: string;
}) {
const tr = buildSbtcDepositTr({
network,
stacksAddress,
signersPublicKey,
maxSignerFee,
lockTime: reclaimLockTime,
reclaimLockTime,
reclaimPublicKey,
});
if (!tr.trOut.address) throw new Error('Failed to create build taproot output');

Expand All @@ -102,23 +264,24 @@ export function buildSbtcDepositTx({
signersPublicKey,
maxSignerFee,
reclaimLockTime,
reclaimPublicKey,
}: {
network: BitcoinNetwork;
amountSats: number;
amountSats: number | bigint;
stacksAddress: string;
/** Aggregated (schnorr) public key of all signers */
signersPublicKey: string;
maxSignerFee: number;
reclaimLockTime: number;
reclaimPublicKey: string;
}) {
// todo: check opts, e.g. pub key length to be schnorr

const deposit = buildSbtcDepositAddress({
network,
stacksAddress,
signersPublicKey,
maxSignerFee,
reclaimLockTime,
reclaimPublicKey,
});

const tx = new btc.Transaction();
Expand All @@ -137,8 +300,9 @@ export async function sbtcDepositHelper({
utxos,
utxoToSpendable = DEFAULT_UTXO_TO_SPENDABLE,
paymentPublicKey,
reclaimPublicKey,
maxSignerFee = 80_000,
reclaimLockTime = 6_000,
reclaimLockTime = 144,
}: {
/** Bitcoin network, defaults to REGTEST */
network?: BitcoinNetwork;
Expand All @@ -151,6 +315,8 @@ export async function sbtcDepositHelper({
stacksAddress: string;
/** Bitcoin change address */
bitcoinChangeAddress: string;
/** Reclaim public key (schnorr) */
reclaimPublicKey: string;
/** Fee rate in sat/vbyte */
feeRate: number;
/** UTXOs to use for the transaction */
Expand Down Expand Up @@ -191,6 +357,7 @@ export async function sbtcDepositHelper({
signersPublicKey,
maxSignerFee,
reclaimLockTime,
reclaimPublicKey,
});

// We separate this part, since wallets could handle it themselves
Expand Down
6 changes: 6 additions & 0 deletions packages/sbtc/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, test, vi } from 'vitest';
import createFetchMock from 'vitest-fetch-mock';
import { REGTEST, SbtcApiClientDevenv, SbtcApiClientTestnet } from '../src';
import { WALLET_00, getBitcoinAccount, getStacksAccount } from './helpers/wallet';
import { bytesToHex } from '@stacks/common';

const dev = new SbtcApiClientDevenv();
const tnet = new SbtcApiClientTestnet();
Expand All @@ -11,6 +12,11 @@ const tnet = new SbtcApiClientTestnet();

createFetchMock(vi).enableMocks();

test('script', async () => {
const wallet = await getBitcoinAccount(WALLET_00);
console.log(bytesToHex(wallet.privateKey));
});

describe('testnet:', () => {
test('fetch utxos', async () => {
fetchMock.mockOnce(
Expand Down
Loading

0 comments on commit a84863f

Please sign in to comment.