diff --git a/docs/docs/build/bob-sdk/gateway.md b/docs/docs/build/bob-sdk/gateway.md index 4293711c..779dc3fd 100644 --- a/docs/docs/build/bob-sdk/gateway.md +++ b/docs/docs/build/bob-sdk/gateway.md @@ -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. diff --git a/sdk/src/gateway/client.ts b/sdk/src/gateway/client.ts index 72071d4a..859e11b3 100644 --- a/sdk/src/gateway/client.ts +++ b/sdk/src/gateway/client.ts @@ -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 = Omit & Partial; @@ -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 { @@ -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; + const feeAmount = amount * feeDecimal; + return feeAmount; +} diff --git a/sdk/src/gateway/types.ts b/sdk/src/gateway/types.ts index 19965c62..ebd2b98c 100644 --- a/sdk/src/gateway/types.ts +++ b/sdk/src/gateway/types.ts @@ -67,6 +67,8 @@ export interface GatewayQuoteParams { fromUserPublicKey?: string; /** @description Strategy address */ strategyAddress?: string; + /** @description Partner address */ + feeRecipient?: string; } /** diff --git a/sdk/src/utils.ts b/sdk/src/utils.ts index 186e17d9..579c54c9 100644 --- a/sdk/src/utils.ts +++ b/sdk/src/utils.ts @@ -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; +} diff --git a/sdk/src/wallet/utxo.ts b/sdk/src/wallet/utxo.ts index 7a882465..96df7200 100644 --- a/sdk/src/wallet/utxo.ts +++ b/sdk/src/wallet/utxo.ts @@ -2,6 +2,9 @@ import { Transaction, Script, selectUTXO, TEST_NETWORK, NETWORK, p2wpkh, p2sh } 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; @@ -33,22 +36,37 @@ export interface Input { * 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} The Base64 encoded PSBT. */ -export async function createBitcoinPsbt( - fromAddress: string, - toAddress: string, - amount: number, - publicKey?: string, - opReturnData?: string, - confirmationTarget: number = 3 -): Promise { +export async function createBitcoinPsbt(params: { + fromAddress: string; + toAddress: string; + amount: number; + publicKey?: string; + opReturnData?: string; + confirmationTarget?: number; + feeRecipient?: string; + feeAmount?: number; +}): Promise { + const { + fromAddress, + toAddress, + amount, + publicKey, + opReturnData, + confirmationTarget = 3, + feeRecipient, + feeAmount, + } = params; + const addressInfo = getAddressInfo(fromAddress); const network = addressInfo.network; if (network === 'regtest') { @@ -92,14 +110,17 @@ export async function createBitcoinPsbt( }, ]; + 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))]), amount: BigInt(0), }); } @@ -117,7 +138,7 @@ export async function createBitcoinPsbt( 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) { diff --git a/sdk/test/gateway.test.ts b/sdk/test/gateway.test.ts index e03a0b40..988a6546 100644 --- a/sdk/test/gateway.test.ts +++ b/sdk/test/gateway.test.ts @@ -141,7 +141,7 @@ describe('Gateway Tests', () => { gatewayAddress: ZeroAddress, baseTokenAddress: TBTC_ADDRESS, dustThreshold: 1000, - satoshis: 1000, + satoshis: 10000, fee: 10, bitcoinAddress: 'bc1qafk4yhqvj4wep57m62dgrmutldusqde8adh20d', txProofDifficultyFactor: 3, @@ -158,7 +158,7 @@ describe('Gateway Tests', () => { toChain: 'BOB', toToken: 'tBTC', toUserAddress: '2N8DbeaBdjkktkRzaKL1tHj9FQELV7jA8Re', - amount: 1000, + amount: 10000, fromChain: 'Bitcoin', fromToken: 'BTC', fromUserAddress: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', @@ -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); @@ -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 () => { diff --git a/sdk/test/ordinal-api.test.ts b/sdk/test/ordinal-api.test.ts index 52830321..b9726174 100644 --- a/sdk/test/ordinal-api.test.ts +++ b/sdk/test/ordinal-api.test.ts @@ -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, diff --git a/sdk/test/utxo.test.ts b/sdk/test/utxo.test.ts index 95b1e6f4..5a7804d4 100644 --- a/sdk/test/utxo.test.ts +++ b/sdk/test/utxo.test.ts @@ -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);