Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add fee output to psbt #376

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/docs/build/bob-sdk/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ Returns a `uuid` for the order and `psbtBase64`, a partially-signed Bitcoin tran
const { uuid, psbtBase64 } = await gatewaySDK.startOrder(quote, quoteParams);
```

#### Fees

It is possible to collect BTC fees if the `feeRecipient` and `fee` (BPS) params are set:

```ts
const { uuid, psbtBase64 } = await gatewaySDK.startOrder(quote, {
...quoteParams,
feeRecipient: '1K69EVCKwd8vm8GFbCzyWz7CBVrnjkis5G',
fee: 100, // 1%
});
```

This will add a new output to the PSBT which collects fees based on the amount of BTC requested (up to 10%), note that any output below the dust limit will be dropped.

### Sign the Bitcoin Transaction

Create a Bitcoin transaction that sends the quoted `amount` of BTC to the LP's `bitcoinAddress`. This also publishes a hash of the order's parameters in the `OP_RETURN` of the transaction so the Gateway can trustlessly verify the order on BOB.
Expand Down
37 changes: 21 additions & 16 deletions sdk/src/gateway/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { SYMBOL_LOOKUP, ADDRESS_LOOKUP } from './tokens';
import { createBitcoinPsbt } from '../wallet';
import { Network } from 'bitcoin-address-validation';
import { EsploraClient } from '../esplora';
import { stripHexPrefix } from '../utils';

type Optional<T, K extends keyof T> = Omit<T, K> & Partial<T>;

Expand Down Expand Up @@ -197,14 +198,16 @@ export class GatewayApiClient {
typeof params.fromChain === 'string' &&
params.fromChain.toLowerCase() === Chain.BITCOIN
) {
psbtBase64 = await createBitcoinPsbt(
params.fromUserAddress,
gatewayQuote.bitcoinAddress,
gatewayQuote.satoshis,
params.fromUserPublicKey,
data.opReturnHash,
gatewayQuote.txProofDifficultyFactor
);
psbtBase64 = await createBitcoinPsbt({
fromAddress: params.fromUserAddress,
toAddress: gatewayQuote.bitcoinAddress,
amount: gatewayQuote.satoshis,
publicKey: params.fromUserPublicKey,
opReturnData: data.opReturnHash,
confirmationTarget: gatewayQuote.txProofDifficultyFactor,
feeRecipient: params.feeRecipient,
feeAmount: calculateFeeAmount(gatewayQuote.satoshis, params.fee),
});
}

return {
Expand Down Expand Up @@ -436,17 +439,19 @@ function calculateOpReturnHash(req: GatewayCreateOrderRequest) {
);
}

function isHexPrefixed(str: string): boolean {
return str.slice(0, 2) === '0x';
}

function stripHexPrefix(str: string): string {
return isHexPrefixed(str) ? str.slice(2) : str;
}

function slugify(str: string): string {
return str
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
}

function calculateFeeAmount(amount: number, fee?: number) {
if (typeof fee === 'undefined' || fee < 1 || fee > 1000) {
fee = 0;
}

const feeDecimal = fee / 10000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use big.js here. JS arithmetic with decimals is not great.

const feeAmount = amount * feeDecimal;
return feeAmount;
}
2 changes: 2 additions & 0 deletions sdk/src/gateway/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface GatewayQuoteParams {
fromUserPublicKey?: string;
/** @description Strategy address */
strategyAddress?: string;
/** @description Partner address */
feeRecipient?: string;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions sdk/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,11 @@ export function estimateTxFee(feeRate: number, numInputs: number = 1) {
const satoshis = feeRate * vsize;
return satoshis;
}

export function isHexPrefixed(str: string): boolean {
return str.slice(0, 2) === '0x';
}

export function stripHexPrefix(str: string): string {
return isHexPrefixed(str) ? str.slice(2) : str;
}
61 changes: 41 additions & 20 deletions sdk/src/wallet/utxo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import { hex, base64 } from '@scure/base';
import { AddressType, getAddressInfo, Network } from 'bitcoin-address-validation';
import { EsploraClient, UTXO } from '../esplora';
import { stripHexPrefix } from '../utils';

const DUST_LIMIT = 546;

export type BitcoinNetworkName = Exclude<Network, 'regtest'>;

Expand Down Expand Up @@ -33,22 +36,37 @@
* May add an additional change output. This returns an **unsigned** PSBT encoded
* as a Base64 string.
*
* @param fromAddress The Bitcoin address which is sending to the `toAddress`.
* @param toAddress The Bitcoin address which is receiving the BTC.
* @param amount The amount of BTC (as satoshis) to send.
* @param publicKey Optional public key needed if using P2SH-P2WPKH.
* @param opReturnData Optional OP_RETURN data to include in an output.
* @param confirmationTarget The number of blocks to include this tx (for fee estimation).
* @param params An object containing the following properties:
* - `fromAddress`: The Bitcoin address which is sending to the `toAddress`.
* - `toAddress`: The Bitcoin address which is receiving the BTC.
* - `amount`: The amount of BTC (as satoshis) to send.
* - `publicKey` (optional): Public key needed if using P2SH-P2WPKH.
* - `opReturnData` (optional): OP_RETURN data to include in an output.
* - `confirmationTarget` (optional): The number of blocks to include this tx (for fee estimation). Defaults to 3.
*
* @returns {Promise<string>} The Base64 encoded PSBT.
*/
export async function createBitcoinPsbt(
fromAddress: string,
toAddress: string,
amount: number,
publicKey?: string,
opReturnData?: string,
confirmationTarget: number = 3
): Promise<string> {
export async function createBitcoinPsbt(params: {
fromAddress: string;
toAddress: string;
amount: number;
publicKey?: string;
opReturnData?: string;
confirmationTarget?: number;
feeRecipient?: string;
feeAmount?: number;
}): Promise<string> {
const {
fromAddress,
toAddress,
amount,
publicKey,
opReturnData,
confirmationTarget = 3,
feeRecipient,
feeAmount,
} = params;

const addressInfo = getAddressInfo(fromAddress);
const network = addressInfo.network;
if (network === 'regtest') {
Expand Down Expand Up @@ -92,14 +110,17 @@
},
];

if (feeRecipient && feeAmount && feeAmount > DUST_LIMIT) {
outputs.push({
address: feeRecipient,
amount: BigInt(feeAmount),
});
}

if (opReturnData) {
// Strip 0x prefix from opReturn
if (opReturnData.startsWith('0x')) {
opReturnData = opReturnData.slice(2);
}
outputs.push({
// OP_RETURN https://github.com/paulmillr/scure-btc-signer/issues/26
script: Script.encode(['RETURN', hex.decode(opReturnData)]),
script: Script.encode(['RETURN', hex.decode(stripHexPrefix(opReturnData))]),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary now and wasn't necessary before?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does the same thing as the code I removed above

amount: BigInt(0),
});
}
Expand All @@ -117,11 +138,11 @@
allowUnknownOutputs: true, // Required for OP_RETURN
allowLegacyWitnessUtxo: true, // Required for P2SH-P2WPKH
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dust: BigInt(546) as any, // TODO: update scure-btc-signer
dust: BigInt(DUST_LIMIT) as any, // TODO: update scure-btc-signer
});

if (!transaction || !transaction.tx) {
throw new Error('Failed to create transaction. Do you have enough funds?');

Check failure on line 145 in sdk/src/wallet/utxo.ts

View workflow job for this annotation

GitHub Actions / SDK

test/utxo.test.ts > UTXO Tests > should spend from address to create a transaction with an OP return output

Error: Failed to create transaction. Do you have enough funds? ❯ Module.createBitcoinPsbt src/wallet/utxo.ts:145:15 ❯ test/utxo.test.ts:52:44 ❯ test/utxo.test.ts:42:17 ❯ test/utxo.test.ts:40:9
}

return base64.encode(transaction.tx.toPSBT(0));
Expand Down
10 changes: 7 additions & 3 deletions sdk/test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe('Gateway Tests', () => {
gatewayAddress: ZeroAddress,
baseTokenAddress: TBTC_ADDRESS,
dustThreshold: 1000,
satoshis: 1000,
satoshis: 10000,
fee: 10,
bitcoinAddress: 'bc1qafk4yhqvj4wep57m62dgrmutldusqde8adh20d',
txProofDifficultyFactor: 3,
Expand All @@ -158,7 +158,7 @@ describe('Gateway Tests', () => {
toChain: 'BOB',
toToken: 'tBTC',
toUserAddress: '2N8DbeaBdjkktkRzaKL1tHj9FQELV7jA8Re',
amount: 1000,
amount: 10000,
fromChain: 'Bitcoin',
fromToken: 'BTC',
fromUserAddress: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
Expand All @@ -169,10 +169,12 @@ describe('Gateway Tests', () => {
toChain: 'BOB',
toToken: 'tBTC',
toUserAddress: ZeroAddress,
amount: 1000,
amount: 10000,
fromChain: 'Bitcoin',
fromToken: 'BTC',
fromUserAddress: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
feeRecipient: '3DFVKuT9Ft4rWpysAZ1bHpg55EBy1HVPcr',
fee: 600,
});

assert.isDefined(result.psbtBase64);
Expand All @@ -181,6 +183,8 @@ describe('Gateway Tests', () => {
psbt.txOutputs[0].script,
bitcoin.script.compile([bitcoin.opcodes.OP_RETURN, Buffer.from(result.opReturnHash.slice(2), 'hex')])
);
assert.deepEqual(psbt.txOutputs[1].address, '3DFVKuT9Ft4rWpysAZ1bHpg55EBy1HVPcr');
assert.deepEqual(psbt.txOutputs[1].value, 600);
});

it('should get strategies', async () => {
Expand Down
3 changes: 1 addition & 2 deletions sdk/test/ordinal-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ describe('Ordinal API Tests', () => {
});
const expectedOutputJson: OutputJson = {
value: 10737,
script_pubkey:
'OP_PUSHNUM_1 OP_PUSHBYTES_32 e18a5367c5d11ee31d10bf4c53e743a7479c70e3336e70dbdea1fd927305c022',
script_pubkey: '5120e18a5367c5d11ee31d10bf4c53e743a7479c70e3336e70dbdea1fd927305c022',
address: 'bc1pux99xe796y0wx8gshax98e6r5arecu8rxdh8pk77587eyuc9cq3q2e3nng',
transaction: 'dfe942a58b7e29a3952d8d1ed6608086c66475d20bc7bdbc3d784d616f9a6a7a',
sat_ranges: null,
Expand Down
8 changes: 7 additions & 1 deletion sdk/test/utxo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ describe('UTXO Tests', () => {
// Use a random public key for P2SH-P2WPKH
pubkey = '03b366c69e8237d9be7c4f1ac2a7abc6a79932fbf3de4e2f6c04797d7ef27abfe1';
}
const psbtBase64 = await createBitcoinPsbt(paymentAddress, toAddress, amount, pubkey, opReturn);
const psbtBase64 = await createBitcoinPsbt({
fromAddress: paymentAddress,
toAddress,
amount,
publicKey: pubkey,
opReturnData: opReturn,
});
const transaction = Transaction.fromPSBT(base64.decode(psbtBase64));

assert(transaction);
Expand Down
Loading