diff --git a/api/ordinals.ts b/api/ordinals.ts index 3b11bf46..e2470178 100644 --- a/api/ordinals.ts +++ b/api/ordinals.ts @@ -72,12 +72,18 @@ export async function fetchBtcOrdinalsData(btcAddress: string, network: NetworkT return ordinals.sort(sortOrdinalsByConfirmationTime); } -export async function getOrdinalIdFromUtxo(utxo: UTXO) { +export async function getOrdinalIdsFromUtxo(utxo: UTXO): Promise { const ordinalContentUrl = `${XVERSE_INSCRIBE_URL}/v1/inscriptions/utxo/${utxo.txid}/${utxo.vout}`; - const ordinalIds = await axios.get(ordinalContentUrl); - if (ordinalIds.data.length > 0) { - return ordinalIds.data[ordinalIds.data.length - 1]; + const { data: ordinalIds } = await axios.get(ordinalContentUrl); + + return ordinalIds; +} + +export async function getOrdinalIdFromUtxo(utxo: UTXO) { + const ordinalIds = await getOrdinalIdsFromUtxo(utxo); + if (ordinalIds.length > 0) { + return ordinalIds[ordinalIds.length - 1]; } else { return null; } diff --git a/api/xverseInscribe.ts b/api/xverseInscribe.ts index df845796..cde4b8d4 100644 --- a/api/xverseInscribe.ts +++ b/api/xverseInscribe.ts @@ -9,6 +9,12 @@ import { Brc20ExecuteOrderResponse, Brc20FinalizeTransferOrderRequest, Brc20FinalizeTransferOrderResponse, + InscriptionCostEstimateRequest, + InscriptionCostEstimateResponse, + InscriptionCreateOrderRequest, + InscriptionCreateOrderResponse, + InscriptionExecuteOrderRequest, + InscriptionExecuteOrderResponse, NetworkType, } from 'types'; @@ -18,6 +24,26 @@ const apiClient = axios.create({ baseURL: XVERSE_INSCRIBE_URL, }); +const getInscriptionFeeEstimate = async ( + requestBody: InscriptionCostEstimateRequest, +): Promise => { + const response = await apiClient.post('/v1/inscriptions/cost-estimate', requestBody); + return response.data; +}; + +const createInscriptionOrder = async ( + requestBody: InscriptionCreateOrderRequest, +): Promise => { + const response = await apiClient.post('/v1/inscriptions/place-order', requestBody); + return response.data; +}; +const executeInscriptionOrder = async ( + requestBody: InscriptionExecuteOrderRequest, +): Promise => { + const response = await apiClient.post('/v1/inscriptions/execute-order', requestBody); + return response.data; +}; + const getBrc20TransferFees = async ( tick: string, amount: number, @@ -169,6 +195,9 @@ const finalizeBrc20TransferOrder = async ( }; export default { + getInscriptionFeeEstimate, + createInscriptionOrder, + executeInscriptionOrder, getBrc20TransferFees, createBrc20TransferOrder, getBrc20MintFees, diff --git a/hooks/brc20/index.ts b/hooks/brc20/index.ts new file mode 100644 index 00000000..185c9d4e --- /dev/null +++ b/hooks/brc20/index.ts @@ -0,0 +1,2 @@ +export { default as useBrc20TransferExecute } from './useBrc20TransferExecute'; +export { default as useBrc20TransferFees } from './useBrc20TransferFees'; diff --git a/hooks/brc20/useBrc20TransferExecute.ts b/hooks/brc20/useBrc20TransferExecute.ts new file mode 100644 index 00000000..5d85e594 --- /dev/null +++ b/hooks/brc20/useBrc20TransferExecute.ts @@ -0,0 +1,182 @@ +import { useCallback, useState } from 'react'; + +import { NetworkType, UTXO } from 'types'; +import { CoreError } from '../../utils/coreError'; + +import { BRC20ErrorCode, ExecuteTransferProgressCodes, brc20TransferExecute } from '../../transactions/brc20'; + +export enum ErrorCode { + INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS', + INVALID_TICK = 'INVALID_TICK', + INVALID_AMOUNT = 'INVALID_AMOUNT', + INVALID_FEE_RATE = 'INVALID_FEE_RATE', + BROADCAST_FAILED = 'BROADCAST_FAILED', + SERVER_ERROR = 'SERVER_ERROR', +} + +type Props = { + seedPhrase: string; + accountIndex: number; + addressUtxos: UTXO[]; + tick: string; + amount: number; + revealAddress: string; + changeAddress: string; + recipientAddress: string; + feeRate: number; + network: NetworkType; +}; + +const validateProps = (props: Props) => { + const { addressUtxos, tick, amount, feeRate } = props; + + if (!addressUtxos.length) { + return ErrorCode.INSUFFICIENT_FUNDS; + } + + if (tick.length !== 4) { + return ErrorCode.INVALID_TICK; + } + + if (amount <= 0) { + return ErrorCode.INVALID_AMOUNT; + } + + if (feeRate <= 0) { + return ErrorCode.INVALID_FEE_RATE; + } + return undefined; +}; + +/** + * + * @param seedPhrase - The seed phrase of the wallet + * @param accountIndex - The account index of the seed phrase to use + * @param addressUtxos - The UTXOs in the bitcoin address which will be used for payment + * @param tick - The 4 letter BRC-20 token name + * @param amount - The amount of the BRC-20 token to transfer + * @param revealAddress - The address where the balance of the BRC-20 token lives. This is usually the ordinals address. + * @param changeAddress - The address where change SATS will be sent to. Should be the Bitcoin address of the wallet. + * @param recipientAddress - The address where the BRC-20 tokens will be sent to. + * @param feeRate - The desired fee rate for the transactions + * @param network - The network to broadcast the transactions on (Mainnet or Testnet) + * @returns + */ +const useBrc20TransferExecute = (props: Props) => { + const { + seedPhrase, + accountIndex, + addressUtxos, + tick, + amount, + revealAddress, + changeAddress, + recipientAddress, + feeRate, + network, + } = props; + const [running, setRunning] = useState(false); + const [commitTransactionId, setCommitTransactionId] = useState(); + const [revealTransactionId, setRevealTransactionId] = useState(); + const [transferTransactionId, setTransferTransactionId] = useState(); + const [progress, setProgress] = useState(); + const [errorCode, setErrorCode] = useState(); + + const executeTransfer = useCallback(() => { + if (running || !!transferTransactionId) return; + + const innerProps = { + seedPhrase, + accountIndex, + addressUtxos, + tick, + amount, + revealAddress, + changeAddress, + recipientAddress, + feeRate, + network, + }; + + const validationErrorCode = validateProps(innerProps); + setErrorCode(validationErrorCode); + + if (validationErrorCode) { + return; + } + + // if we get to here, that means that the transfer is valid and we can try to execute it but we don't want to + // be able to accidentally execute it again if something goes wrong, so we set the running flag + setRunning(true); + + const runTransfer = async () => { + try { + const transferGenerator = await brc20TransferExecute(innerProps); + + let done = false; + do { + const itt = await transferGenerator.next(); + done = itt.done ?? false; + + if (done) { + const result = itt.value as { + revealTransactionId: string; + commitTransactionId: string; + transferTransactionId: string; + }; + setCommitTransactionId(result.commitTransactionId); + setRevealTransactionId(result.revealTransactionId); + setTransferTransactionId(result.transferTransactionId); + setProgress(undefined); + } else { + setProgress(itt.value as ExecuteTransferProgressCodes); + } + } while (!done); + } catch (e) { + let finalErrorCode: string | undefined; + if (CoreError.isCoreError(e)) { + finalErrorCode = e.code; + } + + switch (finalErrorCode) { + case BRC20ErrorCode.FAILED_TO_FINALIZE: + setErrorCode(ErrorCode.BROADCAST_FAILED); + break; + case BRC20ErrorCode.INSUFFICIENT_FUNDS: + setErrorCode(ErrorCode.INSUFFICIENT_FUNDS); + break; + default: + setErrorCode(ErrorCode.SERVER_ERROR); + break; + } + } + }; + + runTransfer(); + }, [ + seedPhrase, + accountIndex, + addressUtxos, + tick, + amount, + revealAddress, + changeAddress, + recipientAddress, + feeRate, + network, + running, + transferTransactionId, + ]); + + return { + executeTransfer, + transferTransactionId, + commitTransactionId, + revealTransactionId, + complete: !!transferTransactionId, + progress, + errorCode, + }; +}; + +export default useBrc20TransferExecute; diff --git a/hooks/brc20/useBrc20TransferFees.ts b/hooks/brc20/useBrc20TransferFees.ts new file mode 100644 index 00000000..30db6c17 --- /dev/null +++ b/hooks/brc20/useBrc20TransferFees.ts @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; + +import { UTXO } from 'types'; +import { brc20TransferEstimateFees } from '../../transactions/brc20'; + +type CommitValueBreakdown = { + commitChainFee: number; + revealChainFee: number; + revealServiceFee: number; + transferChainFee: number; + transferUtxoValue: number; +}; + +export enum ErrorCode { + UTXOS_MISSING = 'UTXOS_MISSING', + INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS', + INVALID_TICK = 'INVALID_TICK', + INVALID_AMOUNT = 'INVALID_AMOUNT', + INVALID_FEE_RATE = 'INVALID_FEE_RATE', + SERVER_ERROR = 'SERVER_ERROR', +} + +type Props = { + addressUtxos: UTXO[] | undefined; + tick: string; + amount: number; + feeRate: number; + revealAddress: string; +}; + +const validateProps = (props: Props) => { + const { addressUtxos, tick, amount, feeRate } = props; + + if (!addressUtxos) { + return ErrorCode.UTXOS_MISSING; + } + + if (!addressUtxos.length) { + return ErrorCode.INSUFFICIENT_FUNDS; + } + + if (tick.length !== 4) { + return ErrorCode.INVALID_TICK; + } + + if (amount <= 0) { + return ErrorCode.INVALID_AMOUNT; + } + + if (feeRate <= 0) { + return ErrorCode.INVALID_FEE_RATE; + } + + return null; +}; + +/** + * Estimates the fees for a BRC-20 1-step transfer + * @param addressUtxos - The UTXOs in the bitcoin address which will be used for payment + * @param tick - The 4 letter BRC-20 token name + * @param amount - The amount of the BRC-20 token to transfer + * @param feeRate - The desired fee rate for the transactions + * @param revealAddress - The address where the balance of the BRC-20 token lives. This is usually the ordinals address. + */ +const useBrc20TransferFees = (props: Props) => { + const { addressUtxos, tick, amount, feeRate, revealAddress } = props; + const [commitValue, setCommitValue] = useState(); + const [commitValueBreakdown, setCommitValueBreakdown] = useState(); + const [isInitialised, setIsInitialised] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [errorCode, setErrorCode] = useState(); + + useEffect(() => { + const validationErrorCode = validateProps(props); + + if (validationErrorCode) { + setErrorCode(validationErrorCode); + + if (validationErrorCode !== ErrorCode.UTXOS_MISSING) { + setIsInitialised(true); + } + + return; + } + + setIsInitialised(true); + setIsLoading(true); + setErrorCode(undefined); + + const runEstimate = async () => { + try { + const result = await brc20TransferEstimateFees({ + addressUtxos: addressUtxos!, + tick, + amount, + revealAddress, + feeRate, + }); + setCommitValue(result.commitValue); + setCommitValueBreakdown(result.valueBreakdown); + } catch (e) { + if (e.message === 'Not enough funds at selected fee rate') { + setErrorCode(ErrorCode.INSUFFICIENT_FUNDS); + } else { + setErrorCode(ErrorCode.SERVER_ERROR); + } + } + + setIsLoading(false); + }; + + runEstimate(); + }, [addressUtxos, tick, amount, revealAddress, feeRate]); + + return { + commitValue, + commitValueBreakdown, + isLoading, + errorCode: isInitialised ? errorCode : undefined, + isInitialised, + }; +}; + +export default useBrc20TransferFees; diff --git a/hooks/index.ts b/hooks/index.ts new file mode 100644 index 00000000..fda5c674 --- /dev/null +++ b/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './brc20'; +export * from './inscriptions'; diff --git a/hooks/inscriptions/index.ts b/hooks/inscriptions/index.ts new file mode 100644 index 00000000..661f4243 --- /dev/null +++ b/hooks/inscriptions/index.ts @@ -0,0 +1,2 @@ +export { default as useInscriptionExecute } from './useInscriptionExecute'; +export { default as useInscriptionFees } from './useInscriptionFees'; diff --git a/hooks/inscriptions/useInscriptionExecute.ts b/hooks/inscriptions/useInscriptionExecute.ts new file mode 100644 index 00000000..9c12f8b9 --- /dev/null +++ b/hooks/inscriptions/useInscriptionExecute.ts @@ -0,0 +1,103 @@ +import { useCallback, useState } from 'react'; + +import { NetworkType, UTXO } from 'types'; +import { CoreError } from '../../utils/coreError'; + +import { InscriptionErrorCode, inscriptionMintExecute } from '../../transactions/inscriptionMint'; + +type Props = { + seedPhrase: string; + accountIndex: number; + addressUtxos: UTXO[]; + revealAddress: string; + changeAddress: string; + contentString?: string; + contentBase64?: string; + contentType: string; + feeRate: number; + network: NetworkType; +}; + +const useInscriptionExecute = (props: Props) => { + const { + seedPhrase, + accountIndex, + addressUtxos, + contentType, + contentBase64, + contentString, + revealAddress, + changeAddress, + feeRate, + network, + } = props; + const [running, setRunning] = useState(false); + const [revealTransactionId, setRevealTransactionId] = useState(); + const [errorCode, setErrorCode] = useState(); + const [errorMessage, setErrorMessage] = useState(); + + const executeMint = useCallback(() => { + if (running || !!revealTransactionId) return; + + const innerProps = { + seedPhrase, + accountIndex, + addressUtxos, + revealAddress, + changeAddress, + contentType, + contentBase64, + contentString, + feeRate, + network, + }; + + // if we get to here, that means that the transfer is valid and we can try to execute it but we don't want to + // be able to accidentally execute it again if something goes wrong, so we set the running flag + setRunning(true); + + const runTransfer = async () => { + try { + const mintResult = await inscriptionMintExecute(innerProps); + + setRevealTransactionId(mintResult); + } catch (e) { + if (CoreError.isCoreError(e) && (e.code ?? '') in InscriptionErrorCode) { + setErrorCode(e.code as InscriptionErrorCode); + } else { + setErrorCode(InscriptionErrorCode.SERVER_ERROR); + } + + setErrorMessage(e.message); + } finally { + setRunning(false); + } + }; + + runTransfer(); + }, [ + seedPhrase, + accountIndex, + addressUtxos, + contentType, + contentBase64, + contentString, + revealAddress, + changeAddress, + feeRate, + network, + running, + revealTransactionId, + ]); + + return { + isExecuting: running, + executeMint, + revealTransactionId, + complete: !!revealTransactionId, + errorCode, + errorMessage, + }; +}; + +export default useInscriptionExecute; diff --git a/hooks/inscriptions/useInscriptionFees.ts b/hooks/inscriptions/useInscriptionFees.ts new file mode 100644 index 00000000..38effde2 --- /dev/null +++ b/hooks/inscriptions/useInscriptionFees.ts @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; + +import { UTXO } from 'types'; +import { InscriptionErrorCode, inscriptionMintFeeEstimate } from '../../transactions/inscriptionMint'; +import { CoreError } from '../../utils/coreError'; + +type CommitValueBreakdown = { + commitChainFee: number; + revealChainFee: number; + revealServiceFee: number; + externalServiceFee?: number; +}; + +type Props = { + addressUtxos: UTXO[] | undefined; + content: string; + contentType: string; + feeRate: number; + revealAddress: string; + finalInscriptionValue?: number; + serviceFee?: number; + serviceFeeAddress?: string; +}; + +const DUMMY_UTXO = { + address: '', + txid: '1234567890123456789012345678901234567890123456789012345678901234', + vout: 0, + status: { confirmed: true }, + value: 100e8, +}; + +/** + * Estimates the fees for a BRC-20 1-step transfer + * @param addressUtxos - The UTXOs in the bitcoin address which will be used for payment + * @param content - The content of the inscription + * @param contentType - The contentType of the inscription + * @param feeRate - The desired fee rate for the transactions + * @param revealAddress - The address where the balance of the BRC-20 token lives. This is usually the ordinals address. + */ +const useInscriptionFees = (props: Props) => { + const { + addressUtxos, + content, + contentType, + feeRate, + revealAddress, + finalInscriptionValue, + serviceFee, + serviceFeeAddress, + } = props; + + const [commitValue, setCommitValue] = useState(); + const [commitValueBreakdown, setCommitValueBreakdown] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [errorCode, setErrorCode] = useState(); + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + setIsLoading(true); + setErrorCode(undefined); + setErrorMessage(undefined); + + const runEstimate = async () => { + try { + const result = await inscriptionMintFeeEstimate({ + addressUtxos: addressUtxos || [DUMMY_UTXO], + content, + contentType, + revealAddress, + feeRate, + finalInscriptionValue, + serviceFee, + serviceFeeAddress, + }); + setCommitValue(result.commitValue); + setCommitValueBreakdown(result.valueBreakdown); + } catch (e) { + if (CoreError.isCoreError(e) && (e.code ?? '') in InscriptionErrorCode) { + setErrorCode(e.code as InscriptionErrorCode); + + // if there are not enough funds, we get the fee again with a fictitious UTXO to show what the fee would be + if (e.code === InscriptionErrorCode.INSUFFICIENT_FUNDS) { + const result = await inscriptionMintFeeEstimate({ + addressUtxos: [DUMMY_UTXO], + content, + contentType, + revealAddress, + feeRate, + finalInscriptionValue, + serviceFee, + serviceFeeAddress, + }); + setCommitValue(result.commitValue); + setCommitValueBreakdown(result.valueBreakdown); + } + } else { + setErrorCode(InscriptionErrorCode.SERVER_ERROR); + } + + setErrorMessage(e.message); + } + + setIsLoading(false); + }; + + runEstimate(); + }, [ + addressUtxos, + content, + contentType, + serviceFee, + serviceFeeAddress, + finalInscriptionValue, + revealAddress, + feeRate, + ]); + + return { + commitValue, + commitValueBreakdown, + isLoading, + errorCode, + errorMessage, + }; +}; + +export default useInscriptionFees; diff --git a/index.ts b/index.ts index b82dea86..61411e1f 100644 --- a/index.ts +++ b/index.ts @@ -1,10 +1,11 @@ -export * from './wallet'; -export * from './types'; -export * from './transactions'; -export * from './currency'; +export * from './account'; export * from './api'; +export * from './coins'; export * from './connect'; -export * from './account'; +export * from './currency'; export * from './gaia'; +export * from './hooks'; export * from './ledger'; -export * from './coins'; +export * from './transactions'; +export * from './types'; +export * from './wallet'; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 7eee8c36..00000000 --- a/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testPathIgnorePatterns: ['dist'], -}; diff --git a/package-lock.json b/package-lock.json index e48a014e..bf7b301c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "@zondax/ledger-stacks": "^1.0.4", "axios": "0.27.2", "base64url": "^3.0.1", - "bignumber.js": "9.1.1", "bip32": "^4.0.0", "bip39": "3.0.3", "bitcoin-address-validation": "^2.2.1", @@ -42,6 +41,7 @@ "varuint-bitcoin": "^1.1.2" }, "devDependencies": { + "@types/react": "^18.2.18", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", "@vitest/coverage-c8": "^0.31.1", @@ -61,6 +61,10 @@ "vitest": "^0.31.1", "webpack": "^5.74.0", "webpack-cli": "^4.10.0" + }, + "peerDependencies": { + "bignumber.js": "^9.0.0", + "react": ">18.0.0" } }, "node_modules/@ampproject/remapping": { @@ -1301,6 +1305,29 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz", "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==" }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.18.tgz", + "integrity": "sha512-da4NTSeBv/P34xoZPhtcLkmZuJ+oYaCxHmyHzwaDQo9RQPBeXV+06gEk2FpqEcsX9XrnNLvRpVh6bdavDSjtiQ==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -2197,6 +2224,7 @@ "version": "9.1.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==", + "peer": true, "engines": { "node": "*" } @@ -2962,6 +2990,12 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, "node_modules/date-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz", @@ -4836,6 +4870,12 @@ "node": ">= 0.8" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -5238,6 +5278,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", @@ -6008,6 +6060,18 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index a86efc88..808a9217 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "@zondax/ledger-stacks": "^1.0.4", "axios": "0.27.2", "base64url": "^3.0.1", - "bignumber.js": "9.1.1", "bip32": "^4.0.0", "bip39": "3.0.3", "bitcoin-address-validation": "^2.2.1", @@ -63,6 +62,7 @@ "author": "", "license": "ISC", "devDependencies": { + "@types/react": "^18.2.18", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", "@vitest/coverage-c8": "^0.31.1", @@ -82,5 +82,9 @@ "vitest": "^0.31.1", "webpack": "^5.74.0", "webpack-cli": "^4.10.0" + }, + "peerDependencies": { + "bignumber.js": "^9.0.0", + "react": ">18.0.0" } } diff --git a/tests/testExtensions.ts b/tests/testExtensions.ts new file mode 100644 index 00000000..7dd7fb11 --- /dev/null +++ b/tests/testExtensions.ts @@ -0,0 +1,41 @@ +import { expect } from 'vitest'; + +import { CoreError } from '../utils/coreError'; + +expect.extend({ + toThrowCoreError: (received: unknown, message: string, code: string) => { + if (!(received instanceof Error)) { + return { + message: () => `Expected function to throw CoreError`, + pass: false, + }; + } + if (!CoreError.isCoreError(received)) { + return { + message: () => `Expected function to throw CoreError`, + pass: false, + }; + } + if (received.message !== message) { + return { + message: () => 'Expected different CoreError message', + pass: false, + expected: message, + received: received.message, + }; + } + if (received.code !== code) { + return { + message: () => 'Expected different CoreError code', + pass: false, + expected: code, + received: received.code, + }; + } + + return { + message: () => `Expected error matches`, + pass: true, + }; + }, +}); diff --git a/tests/transactions/brc20.test.ts b/tests/transactions/brc20.test.ts index 618ba8d4..a9ad0ce6 100644 --- a/tests/transactions/brc20.test.ts +++ b/tests/transactions/brc20.test.ts @@ -96,6 +96,7 @@ describe('brc20MintExecute', () => { const mockedAddressUtxos: UTXO[] = []; const mockedTick = 'TICK'; const mockedAmount = 10; + const mockedCommitAddress = 'commit_address'; const mockedRevealAddress = 'reveal_address'; const mockedChangeAddress = 'change_address'; const mockedFeeRate = 12; @@ -105,7 +106,7 @@ describe('brc20MintExecute', () => { vi.mocked(getBtcPrivateKey).mockResolvedValueOnce('private_key'); vi.mocked(xverseInscribeApi.createBrc20MintOrder).mockResolvedValue({ - commitAddress: 'commit_address', + commitAddress: mockedCommitAddress, commitValue: 1000, } as any); @@ -157,7 +158,7 @@ describe('brc20MintExecute', () => { expect(selectUtxosForSend).toHaveBeenCalledWith({ changeAddress: 'change_address', - recipients: [{ address: mockedRevealAddress, amountSats: new BigNumber(1000) }], + recipients: [{ address: mockedCommitAddress, amountSats: new BigNumber(1000) }], availableUtxos: mockedAddressUtxos, feeRate: mockedFeeRate, }); @@ -168,7 +169,7 @@ describe('brc20MintExecute', () => { new BigNumber(1000), [ { - address: 'commit_address', + address: mockedCommitAddress, amountSats: new BigNumber(1000), }, ], @@ -177,7 +178,7 @@ describe('brc20MintExecute', () => { 'Mainnet', ); - expect(xverseInscribeApi.executeBrc20Order).toHaveBeenCalledWith('commit_address', 'commit_hex'); + expect(xverseInscribeApi.executeBrc20Order).toHaveBeenCalledWith(mockedCommitAddress, 'commit_hex'); }); }); @@ -260,6 +261,7 @@ describe('brc20TransferExecute', () => { const mockedTick = 'TICK'; const mockedAmount = 10; const mockedRevealAddress = 'reveal_address'; + const mockedCommitAddress = 'commit_address'; const mockedChangeAddress = 'change_address'; const mockedRecipientAddress = 'recipient_address'; const mockedFeeRate = 12; @@ -273,7 +275,7 @@ describe('brc20TransferExecute', () => { } as any); vi.mocked(xverseInscribeApi.createBrc20TransferOrder).mockResolvedValueOnce({ - commitAddress: 'commit_address', + commitAddress: mockedCommitAddress, commitValue: 1000, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we only use these 2 fields in this function } as any); @@ -373,7 +375,7 @@ describe('brc20TransferExecute', () => { case ExecuteTransferProgressCodes.ExecutingInscriptionOrder: expect(selectUtxosForSend).toHaveBeenCalledWith({ changeAddress: mockedChangeAddress, - recipients: [{ address: mockedRevealAddress, amountSats: new BigNumber(1000) }], + recipients: [{ address: mockedCommitAddress, amountSats: new BigNumber(1000) }], availableUtxos: mockedAddressUtxos, feeRate: mockedFeeRate, }); @@ -384,7 +386,7 @@ describe('brc20TransferExecute', () => { new BigNumber(1000), [ { - address: 'commit_address', + address: mockedCommitAddress, amountSats: new BigNumber(1000), }, ], @@ -395,7 +397,7 @@ describe('brc20TransferExecute', () => { break; case ExecuteTransferProgressCodes.CreatingTransferTransaction: - expect(xverseInscribeApi.executeBrc20Order).toHaveBeenCalledWith('commit_address', 'commit_hex', true); + expect(xverseInscribeApi.executeBrc20Order).toHaveBeenCalledWith(mockedCommitAddress, 'commit_hex', true); break; case ExecuteTransferProgressCodes.Finalizing: diff --git a/tests/transactions/inscriptionMint.test.ts b/tests/transactions/inscriptionMint.test.ts new file mode 100644 index 00000000..9e45b461 --- /dev/null +++ b/tests/transactions/inscriptionMint.test.ts @@ -0,0 +1,479 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getOrdinalIdsFromUtxo } from '../../api/ordinals'; +import xverseInscribeApi from '../../api/xverseInscribe'; +import { generateSignedBtcTransaction, selectUtxosForSend } from '../../transactions/btc'; +import { + InscriptionErrorCode, + inscriptionMintExecute, + inscriptionMintFeeEstimate, +} from '../../transactions/inscriptionMint'; +import { getBtcPrivateKey } from '../../wallet'; + +vi.mock('../../api/xverseInscribe'); +vi.mock('../../api/ordinals'); +vi.mock('../../transactions/btc'); +vi.mock('../../wallet'); + +describe('inscriptionMintFeeEstimate', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('calculates fee estimate correctly', async () => { + vi.mocked(xverseInscribeApi.getInscriptionFeeEstimate).mockResolvedValue({ + chainFee: 1000, + inscriptionValue: 546, + serviceFee: 2000, + vSize: 100, + }); + vi.mocked(selectUtxosForSend).mockReturnValue({ + fee: 1100, + change: 1200, + feeRate: 8, + selectedUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + }); + + const content = 'a'.repeat(400000); + + const result = await inscriptionMintFeeEstimate({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + content, + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + serviceFee: 5000, + serviceFeeAddress: 'dummyServiceFeeAddress', + }); + + expect(result.commitValue).toEqual(10100); + expect(result.valueBreakdown).toEqual({ + commitChainFee: 1100, + externalServiceFee: 5000, + inscriptionValue: 1000, + revealChainFee: 1000, + revealServiceFee: 2000, + }); + }); + + it('calculates fee estimate correctly', async () => { + vi.mocked(xverseInscribeApi.getInscriptionFeeEstimate).mockResolvedValue({ + chainFee: 1000, + inscriptionValue: 546, + serviceFee: 2000, + vSize: 100, + }); + vi.mocked(selectUtxosForSend).mockReturnValue(undefined); + + const content = 'a'.repeat(400000); + + await expect(() => + inscriptionMintFeeEstimate({ + addressUtxos: [], + content, + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + serviceFee: 5000, + serviceFeeAddress: 'dummyServiceFeeAddress', + }), + ).rejects.toThrowCoreError('Not enough funds at selected fee rate', InscriptionErrorCode.INSUFFICIENT_FUNDS); + }); + + it.each([ + { + serviceFeeAddress: 'dummyServiceFeeAddress', + }, + { + serviceFee: 5000, + }, + { + serviceFee: 500, + serviceFeeAddress: 'dummyServiceFeeAddress', + }, + ])('should fail on invalid service fee config: %s', async (config) => { + await expect(() => + inscriptionMintFeeEstimate({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + content: 'dummyContent', + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + ...config, + }), + ).rejects.toThrowCoreError( + 'Invalid service fee config, both serviceFee and serviceFeeAddress must be specified', + InscriptionErrorCode.INVALID_SERVICE_FEE_CONFIG, + ); + }); + + it('should fail on invalid fee rate', async () => { + await expect(() => + inscriptionMintFeeEstimate({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + content: 'dummyContent', + contentType: 'text/plain', + feeRate: -1, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + }), + ).rejects.toThrowCoreError('Fee rate should be a positive number', InscriptionErrorCode.INVALID_FEE_RATE); + }); + + it('should fail on big content', async () => { + const content = 'a'.repeat(400001); + + await expect(() => + inscriptionMintFeeEstimate({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + content: content, + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + }), + ).rejects.toThrowCoreError('Content exceeds maximum size of 400000 bytes', InscriptionErrorCode.CONTENT_TOO_BIG); + }); + + it('should fail on low inscription value', async () => { + await expect(() => + inscriptionMintFeeEstimate({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + content: 'dummyContent', + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 500, + }), + ).rejects.toThrowCoreError( + 'Inscription value cannot be less than 546', + InscriptionErrorCode.INSCRIPTION_VALUE_TOO_LOW, + ); + }); +}); + +describe('inscriptionMintExecute', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('creates inscription order and executes correctly', async () => { + vi.mocked(getBtcPrivateKey).mockResolvedValue('dummyPrivateKey'); + vi.mocked(xverseInscribeApi.createInscriptionOrder).mockResolvedValue({ + commitAddress: 'dummyCommitAddress', + commitValue: 1000, + commitValueBreakdown: { + chainFee: 1000, + inscriptionValue: 546, + serviceFee: 2000, + }, + }); + vi.mocked(selectUtxosForSend).mockReturnValue({ + fee: 1100, + change: 1200, + feeRate: 8, + selectedUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + }); + vi.mocked(generateSignedBtcTransaction).mockResolvedValue({ + hex: 'dummyHex', + } as any); + vi.mocked(xverseInscribeApi.executeInscriptionOrder).mockResolvedValue({ + revealTransactionId: 'revealTxnId', + } as any); + vi.mocked(getOrdinalIdsFromUtxo).mockResolvedValue([]); + + const result = await inscriptionMintExecute({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + contentString: 'dummyContent', + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + serviceFee: 5000, + serviceFeeAddress: 'dummyServiceFeeAddress', + accountIndex: 0, + changeAddress: 'dummyChangeAddress', + network: 'Mainnet', + seedPhrase: 'dummySeedPhrase', + }); + + expect(result).toBe('revealTxnId'); + }); + + it('fails on no non-ordinal UTXOS', async () => { + vi.mocked(getBtcPrivateKey).mockResolvedValue('dummyPrivateKey'); + vi.mocked(xverseInscribeApi.createInscriptionOrder).mockResolvedValue({ + commitAddress: 'dummyCommitAddress', + commitValue: 1000, + commitValueBreakdown: { + chainFee: 1000, + inscriptionValue: 546, + serviceFee: 2000, + }, + }); + vi.mocked(selectUtxosForSend).mockReturnValue({ + fee: 1100, + change: 1200, + feeRate: 8, + selectedUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + }); + vi.mocked(generateSignedBtcTransaction).mockResolvedValue({ + hex: 'dummyHex', + } as any); + vi.mocked(xverseInscribeApi.executeInscriptionOrder).mockResolvedValue({ + revealTransactionId: 'revealTxnId', + } as any); + vi.mocked(getOrdinalIdsFromUtxo).mockResolvedValue(['ordinalId']); + + await expect(() => + inscriptionMintExecute({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + contentString: 'dummyContent', + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + serviceFee: 5000, + serviceFeeAddress: 'dummyServiceFeeAddress', + accountIndex: 0, + changeAddress: 'dummyChangeAddress', + network: 'Mainnet', + seedPhrase: 'dummySeedPhrase', + }), + ).rejects.toThrowCoreError( + 'Must have at least one non-inscribed UTXO for inscription', + InscriptionErrorCode.NO_NON_ORDINAL_UTXOS, + ); + }); + + it('should fail on no UTXOs', async () => { + await expect(() => + inscriptionMintExecute({ + addressUtxos: [], + contentString: 'dummyContent', + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + serviceFee: 5000, + serviceFeeAddress: 'dummyServiceFeeAddress', + accountIndex: 0, + changeAddress: 'dummyChangeAddress', + network: 'Mainnet', + seedPhrase: 'dummySeedPhrase', + }), + ).rejects.toThrowCoreError('No available UTXOs', InscriptionErrorCode.INSUFFICIENT_FUNDS); + }); + + it('should fail on low fee rate', async () => { + await expect(() => + inscriptionMintExecute({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + contentString: 'dummyContent', + contentType: 'text/plain', + feeRate: 0, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + serviceFee: 5000, + serviceFeeAddress: 'dummyServiceFeeAddress', + accountIndex: 0, + changeAddress: 'dummyChangeAddress', + network: 'Mainnet', + seedPhrase: 'dummySeedPhrase', + }), + ).rejects.toThrowCoreError('Fee rate should be a positive number', InscriptionErrorCode.INVALID_FEE_RATE); + }); + + it.each([ + { + serviceFeeAddress: 'dummyServiceFeeAddress', + }, + { + serviceFee: 5000, + }, + { + serviceFee: 500, + serviceFeeAddress: 'dummyServiceFeeAddress', + }, + ])('should fail on invalid service fee config: %s', async (config) => { + await expect(() => + inscriptionMintExecute({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + contentString: 'dummyContent', + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + accountIndex: 0, + changeAddress: 'dummyChangeAddress', + network: 'Mainnet', + seedPhrase: 'dummySeedPhrase', + ...config, + }), + ).rejects.toThrowCoreError( + 'Invalid service fee config, both serviceFee and serviceFeeAddress must be specified', + InscriptionErrorCode.INVALID_SERVICE_FEE_CONFIG, + ); + }); + + it.each([ + { + contentString: 'dummyContent', + contentBase64: 'dummyContent', + }, + { + contentBase64: '', + }, + {}, + ])('should fail on invalid content: %s', async (config) => { + await expect(() => + inscriptionMintExecute({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + accountIndex: 0, + changeAddress: 'dummyChangeAddress', + network: 'Mainnet', + seedPhrase: 'dummySeedPhrase', + ...config, + }), + ).rejects.toThrowCoreError( + 'Only contentString or contentBase64 can be specified, not both or neither, and should have content', + InscriptionErrorCode.INVALID_CONTENT, + ); + }); + + it('should fail on large content', async () => { + await expect(() => + inscriptionMintExecute({ + addressUtxos: [ + { + address: 'dummyAddress', + status: { confirmed: true }, + txid: 'dummyTxId', + vout: 0, + value: 1000, + }, + ], + contentString: 'a'.repeat(400001), + contentType: 'text/plain', + feeRate: 8, + revealAddress: 'dummyRevealAddress', + finalInscriptionValue: 1000, + accountIndex: 0, + changeAddress: 'dummyChangeAddress', + network: 'Mainnet', + seedPhrase: 'dummySeedPhrase', + }), + ).rejects.toThrowCoreError(`Content exceeds maximum size of 400000 bytes`, InscriptionErrorCode.CONTENT_TOO_BIG); + }); +}); diff --git a/tests/vitest.d.ts b/tests/vitest.d.ts new file mode 100644 index 00000000..bc4fa1fc --- /dev/null +++ b/tests/vitest.d.ts @@ -0,0 +1,11 @@ +import 'vitest'; + +interface CustomMatchers { + toBeFoo(): R; +} + +declare module 'vitest' { + interface JestAssertion extends jest.Matchers { + toThrowCoreError: (error: string, code?: string) => void; + } +} diff --git a/transactions/brc20.ts b/transactions/brc20.ts index bf9b2609..2e0f5b36 100644 --- a/transactions/brc20.ts +++ b/transactions/brc20.ts @@ -1,15 +1,22 @@ 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 { CoreError } from '../utils/coreError'; import { getBtcPrivateKey } from '../wallet'; import { generateSignedBtcTransaction, selectUtxosForSend, signNonOrdinalBtcSendTransaction } from './btc'; // This is the value of the inscription output, which the final recipient of the inscription will receive. const FINAL_SATS_VALUE = 1000; +export enum BRC20ErrorCode { + INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS', + FAILED_TO_FINALIZE = 'FAILED_TO_FINALIZE', +} + type EstimateProps = { addressUtxos: UTXO[]; tick: string; @@ -106,7 +113,7 @@ export const brc20MintEstimateFees = async (estimateProps: EstimateProps): Promi }); if (!bestUtxoData) { - throw new Error('Not enough funds at selected fee rate'); + throw new CoreError('Not enough funds at selected fee rate', BRC20ErrorCode.INSUFFICIENT_FUNDS); } const commitChainFees = bestUtxoData.fee; @@ -143,13 +150,13 @@ export async function brc20MintExecute(executeProps: ExecuteProps): Promise setTimeout(resolve, 500)); - - const MAX_RETRIES = 5; - let error: Error | undefined; + try { + const response = await xverseInscribeApi.finalizeBrc20TransferOrder(commitAddress, transferTransaction.signedTx); - 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))); + return response; + } catch (error) { + throw new CoreError('Failed to finalize order', BRC20ErrorCode.FAILED_TO_FINALIZE, error); } - - throw error ?? new Error('Failed to broadcast transfer transaction'); } diff --git a/transactions/index.ts b/transactions/index.ts index fa040bdd..ad4a2fc1 100644 --- a/transactions/index.ts +++ b/transactions/index.ts @@ -31,6 +31,7 @@ import { brc20TransferExecute, createBrc20TransferOrder, } from './brc20'; +import { InscriptionErrorCode, inscriptionMintExecute, inscriptionMintFeeEstimate } from './inscriptionMint'; import type { PSBTInput, PSBTOutput, ParsedPSBT } from './psbt'; import { parsePsbt } from './psbt'; import { @@ -41,6 +42,7 @@ import { export { ExecuteTransferProgressCodes, + InscriptionErrorCode, addressToString, brc20TransferEstimateFees, brc20TransferExecute, @@ -64,6 +66,8 @@ export { getNewNonce, getNonce, hexStringToBuffer, + inscriptionMintExecute, + inscriptionMintFeeEstimate, parsePsbt, setFee, setNonce, diff --git a/transactions/inscriptionMint.ts b/transactions/inscriptionMint.ts new file mode 100644 index 00000000..59ac7297 --- /dev/null +++ b/transactions/inscriptionMint.ts @@ -0,0 +1,281 @@ +import BigNumber from 'bignumber.js'; + +import { NetworkType, UTXO } from 'types'; +import { getOrdinalIdsFromUtxo } from '../api/ordinals'; +import xverseInscribeApi from '../api/xverseInscribe'; +import { CoreError } from '../utils/coreError'; +import { getBtcPrivateKey } from '../wallet'; +import { generateSignedBtcTransaction, selectUtxosForSend } from './btc'; + +const MINIMUM_INSCRIPTION_VALUE = 546; +const MAX_CONTENT_LENGTH = 400e3; // 400kb is the max that miners will mine + +export enum InscriptionErrorCode { + INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS', + INVALID_FEE_RATE = 'INVALID_FEE_RATE', + INVALID_SERVICE_FEE_CONFIG = 'INVALID_SERVICE_FEE_CONFIG', + INVALID_CONTENT = 'INVALID_CONTENT', + CONTENT_TOO_BIG = 'CONTENT_TOO_BIG', + INSCRIPTION_VALUE_TOO_LOW = 'INSCRIPTION_VALUE_TOO_LOW', + NO_NON_ORDINAL_UTXOS = 'NO_NON_ORDINAL_UTXOS', + FAILED_TO_FINALIZE = 'FAILED_TO_FINALIZE', + SERVER_ERROR = 'SERVER_ERROR', +} + +type EstimateProps = { + addressUtxos: UTXO[]; + content: string; + contentType: string; + revealAddress: string; + feeRate: number; + finalInscriptionValue?: number; + serviceFee?: number; + serviceFeeAddress?: string; +}; + +type BaseEstimateResult = { + commitValue: number; + valueBreakdown: { + commitChainFee: number; + revealChainFee: number; + revealServiceFee: number; + externalServiceFee?: number; + }; +}; + +type EstimateResult = BaseEstimateResult & { + valueBreakdown: { + inscriptionValue: number; + }; +}; + +type ExecuteProps = { + seedPhrase: string; + accountIndex: number; + changeAddress: string; + network: NetworkType; + addressUtxos: UTXO[]; + contentString?: string; + contentBase64?: string; + contentType: string; + revealAddress: string; + feeRate: number; + finalInscriptionValue?: number; + serviceFee?: number; + serviceFeeAddress?: string; +}; + +export async function inscriptionMintFeeEstimate(estimateProps: EstimateProps): Promise { + const { + addressUtxos, + content, + contentType, + revealAddress, + feeRate, + finalInscriptionValue, + serviceFee, + serviceFeeAddress, + } = estimateProps; + + // a service fee of below 546 will result in a dust UTXO + if (((serviceFee || serviceFeeAddress) && !(serviceFee && serviceFeeAddress)) || (serviceFee && serviceFee < 546)) { + throw new CoreError( + 'Invalid service fee config, both serviceFee and serviceFeeAddress must be specified', + InscriptionErrorCode.INVALID_SERVICE_FEE_CONFIG, + ); + } + + if (feeRate <= 0) { + throw new CoreError('Fee rate should be a positive number', InscriptionErrorCode.INVALID_FEE_RATE); + } + + if (content.length > MAX_CONTENT_LENGTH) { + throw new CoreError( + `Content exceeds maximum size of ${MAX_CONTENT_LENGTH} bytes`, + InscriptionErrorCode.CONTENT_TOO_BIG, + ); + } + + const dummyAddress = 'bc1pgkwmp9u9nel8c36a2t7jwkpq0hmlhmm8gm00kpdxdy864ew2l6zqw2l6vh'; + + const inscriptionValue = finalInscriptionValue ?? MINIMUM_INSCRIPTION_VALUE; + + if (inscriptionValue < MINIMUM_INSCRIPTION_VALUE) { + throw new CoreError( + `Inscription value cannot be less than ${MINIMUM_INSCRIPTION_VALUE}`, + InscriptionErrorCode.INSCRIPTION_VALUE_TOO_LOW, + ); + } + + const { chainFee: revealChainFee, serviceFee: revealServiceFee } = await xverseInscribeApi.getInscriptionFeeEstimate({ + contentLength: content.length, + contentType, + revealAddress, + feeRate, + inscriptionValue, + }); + + const commitValue = new BigNumber(inscriptionValue).plus(revealChainFee).plus(revealServiceFee); + + const recipients = [{ address: revealAddress, amountSats: new BigNumber(commitValue) }]; + + if (serviceFee && serviceFeeAddress) { + recipients.push({ + address: serviceFeeAddress, + amountSats: new BigNumber(serviceFee), + }); + } + + const bestUtxoData = selectUtxosForSend({ + changeAddress: dummyAddress, + recipients, + availableUtxos: addressUtxos, + feeRate, + }); + + if (!bestUtxoData) { + throw new CoreError('Not enough funds at selected fee rate', InscriptionErrorCode.INSUFFICIENT_FUNDS); + } + + const commitChainFees = bestUtxoData.fee; + + return { + commitValue: commitValue + .plus(commitChainFees) + .plus(serviceFee ?? 0) + .toNumber(), + valueBreakdown: { + commitChainFee: commitChainFees, + revealChainFee, + revealServiceFee, + inscriptionValue, + externalServiceFee: serviceFee, + }, + }; +} + +export async function inscriptionMintExecute(executeProps: ExecuteProps): Promise { + const { + seedPhrase, + accountIndex, + addressUtxos, + changeAddress, + contentString, + contentBase64, + contentType, + revealAddress, + feeRate, + network, + serviceFee, + serviceFeeAddress, + finalInscriptionValue, + } = executeProps; + + if (!addressUtxos.length) { + throw new CoreError('No available UTXOs', InscriptionErrorCode.INSUFFICIENT_FUNDS); + } + + if (feeRate <= 0) { + throw new CoreError('Fee rate should be a positive number', InscriptionErrorCode.INVALID_FEE_RATE); + } + + if (((serviceFee || serviceFeeAddress) && !(serviceFee && serviceFeeAddress)) || (serviceFee && serviceFee < 546)) { + throw new CoreError( + 'Invalid service fee config, both serviceFee and serviceFeeAddress must be specified', + InscriptionErrorCode.INVALID_SERVICE_FEE_CONFIG, + ); + } + + const content = contentString ?? contentBase64; + + if (!content || (contentString && contentBase64) || content.length === 0) { + throw new CoreError( + 'Only contentString or contentBase64 can be specified, not both or neither, and should have content', + InscriptionErrorCode.INVALID_CONTENT, + ); + } + + if (content.length > MAX_CONTENT_LENGTH) { + throw new CoreError( + `Content exceeds maximum size of ${MAX_CONTENT_LENGTH} bytes`, + InscriptionErrorCode.CONTENT_TOO_BIG, + ); + } + + const inscriptionValue = finalInscriptionValue ?? MINIMUM_INSCRIPTION_VALUE; + + const privateKey = await getBtcPrivateKey({ + seedPhrase, + index: BigInt(accountIndex), + network: 'Mainnet', + }); + + const contentField = contentBase64 ? { contentBase64 } : { contentString: contentString as string }; + + const { commitAddress, commitValue } = await xverseInscribeApi.createInscriptionOrder({ + ...contentField, + contentType, + feeRate, + network, + revealAddress, + inscriptionValue, + }); + + const recipients = [{ address: commitAddress, amountSats: new BigNumber(commitValue) }]; + + if (serviceFee && serviceFeeAddress) { + recipients.push({ + address: serviceFeeAddress, + amountSats: new BigNumber(serviceFee), + }); + } + + const bestUtxoData = selectUtxosForSend({ + changeAddress, + recipients, + availableUtxos: addressUtxos, + feeRate, + }); + + if (!bestUtxoData) { + throw new CoreError('Not enough funds at selected fee rate', InscriptionErrorCode.INSUFFICIENT_FUNDS); + } + + const selectedOrdinalUtxos = []; + const selectedNonOrdinalUtxos = []; + + for (const utxo of bestUtxoData.selectedUtxos) { + const ordinalIds = await getOrdinalIdsFromUtxo(utxo); + if (ordinalIds.length > 0) { + selectedOrdinalUtxos.push(utxo); + } else { + selectedNonOrdinalUtxos.push(utxo); + } + } + + if (selectedNonOrdinalUtxos.length === 0) { + throw new CoreError( + 'Must have at least one non-inscribed UTXO for inscription', + InscriptionErrorCode.NO_NON_ORDINAL_UTXOS, + ); + } + + const commitChainFees = bestUtxoData.fee; + + const commitTransaction = await generateSignedBtcTransaction( + privateKey, + [...selectedNonOrdinalUtxos, ...selectedOrdinalUtxos], + new BigNumber(commitValue), + recipients, + changeAddress, + new BigNumber(commitChainFees), + network, + ); + + const { revealTransactionId } = await xverseInscribeApi.executeInscriptionOrder({ + commitAddress, + commitTransactionHex: commitTransaction.hex, + }); + + return revealTransactionId; +} diff --git a/types/api/xverseInscribe/index.ts b/types/api/xverseInscribe/index.ts index e28c487e..426428b4 100644 --- a/types/api/xverseInscribe/index.ts +++ b/types/api/xverseInscribe/index.ts @@ -1 +1,2 @@ export * from './brc20'; +export * from './inscription'; diff --git a/types/api/xverseInscribe/inscription.ts b/types/api/xverseInscribe/inscription.ts new file mode 100644 index 00000000..34d8a9ec --- /dev/null +++ b/types/api/xverseInscribe/inscription.ts @@ -0,0 +1,55 @@ +import { NetworkType } from 'types/network'; + +export type InscriptionCostEstimateRequest = { + revealAddress: string; + feeRate: number; + inscriptionValue: number; + contentLength: number; + contentType: string; +}; + +export type InscriptionCostEstimateResponse = { + inscriptionValue: number; + chainFee: number; + serviceFee: number; + vSize: number; +}; + +type InscriptionCreateOrderBaseRequest = { + feeRate: number; + revealAddress: string; + network: NetworkType; + inscriptionValue?: number; + contentType: string; +}; + +type InscriptionCreateTextOrderRequest = InscriptionCreateOrderBaseRequest & { + contentString: string; +}; + +type InscriptionCreateBinaryOrderRequest = InscriptionCreateOrderBaseRequest & { + contentBase64: string; +}; + +export type InscriptionCreateOrderRequest = InscriptionCreateTextOrderRequest | InscriptionCreateBinaryOrderRequest; + +export type InscriptionCreateOrderResponse = { + commitAddress: string; + commitValue: number; + commitValueBreakdown: { + inscriptionValue: number; + chainFee: number; + serviceFee: number; + }; +}; + +export type InscriptionExecuteOrderRequest = { + commitAddress: string; + commitTransactionHex: string; +}; + +export type InscriptionExecuteOrderResponse = { + revealTransactionId: string; + revealUTXOVOut: number; + revealUTXOValue: number; +}; diff --git a/utils/coreError.ts b/utils/coreError.ts new file mode 100644 index 00000000..aa5b16a8 --- /dev/null +++ b/utils/coreError.ts @@ -0,0 +1,19 @@ +class CoreError extends Error { + code?: string; + + constructor(message: string, code?: string, causedBy?: Error) { + super(message); + this.name = 'CoreError'; + this.code = code; + + if (causedBy && causedBy.stack) { + this.stack += `\n\nCaused by: \n${causedBy.stack}`; + } + } + + static isCoreError(e: Error): e is CoreError { + return e.name === 'CoreError'; + } +} + +export { CoreError }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..24b8c67c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +// +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + setupFiles: ['./tests/testExtensions.ts'], + }, +});