From 5178debff9c5a374dedb1afa8d8569915108b8d4 Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Fri, 23 Aug 2024 18:48:02 +0100 Subject: [PATCH 1/4] feat: create psbt in start order Signed-off-by: Gregory Hill --- sdk/examples/gateway.ts | 49 +++++++--------------------------- sdk/src/gateway/client.ts | 26 ++++++++++++++++-- sdk/src/gateway/types.ts | 2 ++ sdk/src/wallet/address.ts | 18 ------------- sdk/src/wallet/index.ts | 5 ++-- sdk/src/wallet/utxo.ts | 55 +++++++++++++++++++++++++++------------ sdk/test/utxo.test.ts | 14 +++++----- 7 files changed, 83 insertions(+), 86 deletions(-) delete mode 100644 sdk/src/wallet/address.ts diff --git a/sdk/examples/gateway.ts b/sdk/examples/gateway.ts index 899f4493..620edb37 100644 --- a/sdk/examples/gateway.ts +++ b/sdk/examples/gateway.ts @@ -1,6 +1,5 @@ import { GatewayQuoteParams, GatewaySDK } from "../src/gateway"; -import { AddressType, getAddressInfo } from "bitcoin-address-validation"; -import { createTransfer } from "../src/wallet/utxo"; +import { base64 } from '@scure/base'; import { Transaction } from '@scure/btc-signer'; const BOB_TBTC_V2_TOKEN_ADDRESS = "0xBBa2eF945D523C4e2608C9E1214C2Cc64D4fc2e2"; @@ -9,51 +8,21 @@ export async function swapBtcForToken(evmAddress: string) { const gatewaySDK = new GatewaySDK("bob"); // or "mainnet" const quoteParams: GatewayQuoteParams = { - toChain: "bob", + fromChain: "Bitcoin", + fromUserAddress: "bc1qafk4yhqvj4wep57m62dgrmutldusqde8adh20d", + toChain: "BOB", toUserAddress: evmAddress, toToken: BOB_TBTC_V2_TOKEN_ADDRESS, // or "tBTC" amount: 10000000, // 0.1 BTC gasRefill: 10000, // 0.0001 BTC }; const quote = await gatewaySDK.getQuote(quoteParams); - const { bitcoinAddress, satoshis } = quote; - const { uuid, opReturnHash } = await gatewaySDK.startOrder(quote, quoteParams); + const { uuid, psbtBase64 } = await gatewaySDK.startOrder(quote, quoteParams); - const tx = await createTxWithOpReturn("bc1qafk4yhqvj4wep57m62dgrmutldusqde8adh20d", bitcoinAddress, satoshis, opReturnHash); + // NOTE: up to implementation to sign PSBT here! + const tx = Transaction.fromPSBT(base64.decode(psbtBase64!)); - // NOTE: relayer should broadcast the tx - await gatewaySDK.finalizeOrder(uuid, tx.toString("hex")); -} - -async function createTxWithOpReturn(fromAddress: string, toAddress: string, amount: number, opReturn: string, fromPubKey?: string): Promise { - const addressType = getAddressInfo(fromAddress).type; - - // Ensure this is not the P2TR address for ordinals (we don't want to spend from it) - if (addressType === AddressType.p2tr) { - throw new Error('Cannot transfer using Taproot (P2TR) address. Please use another address type.'); - } - - // We need the public key to generate the redeem and witness script to spend the scripts - if (addressType === (AddressType.p2sh || AddressType.p2wsh)) { - if (!fromPubKey) { - throw new Error('Public key is required to spend from the selected address type'); - } - } - - const unsignedTx = await createTransfer( - 'mainnet', - addressType, - fromAddress, - toAddress, - amount, - fromPubKey, - opReturn, - ); - - const psbt = unsignedTx.toPSBT(0); - // TODO: sign PSBT - const signedTx = Transaction.fromPSBT(psbt); - - return Buffer.from(signedTx.extract()) + // NOTE: relayer broadcasts the tx + await gatewaySDK.finalizeOrder(uuid, tx.hex); } diff --git a/sdk/src/gateway/client.ts b/sdk/src/gateway/client.ts index 7f4b4140..b408d150 100644 --- a/sdk/src/gateway/client.ts +++ b/sdk/src/gateway/client.ts @@ -1,6 +1,14 @@ import { ethers, AbiCoder } from "ethers"; import { GatewayQuoteParams } from "./types"; import { TOKENS_INFO, ADDRESS_LOOKUP, Token as TokenInfo } from "./tokens"; +import { createBitcoinPsbt } from "../wallet"; + +export enum Chains { + // NOTE: we also support Bitcoin testnet + bitcoin = "bitcoin", + bob = "bob", + bobSepolia = "bobSepolia", +}; type EvmAddress = string; @@ -72,6 +80,7 @@ type GatewayCreateOrderResponse = { type GatewayStartOrderResult = GatewayCreateOrderResponse & { bitcoinAddress: string, satoshis: number; + psbtBase64?: string; }; /** @@ -123,8 +132,8 @@ export class GatewayApiClient { * @param params The parameters for the quote. */ async getQuote(params: GatewayQuoteParams): Promise { - const isMainnet = params.toChain == "bob" || params.toChain == 60808; - const isTestnet = params.toChain == "bobSepolia" || params.toChain == 808813; + const isMainnet = params.toChain === 60808 || typeof params.toChain === "string" && params.toChain.toLowerCase() === Chains.bob; + const isTestnet = params.toChain === 808813 || typeof params.toChain === "string" && params.toChain.toLowerCase() === Chains.bobSepolia; let outputToken = ""; if (params.toToken.startsWith("0x")) { @@ -191,11 +200,24 @@ export class GatewayApiClient { throw new Error('Invalid OP_RETURN hash'); } + let psbtBase64: string; + if (params.fromUserAddress && typeof params.fromChain === "string" && params.fromChain.toLowerCase() === Chains.bitcoin) { + psbtBase64 = await createBitcoinPsbt( + params.fromUserAddress, + params.toUserAddress, + gatewayQuote.satoshis, + params.fromUserPublicKey, + data.opReturnHash, + gatewayQuote.txProofDifficultyFactor + ); + } + return { uuid: data.uuid, opReturnHash: data.opReturnHash, bitcoinAddress: gatewayQuote.bitcoinAddress, satoshis: gatewayQuote.satoshis, + psbtBase64, } } diff --git a/sdk/src/gateway/types.ts b/sdk/src/gateway/types.ts index ff572bb1..4705791c 100644 --- a/sdk/src/gateway/types.ts +++ b/sdk/src/gateway/types.ts @@ -12,6 +12,8 @@ export interface GatewayQuoteParams { toToken: TokenSymbol; /** @description Wallet address on source chain */ fromUserAddress?: string; + /** @description Wallet public key on source chain */ + fromUserPublicKey?: string; /** @description Wallet address on destination chain */ toUserAddress: string; /** @description Amount of tokens to send from the source chain */ diff --git a/sdk/src/wallet/address.ts b/sdk/src/wallet/address.ts deleted file mode 100644 index aaa3f7f5..00000000 --- a/sdk/src/wallet/address.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BitcoinNetworkName } from "./utxo"; - -const BTC_MAINNET_REGEX = /\b([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[ac-hj-np-zAC-HJ-NP-Z02-9]{11,71})\b/; -const BTC_TESTNET_REGEX = /\b([2mn][a-km-zA-HJ-NP-Z1-9]{25,34}|tb1[ac-hj-np-zAC-HJ-NP-Z02-9]{11,71})\b/; - -const mainnetRegex = new RegExp(BTC_MAINNET_REGEX); -const testnetRegex = new RegExp(BTC_TESTNET_REGEX); - -export const isValidBtcAddress = (network: BitcoinNetworkName, address: string): boolean => { - switch (network) { - case 'mainnet': - return mainnetRegex.test(address); - case 'testnet': - return testnetRegex.test(address); - default: - throw new Error(`Invalid bitcoin network configured: ${network}. Valid values are: mainnet | testnet.`); - } -}; diff --git a/sdk/src/wallet/index.ts b/sdk/src/wallet/index.ts index 43b97889..a7ff86bd 100644 --- a/sdk/src/wallet/index.ts +++ b/sdk/src/wallet/index.ts @@ -1,2 +1,3 @@ -export * from "./address"; -export * from "./utxo"; \ No newline at end of file +export * from "./utxo"; + +export { validate as isValidBtcAddress } from 'bitcoin-address-validation'; diff --git a/sdk/src/wallet/utxo.ts b/sdk/src/wallet/utxo.ts index 3b4d425e..40180c86 100644 --- a/sdk/src/wallet/utxo.ts +++ b/sdk/src/wallet/utxo.ts @@ -1,10 +1,9 @@ import { Transaction, Script, selectUTXO, TEST_NETWORK, NETWORK, p2wpkh, p2sh } from '@scure/btc-signer'; -import { hex } from "@scure/base"; -import { AddressType } from 'bitcoin-address-validation'; - +import { hex, base64 } from "@scure/base"; +import { AddressType, getAddressInfo, Network } from 'bitcoin-address-validation'; import { DefaultEsploraClient, UTXO } from '../esplora'; -export type BitcoinNetworkName = 'mainnet' | 'testnet'; +export type BitcoinNetworkName = Exclude; const bitcoinNetworks: Record = { mainnet: NETWORK, @@ -29,17 +28,41 @@ export interface Input { nonWitnessUtxo?: Uint8Array; } -export async function createTransfer( - network: BitcoinNetworkName, - addressType: AddressType, +/** + * Create a PSBT with an output `toAddress` and an optional OP_RETURN output. + * 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). + * @returns {Promise} The Base64 encoded PSBT. + */ +export async function createBitcoinPsbt( fromAddress: string, toAddress: string, amount: number, publicKey?: string, opReturnData?: string, confirmationTarget: number = 3, -): Promise { - const esploraClient = new DefaultEsploraClient(network); +): Promise { + const addressInfo = getAddressInfo(fromAddress); + const network = addressInfo.network; + if (network === "regtest") { + throw new Error("Bitcoin regtest not supported"); + } + + // We need the public key to generate the redeem and witness script to spend the scripts + if (addressInfo.type === (AddressType.p2sh || AddressType.p2wsh)) { + if (!publicKey) { + throw new Error('Public key is required to spend from the selected address type'); + } + } + + const esploraClient = new DefaultEsploraClient(addressInfo.network); // NOTE: esplora only returns the 25 most recent UTXOs // TODO: change this to use the pagination API and return all UTXOs @@ -58,11 +81,8 @@ export async function createTransfer( await Promise.all( confirmedUtxos.map(async (utxo) => { const hex = await esploraClient.getTransactionHex(utxo.txid); - const transaction = Transaction.fromRaw(Buffer.from(hex, 'hex'), { allowUnknownOutputs: true }); - - const input = getInputFromUtxoAndTx(network, utxo, transaction, addressType, publicKey); - + const input = getInputFromUtxoAndTx(network, utxo, transaction, addressInfo.type, publicKey); possibleInputs.push(input); }) ); @@ -104,7 +124,7 @@ export async function createTransfer( throw new Error('Failed to create transaction. Do you have enough funds?'); } - return transaction.tx; + return base64.encode(transaction.tx.toPSBT(0)); } // Using the UTXO and the transaction, we can construct the input for the transaction @@ -113,7 +133,7 @@ export function getInputFromUtxoAndTx( utxo: UTXO, transaction: Transaction, addressType: AddressType, - pubKey?: string + publicKey?: string ): Input { // The output containts the necessary details to spend the UTXO based on the script type // Under the hood, @scure/btc-signer parses the output and extracts the script and amount @@ -126,7 +146,10 @@ export function getInputFromUtxoAndTx( let redeemScript = {}; if (addressType === AddressType.p2sh) { - const inner = p2wpkh(Buffer.from(pubKey!, 'hex'), getBtcNetwork(network)); + if (!publicKey) { + throw new Error("Bitcoin P2SH not supported without public key"); + } + const inner = p2wpkh(Buffer.from(publicKey!, 'hex'), getBtcNetwork(network)); redeemScript = p2sh(inner); } diff --git a/sdk/test/utxo.test.ts b/sdk/test/utxo.test.ts index 229f4924..9115832f 100644 --- a/sdk/test/utxo.test.ts +++ b/sdk/test/utxo.test.ts @@ -1,9 +1,8 @@ import { describe, it, assert } from 'vitest'; -import { AddressType, getAddressInfo } from 'bitcoin-address-validation'; +import { AddressType, getAddressInfo, Network } from 'bitcoin-address-validation'; import { Address, NETWORK, OutScript, Script, Transaction } from '@scure/btc-signer'; -import { hex } from '@scure/base'; - -import { createTransfer, getInputFromUtxoAndTx } from '../src/wallet/utxo'; +import { hex, base64 } from '@scure/base'; +import { createBitcoinPsbt, getInputFromUtxoAndTx } from '../src/wallet/utxo'; import { TransactionOutput } from '@scure/btc-signer/psbt'; // TODO: Add more tests using https://github.com/paulmillr/scure-btc-signer/tree/5ead71ea9a873d8ba1882a9cd6aa561ad410d0d1/test/bitcoinjs-test/fixtures/bitcoinjs @@ -51,15 +50,14 @@ describe('UTXO Tests', () => { // Use a random public key for P2SH-P2WPKH pubkey = '03b366c69e8237d9be7c4f1ac2a7abc6a79932fbf3de4e2f6c04797d7ef27abfe1'; } - const transaction = await createTransfer( - network, - paymentAddressType, + const psbtBase64 = await createBitcoinPsbt( paymentAddress, toAddress, amount, pubkey, opReturn, ); + const transaction = Transaction.fromPSBT(base64.decode(psbtBase64)); assert(transaction); @@ -179,7 +177,7 @@ describe('UTXO Tests', () => { for (const test of testset) { const input = getInputFromUtxoAndTx( - 'testnet', + Network.testnet, test.utxo, test.transaction, test.addressType, From 097f0e0dc78f6fa4702bb4c53a721d21a2235033 Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Tue, 27 Aug 2024 15:38:57 +0100 Subject: [PATCH 2/4] test: gateway sdk Signed-off-by: Gregory Hill --- sdk/package-lock.json | 87 ++++++++++++++++++++++++++++++++++++ sdk/package.json | 3 +- sdk/src/gateway/client.ts | 51 +++++++++++---------- sdk/src/gateway/tokens.ts | 4 +- sdk/test/esplora.test.ts | 2 +- sdk/test/gateway.test.ts | 93 +++++++++++++++++++++++++++++++++++++++ sdk/test/utxo.test.ts | 1 - 7 files changed, 212 insertions(+), 29 deletions(-) create mode 100644 sdk/test/gateway.test.ts diff --git a/sdk/package-lock.json b/sdk/package-lock.json index f44e2717..6fcea513 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -19,6 +19,7 @@ "@types/yargs": "^17.0.33", "ecpair": "^2.1.0", "mocha": "^10.7.3", + "nock": "^14.0.0-beta.11", "tiny-secp256k1": "^2.2.3", "ts-node": "^10.0.0", "typescript": "^5.5.4", @@ -495,6 +496,23 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.34.3.tgz", + "integrity": "sha512-UPi1V51c4+PVVG1lhVK2i29aOyBqXvBF8WBvlXIp2Q0vTVKrm20x2voLfunVC3N5wzocxEoHJyOwAXNdoMqy3Q==", + "dev": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@noble/curves": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.0.tgz", @@ -518,6 +536,28 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.19.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.1.tgz", @@ -1757,6 +1797,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1822,6 +1868,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2026,6 +2078,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nock": { + "version": "14.0.0-beta.11", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.0-beta.11.tgz", + "integrity": "sha512-tmImk6btCUcCFHXkhqZnuSe4e/4M/YYs8qYaQGdjZSBloTXgQyuPrgjzn45zmFWpK2bDSEtIcF8olFdFxRaA1g==", + "dev": true, + "dependencies": { + "@mswjs/interceptors": "^0.34.3", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2091,6 +2157,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "node_modules/p-locate": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", @@ -2218,6 +2290,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -2424,6 +2505,12 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/sdk/package.json b/sdk/package.json index 232f0126..eacae696 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -22,6 +22,7 @@ "@types/yargs": "^17.0.33", "ecpair": "^2.1.0", "mocha": "^10.7.3", + "nock": "^14.0.0-beta.11", "tiny-secp256k1": "^2.2.3", "ts-node": "^10.0.0", "typescript": "^5.5.4", @@ -35,4 +36,4 @@ "bitcoinjs-lib": "^6.1.6", "ethers": "^6.13.2" } -} \ No newline at end of file +} diff --git a/sdk/src/gateway/client.ts b/sdk/src/gateway/client.ts index b408d150..20b68fd6 100644 --- a/sdk/src/gateway/client.ts +++ b/sdk/src/gateway/client.ts @@ -5,9 +5,9 @@ import { createBitcoinPsbt } from "../wallet"; export enum Chains { // NOTE: we also support Bitcoin testnet - bitcoin = "bitcoin", - bob = "bob", - bobSepolia = "bobSepolia", + Bitcoin = "bitcoin", + BOB = "bob", + BOBSepolia = "bobsepolia", }; type EvmAddress = string; @@ -26,17 +26,17 @@ type GatewayQuote = { /** @description The number of confirmations required to confirm the Bitcoin tx */ txProofDifficultyFactor: number; /** @description The optional strategy address */ - strategyAddress: EvmAddress | null, + strategyAddress?: EvmAddress, }; /** @dev Internal request type used to call the Gateway API */ type GatewayCreateOrderRequest = { gatewayAddress: EvmAddress, - strategyAddress: EvmAddress | null, + strategyAddress?: EvmAddress, satsToConvertToEth: number, userAddress: EvmAddress, - gatewayExtraData: string | null, - strategyExtraData: string | null, + gatewayExtraData?: string, + strategyExtraData?: string, satoshis: number, }; @@ -60,7 +60,7 @@ type GatewayOrderResponse = { /** @description The number of confirmations required to confirm the Bitcoin tx */ txProofDifficultyFactor: number; /** @description The optional strategy address */ - strategyAddress: EvmAddress | null, + strategyAddress?: EvmAddress, /** @description The gas refill in satoshis */ satsToConvertToEth: number, }; @@ -113,11 +113,13 @@ export class GatewayApiClient { */ constructor(networkOrUrl: string = "mainnet") { switch (networkOrUrl) { - case "mainnet" || "bob": + case "mainnet": + case "bob": this.network = Network.Mainnet; this.baseUrl = MAINNET_GATEWAY_BASE_URL; break; - case "testnet" || "bobSepolia": + case "testnet": + case "bobSepolia": this.network = Network.Testnet; this.baseUrl = TESTNET_GATEWAY_BASE_URL; break; @@ -132,17 +134,18 @@ export class GatewayApiClient { * @param params The parameters for the quote. */ async getQuote(params: GatewayQuoteParams): Promise { - const isMainnet = params.toChain === 60808 || typeof params.toChain === "string" && params.toChain.toLowerCase() === Chains.bob; - const isTestnet = params.toChain === 808813 || typeof params.toChain === "string" && params.toChain.toLowerCase() === Chains.bobSepolia; + const isMainnet = params.toChain === 60808 || typeof params.toChain === "string" && params.toChain.toLowerCase() === Chains.BOB; + const isTestnet = params.toChain === 808813 || typeof params.toChain === "string" && params.toChain.toLowerCase() === Chains.BOBSepolia; + const toToken = params.toToken.toLowerCase(); let outputToken = ""; - if (params.toToken.startsWith("0x")) { - outputToken = params.toToken; - } else if (params.toToken in TOKENS_INFO) { + if (toToken.startsWith("0x")) { + outputToken = toToken; + } else if (toToken in TOKENS_INFO) { if (isMainnet && this.network === Network.Mainnet) { - outputToken = TOKENS_INFO[params.toToken].bob; + outputToken = TOKENS_INFO[toToken].bob; } else if (isTestnet && this.network === Network.Testnet) { - outputToken = TOKENS_INFO[params.toToken].bobSepolia; + outputToken = TOKENS_INFO[toToken].bobSepolia; } else { throw new Error('Unknown network'); } @@ -173,11 +176,11 @@ export class GatewayApiClient { const request: GatewayCreateOrderRequest = { gatewayAddress: gatewayQuote.gatewayAddress, strategyAddress: gatewayQuote.strategyAddress, - satsToConvertToEth: params.gasRefill, + satsToConvertToEth: params.gasRefill || 0, userAddress: params.toUserAddress, // TODO: figure out how to get extra data - gatewayExtraData: null, - strategyExtraData: null, + gatewayExtraData: undefined, + strategyExtraData: undefined, satoshis: gatewayQuote.satoshis, }; @@ -201,10 +204,10 @@ export class GatewayApiClient { } let psbtBase64: string; - if (params.fromUserAddress && typeof params.fromChain === "string" && params.fromChain.toLowerCase() === Chains.bitcoin) { + if (params.fromUserAddress && typeof params.fromChain === "string" && params.fromChain.toLowerCase() === Chains.Bitcoin) { psbtBase64 = await createBitcoinPsbt( params.fromUserAddress, - params.toUserAddress, + gatewayQuote.bitcoinAddress, gatewayQuote.satoshis, params.fromUserPublicKey, data.opReturnHash, @@ -306,8 +309,8 @@ function calculateOpReturnHash(req: GatewayCreateOrderRequest) { req.strategyAddress || ethers.ZeroAddress, req.satsToConvertToEth, req.userAddress, - req.gatewayExtraData, - req.strategyExtraData + req.gatewayExtraData || "0x", + req.strategyExtraData || "0x" ] )) } \ No newline at end of file diff --git a/sdk/src/gateway/tokens.ts b/sdk/src/gateway/tokens.ts index 73a86170..7d135a62 100644 --- a/sdk/src/gateway/tokens.ts +++ b/sdk/src/gateway/tokens.ts @@ -54,7 +54,7 @@ const TOKENS: { [key: string]: Token } = { export const TOKENS_INFO: { [key: string]: Token } = {}; export const ADDRESS_LOOKUP: { [address: string]: Token } = {}; -for (const key in TOKENS_INFO) { +for (const key in TOKENS) { const token = TOKENS[key]; const lowerBob = token.bob.toLowerCase(); @@ -67,7 +67,7 @@ for (const key in TOKENS_INFO) { bobSepolia: lowerBobSepolia, }; - TOKENS_INFO[key] = lowercasedToken; + TOKENS_INFO[key.toLowerCase()] = lowercasedToken; ADDRESS_LOOKUP[lowerBob] = lowercasedToken; ADDRESS_LOOKUP[lowerBobSepolia] = lowercasedToken; } \ No newline at end of file diff --git a/sdk/test/esplora.test.ts b/sdk/test/esplora.test.ts index f23c403b..ec375683 100644 --- a/sdk/test/esplora.test.ts +++ b/sdk/test/esplora.test.ts @@ -113,7 +113,7 @@ describe("Esplora Tests", () => { it("should get fee rate", async () => { const client = new DefaultEsploraClient("testnet"); const feeRate = await client.getFeeEstimate(1); - assert.isAbove(feeRate, 1); + assert.isAtLeast(feeRate, 1); }); diff --git a/sdk/test/gateway.test.ts b/sdk/test/gateway.test.ts new file mode 100644 index 00000000..dc6ed05c --- /dev/null +++ b/sdk/test/gateway.test.ts @@ -0,0 +1,93 @@ +import { assert, describe, it } from "vitest"; +import { GatewaySDK } from "../src/gateway"; +import { MAINNET_GATEWAY_BASE_URL } from "../src/gateway/client"; +import { TOKENS_INFO } from "../src/gateway/tokens"; +import { ZeroAddress } from "ethers"; +import nock from "nock"; +import * as bitcoin from "bitcoinjs-lib"; + +describe("Gateway Tests", () => { + it("should get quote", async () => { + const gatewaySDK = new GatewaySDK("mainnet"); + + const mockQuote = { + gatewayAddress: ZeroAddress, + dustThreshold: 1000, + satoshis: 1000, + fee: 10, + bitcoinAddress: "", + txProofDifficultyFactor: 3, + strategyAddress: ZeroAddress, + }; + + nock(`${MAINNET_GATEWAY_BASE_URL}`) + .get(`/quote/${TOKENS_INFO["tbtc"].bob}/1000`) + .times(4) + .reply(200, mockQuote); + + assert.deepEqual(await gatewaySDK.getQuote({ + toChain: "BOB", + toToken: "tBTC", + toUserAddress: ZeroAddress, + amount: 1000, + }), mockQuote); + assert.deepEqual(await gatewaySDK.getQuote({ + toChain: "bob", + toToken: "tbtc", + toUserAddress: ZeroAddress, + amount: 1000, + }), mockQuote); + assert.deepEqual(await gatewaySDK.getQuote({ + toChain: 60808, + toToken: "tbtc", + toUserAddress: ZeroAddress, + amount: 1000, + }), mockQuote); + assert.deepEqual(await gatewaySDK.getQuote({ + toChain: "BOB", + toToken: TOKENS_INFO["tbtc"].bob, + toUserAddress: ZeroAddress, + amount: 1000, + }), mockQuote); + }); + + it("should start order", async () => { + const gatewaySDK = new GatewaySDK("bob"); + + const mockQuote = { + gatewayAddress: ZeroAddress, + dustThreshold: 1000, + satoshis: 1000, + fee: 10, + bitcoinAddress: "bc1qafk4yhqvj4wep57m62dgrmutldusqde8adh20d", + txProofDifficultyFactor: 3, + strategyAddress: ZeroAddress, + }; + + nock(`${MAINNET_GATEWAY_BASE_URL}`) + .post(`/order`) + .reply(201, { + uuid: "00000000-0000-0000-0000-000000000000", + opReturnHash: "0x10e69ac36b8d7ae8eb1dca7fe368da3d27d159259f48d345ff687ef0fcbdedcd", + }); + + const result = await gatewaySDK.startOrder(mockQuote, { + toChain: "BOB", + toToken: "tBTC", + toUserAddress: ZeroAddress, + amount: 1000, + fromChain: "Bitcoin", + fromUserAddress: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + }); + + assert.isDefined(result.psbtBase64); + const psbt = bitcoin.Psbt.fromBase64(result.psbtBase64!); + assert.deepEqual( + psbt.txOutputs[0].script, + bitcoin.script.compile([ + bitcoin.opcodes.OP_RETURN, + Buffer.from(result.opReturnHash.slice(2), "hex") + ]) + ); + }); +}); diff --git a/sdk/test/utxo.test.ts b/sdk/test/utxo.test.ts index 9115832f..1af35f7c 100644 --- a/sdk/test/utxo.test.ts +++ b/sdk/test/utxo.test.ts @@ -9,7 +9,6 @@ import { TransactionOutput } from '@scure/btc-signer/psbt'; // TODO: Ensure that the paymentAddresses have sufficient funds to create the transaction describe('UTXO Tests', () => { it('should spend from address to create a transaction with an OP return output', { timeout: 50000 }, async () => { - const network = 'mainnet'; // Addresses where randomly picked from blockstream.info const paymentAddresses = [ // P2WPKH: https://blockstream.info/address/bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq From 5fe3458b5d34861783d1359b4b5abbd2210f07d2 Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Tue, 27 Aug 2024 15:42:38 +0100 Subject: [PATCH 3/4] chore: rename tokens lookup Signed-off-by: Gregory Hill --- sdk/src/gateway/client.ts | 8 ++++---- sdk/src/gateway/tokens.ts | 4 ++-- sdk/test/gateway.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sdk/src/gateway/client.ts b/sdk/src/gateway/client.ts index 20b68fd6..cfcdc4a6 100644 --- a/sdk/src/gateway/client.ts +++ b/sdk/src/gateway/client.ts @@ -1,6 +1,6 @@ import { ethers, AbiCoder } from "ethers"; import { GatewayQuoteParams } from "./types"; -import { TOKENS_INFO, ADDRESS_LOOKUP, Token as TokenInfo } from "./tokens"; +import { SYMBOL_LOOKUP, ADDRESS_LOOKUP, Token as TokenInfo } from "./tokens"; import { createBitcoinPsbt } from "../wallet"; export enum Chains { @@ -141,11 +141,11 @@ export class GatewayApiClient { let outputToken = ""; if (toToken.startsWith("0x")) { outputToken = toToken; - } else if (toToken in TOKENS_INFO) { + } else if (toToken in SYMBOL_LOOKUP) { if (isMainnet && this.network === Network.Mainnet) { - outputToken = TOKENS_INFO[toToken].bob; + outputToken = SYMBOL_LOOKUP[toToken].bob; } else if (isTestnet && this.network === Network.Testnet) { - outputToken = TOKENS_INFO[toToken].bobSepolia; + outputToken = SYMBOL_LOOKUP[toToken].bobSepolia; } else { throw new Error('Unknown network'); } diff --git a/sdk/src/gateway/tokens.ts b/sdk/src/gateway/tokens.ts index 7d135a62..bcbd1bec 100644 --- a/sdk/src/gateway/tokens.ts +++ b/sdk/src/gateway/tokens.ts @@ -51,7 +51,7 @@ const TOKENS: { [key: string]: Token } = { }; /** @description Tokens supported on BOB and BOB Sepolia */ -export const TOKENS_INFO: { [key: string]: Token } = {}; +export const SYMBOL_LOOKUP: { [key: string]: Token } = {}; export const ADDRESS_LOOKUP: { [address: string]: Token } = {}; for (const key in TOKENS) { @@ -67,7 +67,7 @@ for (const key in TOKENS) { bobSepolia: lowerBobSepolia, }; - TOKENS_INFO[key.toLowerCase()] = lowercasedToken; + SYMBOL_LOOKUP[key.toLowerCase()] = lowercasedToken; ADDRESS_LOOKUP[lowerBob] = lowercasedToken; ADDRESS_LOOKUP[lowerBobSepolia] = lowercasedToken; } \ No newline at end of file diff --git a/sdk/test/gateway.test.ts b/sdk/test/gateway.test.ts index dc6ed05c..7899b6a0 100644 --- a/sdk/test/gateway.test.ts +++ b/sdk/test/gateway.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "vitest"; import { GatewaySDK } from "../src/gateway"; import { MAINNET_GATEWAY_BASE_URL } from "../src/gateway/client"; -import { TOKENS_INFO } from "../src/gateway/tokens"; +import { SYMBOL_LOOKUP } from "../src/gateway/tokens"; import { ZeroAddress } from "ethers"; import nock from "nock"; import * as bitcoin from "bitcoinjs-lib"; @@ -21,7 +21,7 @@ describe("Gateway Tests", () => { }; nock(`${MAINNET_GATEWAY_BASE_URL}`) - .get(`/quote/${TOKENS_INFO["tbtc"].bob}/1000`) + .get(`/quote/${SYMBOL_LOOKUP["tbtc"].bob}/1000`) .times(4) .reply(200, mockQuote); @@ -45,7 +45,7 @@ describe("Gateway Tests", () => { }), mockQuote); assert.deepEqual(await gatewaySDK.getQuote({ toChain: "BOB", - toToken: TOKENS_INFO["tbtc"].bob, + toToken: SYMBOL_LOOKUP["tbtc"].bob, toUserAddress: ZeroAddress, amount: 1000, }), mockQuote); From 780c2899d76e4859cab2c310f84dd6ed14bf024d Mon Sep 17 00:00:00 2001 From: Gregory Hill Date: Tue, 27 Aug 2024 15:42:57 +0100 Subject: [PATCH 4/4] chore: bump package to 2.1.0 Signed-off-by: Gregory Hill --- sdk/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/package.json b/sdk/package.json index eacae696..6d819e6b 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@gobob/bob-sdk", - "version": "2.0.0", + "version": "2.1.0", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { @@ -36,4 +36,4 @@ "bitcoinjs-lib": "^6.1.6", "ethers": "^6.13.2" } -} +} \ No newline at end of file