diff --git a/api/xverseInscribe.ts b/api/xverseInscribe.ts new file mode 100644 index 00000000..df845796 --- /dev/null +++ b/api/xverseInscribe.ts @@ -0,0 +1,180 @@ +import axios from 'axios'; + +import { + Brc20CostEstimateRequest, + Brc20CostEstimateResponse, + Brc20CreateOrderRequest, + Brc20CreateOrderResponse, + Brc20ExecuteOrderRequest, + Brc20ExecuteOrderResponse, + Brc20FinalizeTransferOrderRequest, + Brc20FinalizeTransferOrderResponse, + NetworkType, +} from 'types'; + +import { XVERSE_INSCRIBE_URL } from '../constant'; + +const apiClient = axios.create({ + baseURL: XVERSE_INSCRIBE_URL, +}); + +const getBrc20TransferFees = async ( + tick: string, + amount: number, + revealAddress: string, + feeRate: number, + inscriptionValue?: number, +): Promise => { + const requestBody: Brc20CostEstimateRequest = { + operation: 'transfer', + tick, + amount, + revealAddress, + feeRate, + inscriptionValue, + }; + const response = await apiClient.post('/v1/brc20/cost-estimate', requestBody); + return response.data; +}; + +const createBrc20TransferOrder = async ( + tick: string, + amount: number, + revealAddress: string, + feeRate: number, + network: NetworkType, + inscriptionValue?: number, +): Promise => { + const requestBody: Brc20CreateOrderRequest = { + operation: 'transfer', + tick, + amount, + revealAddress, + feeRate, + network, + inscriptionValue, + }; + const response = await apiClient.post('/v1/brc20/place-order', requestBody); + return response.data; +}; + +const getBrc20MintFees = async ( + tick: string, + amount: number, + revealAddress: string, + feeRate: number, + inscriptionValue?: number, +): Promise => { + const requestBody: Brc20CostEstimateRequest = { + operation: 'mint', + tick, + amount, + revealAddress, + feeRate, + inscriptionValue, + }; + const response = await apiClient.post('/v1/brc20/cost-estimate', requestBody); + return response.data; +}; + +const createBrc20MintOrder = async ( + tick: string, + amount: number, + revealAddress: string, + feeRate: number, + network: NetworkType, + inscriptionValue?: number, +): Promise => { + const requestBody: Brc20CreateOrderRequest = { + operation: 'mint', + tick, + amount, + revealAddress, + feeRate, + network, + inscriptionValue, + }; + const response = await apiClient.post('/v1/brc20/place-order', requestBody); + return response.data; +}; + +const getBrc20DeployFees = async ( + tick: string, + max: number, + limit: number, + revealAddress: string, + feeRate: number, + inscriptionValue?: number, +): Promise => { + const requestBody: Brc20CostEstimateRequest = { + operation: 'deploy', + tick, + lim: limit, + max: max, + revealAddress, + feeRate, + inscriptionValue, + }; + const response = await apiClient.post('/v1/brc20/cost-estimate', requestBody); + return response.data; +}; + +const createBrc20DeployOrder = async ( + tick: string, + max: number, + limit: number, + revealAddress: string, + feeRate: number, + network: NetworkType, + inscriptionValue?: number, +): Promise => { + const requestBody: Brc20CreateOrderRequest = { + operation: 'deploy', + tick, + lim: limit, + max: max, + revealAddress, + feeRate, + network, + inscriptionValue, + }; + const response = await apiClient.post('/v1/brc20/place-order', requestBody); + return response.data; +}; + +const executeBrc20Order = async ( + commitAddress: string, + commitTransactionHex: string, + skipFinalize?: boolean, +): Promise => { + const requestBody: Brc20ExecuteOrderRequest = { + commitAddress, + commitTransactionHex, + skipFinalize, + }; + const response = await apiClient.post('/v1/brc20/execute-order', requestBody); + return response.data; +}; + +const finalizeBrc20TransferOrder = async ( + commitAddress: string, + transferTransactionHex: string, +): Promise => { + const requestBody: Brc20FinalizeTransferOrderRequest = { + commitAddress, + transferTransactionHex, + }; + const response = await apiClient.post('/v1/brc20/finalize-order', requestBody); + return response.data; +}; + +export default { + getBrc20TransferFees, + createBrc20TransferOrder, + getBrc20MintFees, + createBrc20MintOrder, + getBrc20DeployFees, + createBrc20DeployOrder, + executeBrc20Order, + finalizeBrc20TransferOrder, +}; diff --git a/constant.ts b/constant.ts index d70a2b73..029b32e9 100644 --- a/constant.ts +++ b/constant.ts @@ -34,6 +34,8 @@ export const NFT_BASE_URI = 'https://stacks.gamma.io/api/v1/collections'; export const XVERSE_API_BASE_URL = 'https://api.xverse.app'; +export const XVERSE_INSCRIBE_URL = 'https://inscribe.xverse.app'; + export const XVERSE_SPONSOR_URL = 'https://sponsor.xverse.app'; export const GAIA_HUB_URL = 'https://hub.blockstack.org'; diff --git a/tests/transactions/brc20.test.ts b/tests/transactions/brc20.test.ts new file mode 100644 index 00000000..618ba8d4 --- /dev/null +++ b/tests/transactions/brc20.test.ts @@ -0,0 +1,432 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { UTXO } from 'types'; +import BitcoinEsploraApiProvider from '../../api/esplora/esploraAPiProvider'; +import xverseInscribeApi from '../../api/xverseInscribe'; +import { + generateSignedBtcTransaction, + selectUtxosForSend, + signNonOrdinalBtcSendTransaction, +} from '../../transactions/btc'; +import { getBtcPrivateKey } from '../../wallet'; + +import BigNumber from 'bignumber.js'; +import { + brc20MintEstimateFees, + brc20MintExecute, + brc20TransferEstimateFees, + brc20TransferExecute, + ExecuteTransferProgressCodes, +} from '../../transactions/brc20'; + +vi.mock('../../api/xverseInscribe'); +vi.mock('../../api/esplora/esploraAPiProvider'); +vi.mock('../../transactions/btc'); +vi.mock('../../wallet'); + +describe('brc20MintEstimateFees', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should estimate BRC20 mint fees correctly', async () => { + const mockedAddressUtxos: UTXO[] = []; + const mockedTick = 'TICK'; + const mockedAmount = 10; + const mockedRevealAddress = 'bc1pyzfhlkq29sylwlv72ve52w8mn7hclefzhyay3dxh32r0322yx6uqajvr3y'; + const mockedFeeRate = 12; + + vi.mocked(xverseInscribeApi.getBrc20MintFees).mockResolvedValue({ + chainFee: 1080, + serviceFee: 2000, + inscriptionValue: 1000, + vSize: 150, + }); + + vi.mocked(selectUtxosForSend).mockReturnValueOnce({ + change: 2070, + fee: 1070, + feeRate: 12, + selectedUtxos: [], + }); + + const result = await brc20MintEstimateFees({ + addressUtxos: mockedAddressUtxos, + tick: mockedTick, + amount: mockedAmount, + revealAddress: mockedRevealAddress, + feeRate: mockedFeeRate, + }); + + expect(result).toEqual({ + commitValue: 1070 + 1080 + 2000 + 1000, + valueBreakdown: { + commitChainFee: 1070, + revealChainFee: 1080, + revealServiceFee: 2000, + inscriptionValue: 1000, + }, + }); + + expect(xverseInscribeApi.getBrc20MintFees).toHaveBeenCalledWith( + mockedTick, + mockedAmount, + mockedRevealAddress, + mockedFeeRate, + 1000, + ); + + expect(selectUtxosForSend).toHaveBeenCalledWith({ + changeAddress: 'bc1pgkwmp9u9nel8c36a2t7jwkpq0hmlhmm8gm00kpdxdy864ew2l6zqw2l6vh', + recipients: [{ address: mockedRevealAddress, amountSats: new BigNumber(4080) }], + availableUtxos: mockedAddressUtxos, + feeRate: mockedFeeRate, + }); + }); +}); + +describe('brc20MintExecute', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should mint BRC20 successfully', async () => { + const mockedSeedPhrase = 'seed_phrase'; + const mockedAccountIndex = 0; + const mockedAddressUtxos: UTXO[] = []; + const mockedTick = 'TICK'; + const mockedAmount = 10; + const mockedRevealAddress = 'reveal_address'; + const mockedChangeAddress = 'change_address'; + const mockedFeeRate = 12; + const mockedNetwork = 'Mainnet'; + const mockedSelectedUtxos: UTXO[] = []; + + vi.mocked(getBtcPrivateKey).mockResolvedValueOnce('private_key'); + + vi.mocked(xverseInscribeApi.createBrc20MintOrder).mockResolvedValue({ + commitAddress: 'commit_address', + commitValue: 1000, + } as any); + + vi.mocked(selectUtxosForSend).mockReturnValueOnce({ + change: 2070, + fee: 1070, + feeRate: 12, + selectedUtxos: mockedSelectedUtxos, + }); + + vi.mocked(generateSignedBtcTransaction).mockResolvedValueOnce({ hex: 'commit_hex' } as any); + + vi.mocked(xverseInscribeApi.executeBrc20Order).mockResolvedValueOnce({ + revealTransactionId: 'revealId', + revealUTXOVOut: 0, + revealUTXOValue: 3000, + }); + + const result = await brc20MintExecute({ + seedPhrase: mockedSeedPhrase, + accountIndex: mockedAccountIndex, + addressUtxos: mockedAddressUtxos, + tick: mockedTick, + amount: mockedAmount, + revealAddress: mockedRevealAddress, + changeAddress: mockedChangeAddress, + feeRate: mockedFeeRate, + network: mockedNetwork, + }); + + expect(result).toEqual('revealId'); + + expect(getBtcPrivateKey).toHaveBeenCalledWith( + expect.objectContaining({ + seedPhrase: mockedSeedPhrase, + index: BigInt(mockedAccountIndex), + network: 'Mainnet', + }), + ); + + expect(xverseInscribeApi.createBrc20MintOrder).toHaveBeenCalledWith( + mockedTick, + mockedAmount, + mockedRevealAddress, + mockedFeeRate, + 'Mainnet', + 1000, + ); + + expect(selectUtxosForSend).toHaveBeenCalledWith({ + changeAddress: 'change_address', + recipients: [{ address: mockedRevealAddress, amountSats: new BigNumber(1000) }], + availableUtxos: mockedAddressUtxos, + feeRate: mockedFeeRate, + }); + + expect(generateSignedBtcTransaction).toHaveBeenCalledWith( + 'private_key', + mockedSelectedUtxos, + new BigNumber(1000), + [ + { + address: 'commit_address', + amountSats: new BigNumber(1000), + }, + ], + mockedChangeAddress, + new BigNumber(1070), + 'Mainnet', + ); + + expect(xverseInscribeApi.executeBrc20Order).toHaveBeenCalledWith('commit_address', 'commit_hex'); + }); +}); + +describe('brc20TransferEstimateFees', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should estimate BRC20 transfer fees correctly', async () => { + const mockedAddressUtxos: UTXO[] = []; + const mockedTick = 'TICK'; + const mockedAmount = 10; + const mockedRevealAddress = 'bc1pyzfhlkq29sylwlv72ve52w8mn7hclefzhyay3dxh32r0322yx6uqajvr3y'; + const mockedFeeRate = 12; + + vi.mocked(signNonOrdinalBtcSendTransaction).mockResolvedValueOnce({ + tx: { vsize: 150 }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we only use this field in this function + } as any); + + vi.mocked(xverseInscribeApi.getBrc20TransferFees).mockResolvedValue({ + chainFee: 1080, + serviceFee: 2000, + inscriptionValue: 1000, + vSize: 150, + }); + + vi.mocked(selectUtxosForSend).mockReturnValueOnce({ + change: 2070, + fee: 1070, + feeRate: 12, + selectedUtxos: [], + }); + + const result = await brc20TransferEstimateFees({ + addressUtxos: mockedAddressUtxos, + tick: mockedTick, + amount: mockedAmount, + revealAddress: mockedRevealAddress, + feeRate: mockedFeeRate, + }); + + expect(result).toEqual({ + commitValue: 1070 + 1080 + 2000 + 1800 + 1000, + valueBreakdown: { + commitChainFee: 1070, + revealChainFee: 1080, + revealServiceFee: 2000, + transferChainFee: 1800, + transferUtxoValue: 1000, + }, + }); + + expect(xverseInscribeApi.getBrc20TransferFees).toHaveBeenCalledWith( + mockedTick, + mockedAmount, + mockedRevealAddress, + mockedFeeRate, + 2800, + ); + + expect(selectUtxosForSend).toHaveBeenCalledWith({ + changeAddress: 'bc1pgkwmp9u9nel8c36a2t7jwkpq0hmlhmm8gm00kpdxdy864ew2l6zqw2l6vh', + recipients: [{ address: mockedRevealAddress, amountSats: new BigNumber(5880) }], + availableUtxos: mockedAddressUtxos, + feeRate: mockedFeeRate, + }); + }); +}); + +describe('brc20TransferExecute', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should execute BRC20 transfer correctly', async () => { + const mockedSeedPhrase = 'seed_phrase'; + const mockedAccountIndex = 0; + const mockedAddressUtxos: UTXO[] = []; + const mockedTick = 'TICK'; + const mockedAmount = 10; + const mockedRevealAddress = 'reveal_address'; + const mockedChangeAddress = 'change_address'; + const mockedRecipientAddress = 'recipient_address'; + const mockedFeeRate = 12; + const mockedNetwork = 'Mainnet'; + + vi.mocked(getBtcPrivateKey).mockResolvedValueOnce('private_key'); + + vi.mocked(signNonOrdinalBtcSendTransaction).mockResolvedValue({ + tx: { vsize: 150 }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we only use this field in this function + } as any); + + vi.mocked(xverseInscribeApi.createBrc20TransferOrder).mockResolvedValueOnce({ + commitAddress: 'commit_address', + commitValue: 1000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we only use these 2 fields in this function + } as any); + + const mockedSelectedUtxos: UTXO[] = []; + vi.mocked(selectUtxosForSend).mockReturnValueOnce({ + change: 2070, + fee: 1070, + feeRate: mockedFeeRate, + selectedUtxos: mockedSelectedUtxos, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- only use the one field in this method + vi.mocked(generateSignedBtcTransaction).mockResolvedValueOnce({ hex: 'commit_hex' } as any); + + vi.mocked(xverseInscribeApi.executeBrc20Order).mockResolvedValueOnce({ + revealTransactionId: 'revealId', + revealUTXOVOut: 0, + revealUTXOValue: 3000, + }); + + vi.mocked(BitcoinEsploraApiProvider).mockImplementation( + () => + ({ + sendRawTransaction: vi.fn().mockResolvedValueOnce({ tx: { hash: 'tx_hash' } }), + } as any), + ); + + vi.mocked(xverseInscribeApi.finalizeBrc20TransferOrder).mockResolvedValueOnce({ + revealTransactionId: 'revealId', + commitTransactionId: 'commitId', + transferTransactionId: 'transferId', + }); + + // Execute the generator function + const generator = brc20TransferExecute({ + seedPhrase: mockedSeedPhrase, + accountIndex: mockedAccountIndex, + addressUtxos: mockedAddressUtxos, + tick: mockedTick, + amount: mockedAmount, + revealAddress: mockedRevealAddress, + changeAddress: mockedChangeAddress, + recipientAddress: mockedRecipientAddress, + feeRate: mockedFeeRate, + network: mockedNetwork, + }); + + let result; + let done; + + // Iterate through the generator function until it's done + do { + ({ value: result, done } = await generator.next()); + + // Assert the progress and result based on the current value + switch (result) { + case ExecuteTransferProgressCodes.CreatingInscriptionOrder: + expect(getBtcPrivateKey).toHaveBeenCalledWith( + expect.objectContaining({ + seedPhrase: mockedSeedPhrase, + index: BigInt(mockedAccountIndex), + network: 'Mainnet', + }), + ); + break; + + case ExecuteTransferProgressCodes.CreatingCommitTransaction: + expect(signNonOrdinalBtcSendTransaction).toHaveBeenCalledTimes(1); + expect(signNonOrdinalBtcSendTransaction).toHaveBeenCalledWith( + mockedRecipientAddress, + [ + { + address: mockedRevealAddress, + status: { confirmed: false }, + txid: '0000000000000000000000000000000000000000000000000000000000000001', + vout: 0, + value: 1000, + }, + ], + 0, + mockedSeedPhrase, + 'Mainnet', + new BigNumber(1), + ); + + expect(xverseInscribeApi.createBrc20TransferOrder).toHaveBeenCalledWith( + mockedTick, + mockedAmount, + mockedRevealAddress, + mockedFeeRate, + 'Mainnet', + 1000 + 150 * mockedFeeRate, + ); + break; + + case ExecuteTransferProgressCodes.ExecutingInscriptionOrder: + expect(selectUtxosForSend).toHaveBeenCalledWith({ + changeAddress: mockedChangeAddress, + recipients: [{ address: mockedRevealAddress, amountSats: new BigNumber(1000) }], + availableUtxos: mockedAddressUtxos, + feeRate: mockedFeeRate, + }); + + expect(generateSignedBtcTransaction).toHaveBeenCalledWith( + 'private_key', + mockedSelectedUtxos, + new BigNumber(1000), + [ + { + address: 'commit_address', + amountSats: new BigNumber(1000), + }, + ], + mockedChangeAddress, + new BigNumber(1070), + 'Mainnet', + ); + break; + + case ExecuteTransferProgressCodes.CreatingTransferTransaction: + expect(xverseInscribeApi.executeBrc20Order).toHaveBeenCalledWith('commit_address', 'commit_hex', true); + break; + + case ExecuteTransferProgressCodes.Finalizing: + expect(signNonOrdinalBtcSendTransaction).toHaveBeenCalledTimes(2); + expect(signNonOrdinalBtcSendTransaction).toHaveBeenLastCalledWith( + mockedRecipientAddress, + [ + { + address: mockedRevealAddress, + status: { confirmed: false }, + txid: 'revealId', + vout: 0, + value: 3000, + }, + ], + 0, + mockedSeedPhrase, + 'Mainnet', + new BigNumber(1800), + ); + + break; + default: + break; + } + } while (!done); + + expect(result).toEqual({ + revealTransactionId: 'revealId', + commitTransactionId: 'commitId', + transferTransactionId: 'transferId', + }); + }); +}); diff --git a/transactions/brc20.ts b/transactions/brc20.ts index 3b4a0059..bf9b2609 100644 --- a/transactions/brc20.ts +++ b/transactions/brc20.ts @@ -1,27 +1,73 @@ import { base64 } from '@scure/base'; +import BigNumber from 'bignumber.js'; +import { NetworkType, UTXO } from 'types'; import { createInscriptionRequest } from '../api'; import BitcoinEsploraApiProvider from '../api/esplora/esploraAPiProvider'; +import xverseInscribeApi from '../api/xverseInscribe'; +import { getBtcPrivateKey } from '../wallet'; +import { generateSignedBtcTransaction, selectUtxosForSend, signNonOrdinalBtcSendTransaction } from './btc'; -const createTransferInscriptionContent = (token: string, amount: string) => - ({ - p: 'brc-20', - op: 'transfer', - tick: token, - amt: amount, - }); +// This is the value of the inscription output, which the final recipient of the inscription will receive. +const FINAL_SATS_VALUE = 1000; + +type EstimateProps = { + addressUtxos: UTXO[]; + tick: string; + amount: number; + revealAddress: string; + feeRate: number; +}; + +type BaseEstimateResult = { + commitValue: number; + valueBreakdown: { + commitChainFee: number; + revealChainFee: number; + revealServiceFee: number; + }; +}; + +type EstimateResult = BaseEstimateResult & { + valueBreakdown: { + inscriptionValue: number; + }; +}; + +type TransferEstimateResult = BaseEstimateResult & { + valueBreakdown: { + transferChainFee: number; + transferUtxoValue: number; + }; +}; + +type ExecuteProps = { + seedPhrase: string; + accountIndex: number; + addressUtxos: UTXO[]; + tick: string; + amount: number; + revealAddress: string; + changeAddress: string; + feeRate: number; + network: NetworkType; +}; + +const createTransferInscriptionContent = (token: string, amount: string) => ({ + p: 'brc-20', + op: 'transfer', + tick: token, + amt: amount, +}); const btcClient = new BitcoinEsploraApiProvider({ - network: 'Mainnet', -}) + network: 'Mainnet', +}); -export const createBrc20TransferOrder = async ( - token: string, - amount: string, - recipientAddress: string, -) => { +// TODO: deprecate +export const createBrc20TransferOrder = async (token: string, amount: string, recipientAddress: string) => { const transferInscriptionContent = createTransferInscriptionContent(token, amount); const contentB64 = base64.encode(Buffer.from(JSON.stringify(transferInscriptionContent))); - const contentSize = Buffer.from(JSON.stringify(transferInscriptionContent)).length + const contentSize = Buffer.from(JSON.stringify(transferInscriptionContent)).length; const feesResponse = await btcClient.getRecommendedFees(); const inscriptionRequest = await createInscriptionRequest( recipientAddress, @@ -37,3 +83,311 @@ export const createBrc20TransferOrder = async ( feesResponse, }; }; + +export const brc20MintEstimateFees = async (estimateProps: EstimateProps): Promise => { + const { addressUtxos, tick, amount, revealAddress, feeRate } = estimateProps; + const dummyAddress = 'bc1pgkwmp9u9nel8c36a2t7jwkpq0hmlhmm8gm00kpdxdy864ew2l6zqw2l6vh'; + + const { chainFee: revealChainFee, serviceFee: revealServiceFee } = await xverseInscribeApi.getBrc20MintFees( + tick, + amount, + revealAddress, + feeRate, + FINAL_SATS_VALUE, + ); + + const commitValue = new BigNumber(FINAL_SATS_VALUE).plus(revealChainFee).plus(revealServiceFee); + + const bestUtxoData = selectUtxosForSend({ + changeAddress: dummyAddress, + recipients: [{ address: revealAddress, amountSats: new BigNumber(commitValue) }], + availableUtxos: addressUtxos, + feeRate, + }); + + if (!bestUtxoData) { + throw new Error('Not enough funds at selected fee rate'); + } + + const commitChainFees = bestUtxoData.fee; + + return { + commitValue: commitValue.plus(commitChainFees).toNumber(), + valueBreakdown: { + commitChainFee: commitChainFees, + revealChainFee, + revealServiceFee, + inscriptionValue: FINAL_SATS_VALUE, + }, + }; +}; + +export async function brc20MintExecute(executeProps: ExecuteProps): Promise { + const { seedPhrase, accountIndex, addressUtxos, tick, amount, revealAddress, changeAddress, feeRate, network } = + executeProps; + + const privateKey = await getBtcPrivateKey({ + seedPhrase, + index: BigInt(accountIndex), + network: 'Mainnet', + }); + + const { commitAddress, commitValue } = await xverseInscribeApi.createBrc20MintOrder( + tick, + amount, + revealAddress, + feeRate, + network, + FINAL_SATS_VALUE, + ); + + const bestUtxoData = selectUtxosForSend({ + changeAddress, + recipients: [{ address: revealAddress, amountSats: new BigNumber(commitValue) }], + availableUtxos: addressUtxos, + feeRate, + }); + + if (!bestUtxoData) { + throw new Error('Not enough funds at selected fee rate'); + } + + const commitChainFees = bestUtxoData.fee; + + const commitTransaction = await generateSignedBtcTransaction( + privateKey, + bestUtxoData.selectedUtxos, + new BigNumber(commitValue), + [ + { + address: commitAddress, + amountSats: new BigNumber(commitValue), + }, + ], + changeAddress, + new BigNumber(commitChainFees), + network, + ); + + const { revealTransactionId } = await xverseInscribeApi.executeBrc20Order(commitAddress, commitTransaction.hex); + + return revealTransactionId; +} + +export const brc20TransferEstimateFees = async (estimateProps: EstimateProps): Promise => { + const { addressUtxos, tick, amount, revealAddress, feeRate } = estimateProps; + + const dummyAddress = 'bc1pgkwmp9u9nel8c36a2t7jwkpq0hmlhmm8gm00kpdxdy864ew2l6zqw2l6vh'; + const finalRecipientUtxoValue = new BigNumber(FINAL_SATS_VALUE); + const { tx } = await signNonOrdinalBtcSendTransaction( + dummyAddress, + [ + { + address: revealAddress, + status: { + confirmed: false, + }, + txid: '0000000000000000000000000000000000000000000000000000000000000001', + vout: 0, + value: FINAL_SATS_VALUE, + }, + ], + 0, + 'action action action action action action action action action action action action', + 'Mainnet', + new BigNumber(1), + ); + + const transferFeeEstimate = tx.vsize * feeRate; + + const inscriptionValue = finalRecipientUtxoValue.plus(transferFeeEstimate); + + const { chainFee: revealChainFee, serviceFee: revealServiceFee } = await xverseInscribeApi.getBrc20TransferFees( + tick, + amount, + revealAddress, + feeRate, + inscriptionValue.toNumber(), + ); + + const commitValue = inscriptionValue.plus(revealChainFee).plus(revealServiceFee); + + const bestUtxoData = selectUtxosForSend({ + changeAddress: dummyAddress, + recipients: [{ address: revealAddress, amountSats: new BigNumber(commitValue) }], + availableUtxos: addressUtxos, + feeRate, + }); + + if (!bestUtxoData) { + throw new Error('Not enough funds at selected fee rate'); + } + + const commitChainFees = bestUtxoData.fee; + + return { + commitValue: commitValue.plus(commitChainFees).toNumber(), + valueBreakdown: { + commitChainFee: commitChainFees, + revealChainFee, + revealServiceFee, + transferChainFee: transferFeeEstimate, + transferUtxoValue: finalRecipientUtxoValue.toNumber(), + }, + }; +}; + +export enum ExecuteTransferProgressCodes { + CreatingInscriptionOrder = 'CreatingInscriptionOrder', + CreatingCommitTransaction = 'CreatingCommitTransaction', + ExecutingInscriptionOrder = 'ExecutingInscriptionOrder', + CreatingTransferTransaction = 'CreatingTransferTransaction', + Finalizing = 'Finalizing', +} + +export async function* brc20TransferExecute(executeProps: ExecuteProps & { recipientAddress: string }): AsyncGenerator< + ExecuteTransferProgressCodes, + { + revealTransactionId: string; + commitTransactionId: string; + transferTransactionId: string; + }, + never +> { + const { + seedPhrase, + accountIndex, + addressUtxos, + tick, + amount, + revealAddress, + changeAddress, + feeRate, + recipientAddress, + network, + } = executeProps; + + const privateKey = await getBtcPrivateKey({ + seedPhrase, + index: BigInt(accountIndex), + network: 'Mainnet', + }); + + yield ExecuteTransferProgressCodes.CreatingInscriptionOrder; + + const finalRecipientUtxoValue = new BigNumber(FINAL_SATS_VALUE); + const { tx } = await signNonOrdinalBtcSendTransaction( + recipientAddress, + [ + { + address: revealAddress, + status: { + confirmed: false, + }, + txid: '0000000000000000000000000000000000000000000000000000000000000001', + vout: 0, + value: FINAL_SATS_VALUE, + }, + ], + accountIndex, + seedPhrase, + 'Mainnet', + new BigNumber(1), + ); + + const transferFeeEstimate = tx.vsize * feeRate; + + const inscriptionValue = finalRecipientUtxoValue.plus(transferFeeEstimate); + + const { commitAddress, commitValue } = await xverseInscribeApi.createBrc20TransferOrder( + tick, + amount, + revealAddress, + feeRate, + network, + inscriptionValue.toNumber(), + ); + + yield ExecuteTransferProgressCodes.CreatingCommitTransaction; + + const bestUtxoData = selectUtxosForSend({ + changeAddress, + recipients: [{ address: revealAddress, amountSats: new BigNumber(commitValue) }], + availableUtxos: addressUtxos, + feeRate, + }); + + if (!bestUtxoData) { + throw new Error('Not enough funds at selected fee rate'); + } + + const commitChainFees = bestUtxoData.fee; + + const commitTransaction = await generateSignedBtcTransaction( + privateKey, + bestUtxoData.selectedUtxos, + new BigNumber(commitValue), + [ + { + address: commitAddress, + amountSats: new BigNumber(commitValue), + }, + ], + changeAddress, + new BigNumber(commitChainFees), + network, + ); + + yield ExecuteTransferProgressCodes.ExecutingInscriptionOrder; + + const { revealTransactionId, revealUTXOVOut, revealUTXOValue } = await xverseInscribeApi.executeBrc20Order( + commitAddress, + commitTransaction.hex, + true, + ); + + yield ExecuteTransferProgressCodes.CreatingTransferTransaction; + + const transferTransaction = await signNonOrdinalBtcSendTransaction( + recipientAddress, + [ + { + address: revealAddress, + status: { + confirmed: false, + }, + txid: revealTransactionId, + vout: revealUTXOVOut, + value: revealUTXOValue, + }, + ], + accountIndex, + seedPhrase, + 'Mainnet', + new BigNumber(transferFeeEstimate), + ); + + yield ExecuteTransferProgressCodes.Finalizing; + + // we sleep here to give the reveal transaction time to propagate + await new Promise((resolve) => setTimeout(resolve, 500)); + + const MAX_RETRIES = 5; + let error: Error | undefined; + + for (let i = 0; i <= MAX_RETRIES; i++) { + try { + const response = await xverseInscribeApi.finalizeBrc20TransferOrder(commitAddress, transferTransaction.signedTx); + + return response; + } catch (err) { + error = err as Error; + } + // we do exponential back-off here to give the reveal transaction time to propagate + // sleep times are 500ms, 1000ms, 2000ms, 4000ms, 8000ms + // eslint-disable-next-line @typescript-eslint/no-loop-func -- exponential back-off sleep between retries + await new Promise((resolve) => setTimeout(resolve, 500 * Math.pow(2, i))); + } + + throw error ?? new Error('Failed to broadcast transfer transaction'); +} diff --git a/transactions/btc.ts b/transactions/btc.ts index 78263020..b77073df 100644 --- a/transactions/btc.ts +++ b/transactions/btc.ts @@ -162,7 +162,7 @@ export async function generateSignedBtcTransaction( throw new ResponseError(ErrorCodes.InSufficientBalanceWithTxFee).statusCode; } - const changeSats = sumValue.minus(satsToSend); + const changeSats = sumValue.minus(satsToSend).minus(feeSats); addInputs(tx, selectedUnspentOutputs, p2sh); @@ -897,34 +897,30 @@ export async function signNonOrdinalBtcSendTransaction( calculatedFee = fee; } - try { - const tx = new btc.Transaction(); - const btcNetwork = getBtcNetwork(network); + const tx = new btc.Transaction(); + const btcNetwork = getBtcNetwork(network); - // Create spend - const taprootInternalPubKey = secp256k1.schnorr.getPublicKey(taprootPrivateKey); - const p2tr = btc.p2tr(taprootInternalPubKey, undefined, btcNetwork); + // Create spend + const taprootInternalPubKey = secp256k1.schnorr.getPublicKey(taprootPrivateKey); + const p2tr = btc.p2tr(taprootInternalPubKey, undefined, btcNetwork); - addInputsTaproot(tx, selectedUnspentOutputs, taprootInternalPubKey, p2tr); + addInputsTaproot(tx, selectedUnspentOutputs, taprootInternalPubKey, p2tr); - // Add outputs - recipients.forEach((recipient) => { - addOutput(tx, recipient.address, recipient.amountSats.minus(calculatedFee), btcNetwork); - }); + // Add outputs + recipients.forEach((recipient) => { + addOutput(tx, recipient.address, recipient.amountSats.minus(calculatedFee), btcNetwork); + }); - // Sign inputs - tx.sign(hex.decode(taprootPrivateKey)); - tx.finalize(); + // Sign inputs + tx.sign(hex.decode(taprootPrivateKey)); + tx.finalize(); - const signedBtcTx: SignedBtcTx = { - tx: tx, - signedTx: tx.hex, - fee: fee ?? calculatedFee, - total: sumSelectedOutputs, - }; + const signedBtcTx: SignedBtcTx = { + tx: tx, + signedTx: tx.hex, + fee: fee ?? calculatedFee, + total: sumSelectedOutputs, + }; - return await Promise.resolve(signedBtcTx); - } catch (error) { - return Promise.reject(error.toString()); - } + return signedBtcTx; } diff --git a/transactions/index.ts b/transactions/index.ts index 4c323acd..fa040bdd 100644 --- a/transactions/index.ts +++ b/transactions/index.ts @@ -25,7 +25,12 @@ import { signTransaction, } from './stx'; -import { createBrc20TransferOrder } from './brc20'; +import { + ExecuteTransferProgressCodes, + brc20TransferEstimateFees, + brc20TransferExecute, + createBrc20TransferOrder, +} from './brc20'; import type { PSBTInput, PSBTOutput, ParsedPSBT } from './psbt'; import { parsePsbt } from './psbt'; import { @@ -35,7 +40,10 @@ import { } from './stacking'; export { + ExecuteTransferProgressCodes, addressToString, + brc20TransferEstimateFees, + brc20TransferExecute, broadcastSignedTransaction, createBrc20TransferOrder, createContractCallPromises, diff --git a/types/api/xverseInscribe/brc20.ts b/types/api/xverseInscribe/brc20.ts new file mode 100644 index 00000000..caec2a75 --- /dev/null +++ b/types/api/xverseInscribe/brc20.ts @@ -0,0 +1,67 @@ +import { NetworkType } from 'types/network'; + +type Brc20TransferOrMintRequest = { + operation: 'transfer' | 'mint'; + tick: string; + amount: number; +}; + +type Brc20DeployRequest = { + operation: 'deploy'; + tick: string; + max: number; + lim: number; +}; + +export type Brc20CostEstimateRequest = (Brc20TransferOrMintRequest | Brc20DeployRequest) & { + revealAddress: string; + feeRate: number; + inscriptionValue?: number; +}; + +export type Brc20CostEstimateResponse = { + inscriptionValue: number; + chainFee: number; + serviceFee: number; + vSize: number; +}; + +export type Brc20CreateOrderRequest = (Brc20TransferOrMintRequest | Brc20DeployRequest) & { + revealAddress: string; + feeRate: number; + network: NetworkType; + inscriptionValue?: number; +}; + +export type Brc20CreateOrderResponse = { + commitAddress: string; + commitValue: number; + commitValueBreakdown: { + inscriptionValue: number; + chainFee: number; + serviceFee: number; + }; +}; + +export type Brc20ExecuteOrderRequest = { + commitAddress: string; + commitTransactionHex: string; + skipFinalize?: boolean; +}; + +export type Brc20ExecuteOrderResponse = { + revealTransactionId: string; + revealUTXOVOut: 0; + revealUTXOValue: number; +}; + +export type Brc20FinalizeTransferOrderRequest = { + commitAddress: string; + transferTransactionHex: string; +}; + +export type Brc20FinalizeTransferOrderResponse = { + commitTransactionId: string; + revealTransactionId: string; + transferTransactionId: string; +}; diff --git a/types/api/xverseInscribe/index.ts b/types/api/xverseInscribe/index.ts new file mode 100644 index 00000000..e28c487e --- /dev/null +++ b/types/api/xverseInscribe/index.ts @@ -0,0 +1 @@ +export * from './brc20'; diff --git a/types/index.ts b/types/index.ts index 050a0e8f..7c7ecda1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -44,6 +44,7 @@ export * from './api/xverse/sponsor'; export type { Pool, StackerInfo, StackingData, StackingPoolInfo, StackingStateData } from './api/xverse/stacking'; export * from './api/xverse/transaction'; export * from './api/xverse/wallet'; +export * from './api/xverseInscribe'; export type { SupportedCurrency } from './currency'; export * from './error'; export * from './network';