diff --git a/.github/workflows/release-bitcoin.yml b/.github/workflows/release-bitcoin.yml new file mode 100644 index 000000000..b8895cb0c --- /dev/null +++ b/.github/workflows/release-bitcoin.yml @@ -0,0 +1,62 @@ +name: Publish Sygma SDK Bitcoin package to GitHub Package Registry + +on: + push: + branches: ["main"] + paths: ["packages/bitcoin/**"] + +jobs: + maybe-release: + name: release + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + # you should probably do this after your regular CI checks passes + # it will analyze commits and create PR with new version and updated CHANGELOG:md file. On merging it will create github release page with changelog + - uses: google-github-actions/release-please-action@v3 + id: release + with: + command: manifest + release-type: node + token: ${{secrets.RELEASE_TOKEN}} + config-file: "release-please/rp-bitcoin-config.json" + manifest-file: "release-please/rp-bitcoin-manifest.json" + monorepo-tags: true + default-branch: main + path: "packages/bitcoin" + changelog-types: '[{"type":"feat","section":"Features","hidden":false},{"type":"fix","section":"Bug Fixes","hidden":false},{"type":"chore","section":"Miscellaneous","hidden":false},{"type":"revert","hidden":true}]' + + - uses: actions/checkout@v4 + # these if statements ensure that a publication only occurs when + # a new release is created: + if: ${{ steps.release.outputs.releases_created }} + + - uses: actions/setup-node@v4 + with: + cache: "yarn" + node-version: 18 + registry-url: "https://registry.npmjs.org" + scope: "@buildwithsygma" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + if: ${{ steps.release.outputs.releases_created }} + + - name: Enable corepack + run: corepack enable + if: ${{ steps.release.outputs.releases_created }} + + - name: Install dependencies + run: yarn install --immutable + if: ${{ steps.release.outputs.releases_created }} + + - run: yarn build + if: ${{ steps.release.outputs.releases_created }} + + - env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + if: ${{ steps.release.outputs.releases_created }} + run: | + echo -e "\nnpmAuthToken: \"$NODE_AUTH_TOKEN\"" >> ./.yarnrc.yml + + - run: yarn workspace @buildwithsygma/bitcoin npm publish --access public + if: ${{ steps.release.outputs.releases_created }} diff --git a/examples/bitcoin-to-evm-fungible-transfer/.env.sample b/examples/bitcoin-to-evm-fungible-transfer/.env.sample new file mode 100644 index 000000000..a251cf896 --- /dev/null +++ b/examples/bitcoin-to-evm-fungible-transfer/.env.sample @@ -0,0 +1,10 @@ +SYGMA_ENV=testnet +BLOCKSTREAM_URL="your blockstream url" +DESTINATION_ADDRESS="your evm destination address" +RESOURCE_ID="resource id" +SOURCE_CAIPID="source domain caip id" +EXPLORER_URL="your bitcoin explorer url" +MNEMONIC="your 12 or 24 mnemonic" +DERIVATION_PATH="your derivation path" +ADDRESS="your bitcoin address to use and send change" +AMOUNT="your amount to transfer" \ No newline at end of file diff --git a/examples/bitcoin-to-evm-fungible-transfer/README.md b/examples/bitcoin-to-evm-fungible-transfer/README.md new file mode 100644 index 000000000..2dc96dabd --- /dev/null +++ b/examples/bitcoin-to-evm-fungible-transfer/README.md @@ -0,0 +1,122 @@ +# Sygma SDK Bitcoin to EVM example + +## Sygma SDK ERC20 Example + +This is an example that demonstrates functionality of the protocol using Sygma SDK. The src/transfer.ts script showcases how bitcoins can transferred over to an EVM Sepolia address utilizing `@buildwithsygma/bitcoin` package. + +## Prerequisites + +Before running the script, ensure that you have the following: + +- Node.js +- Yarn (version 3.4.1 or higher) +- A development wallet with some testnet BTC +- The private key of a Taproot address to sign the transaction +- Valid UTXO information of your taproot address. You can get the UTXO's of your address by querying some public APIS like blockstream one and passing your address as parameter + +## Getting started + +### 1. Clone the repository + +To get started, clone this repository to your local machine with: + +```bash +git clone git@github.com:sygmaprotocol/sygma-sdk.git +cd sygma-sdk/ +``` + +### 2. Install dependencies + +Install the project dependencies by running: + +```bash +yarn install +``` + +### 3. Build the sdk + +To start the example you need to build the sdk first with: + +```bash +yarn build:all +``` + +## Usage + +This example uses the `dotenv` module to manage private keys and also to define env variables needed for this example to work. To run the example, you will need to configure your environment variables A `.env.sample` is provided as a template. + +**DO NOT COMMIT PRIVATE KEYS WITH REAL FUNDS TO GITHUB. DOING SO COULD RESULT IN COMPLETE LOSS OF YOUR FUNDS.** + +Create a `.env` file in the btc-to-evm example folder: + +```bash +cd examples/btc-to-evm-fungible-transfer +touch .env +``` + +Replace the values that are defined in the `.env.sample` file: + +```bash +SYGMA_ENV=testnet +BLOCKSTREAM_URL="your blockstream url" +DESTINATION_ADDRESS="your evm destination address" +DESTINATION_CHAIN_ID="destination domain chain id" +RESOURCE_ID="resource id" +SOURCE_CAIPID="source domain caip id" +EXPLORER_URL="your bitcoin explorer url" +MNEMONIC="your 12 or 24 mnemonic" +DERIVATION_PATH="your derivation path" +ADDRESS="your change address" +AMOUNT="your amount to transfer" +``` + +* `DESTINATION_ADDRESS`: your `evm` destination address where you want your funds to be relayed +* `DESTINATION_CHAIN_ID`: this is the chainId of the network where you want to receive the funds +* `RESOURCE_ID`: the bitcoin resource id that can be found in our `shared-config` repository +* `SOURCE_CAIPID`: caipId of the bitcoin domain +* `MNEMONIC`: your testnet wallet mnemonic +* `DERIVATION_PATH`: derivation path for your mnemonic. Use derivation path for either P2TR address or P2WPKH one +* `ADDRESS`: the address from which we get the UTXO's and to which we send the change +* `AMOUNT`: the actual amount to transfer + +Take into consideration that a typical response when query the utxos of your address look like this: + +```json +{ + "txid": "7bdf2ce472ee3c9cba6d2944b0ca6bcdceb4b893c7d2163678a0b688a8315d74", + "vout": 3, + "status": { + "confirmed": true, + "block_height": 2869535, + "block_hash": "", + "block_time": 1721666904 + }, + "value": 936396 +} +``` + +Where `value` is the amount you have at your disposal and `vout` is the transaction output index. + +To send Testnet BTC to your EVM account on Sepolia using Taproot address run: + +```bash +yarn run transfer:p2tr +``` + +To send Testnet BTC to your EVM account on Sepolia using P2WPKH address run: + +```bash +yarn run transfer:p2wpkh +``` + +Replace the placeholder values in the `.env` file with your own Testnet BTC Taproot private key as well as the other env variables needed such as DESTINATION_ADDRESS, DESTINATION_DOMAIN_ID, RESOURCE_ID, DERIVATION_PATH, and SOURCE_DOMAIN_ID. + +## Script Functionality + +This example script performs the following steps: +- I creates a signer to sign the Bitcoin Testnet transaction using your provider private key from your taproot address. +- it gets the fee for 5 confirmations blocks. You can change that following this [reference](https://github.com/Blockstream/esplora/blob/master/API.md#get-fee-estimates) +- it then encodes the provided EVM address + the destination domain id needed to relay the funds +- Once you have provided with the UTXO information needed, it will calculate the fee of the transaction based on some aproximation value +- it then instantiate a PSBT class to be able to provide the inputs and outputs needed to relay the assets +- It signs the transaction and the broadcasted into the Bitcoin testnet network. You will get an url with the transaction id to follow the confirmation of the transaction. diff --git a/examples/bitcoin-to-evm-fungible-transfer/package.json b/examples/bitcoin-to-evm-fungible-transfer/package.json new file mode 100644 index 000000000..70cec83b2 --- /dev/null +++ b/examples/bitcoin-to-evm-fungible-transfer/package.json @@ -0,0 +1,42 @@ +{ + "name": "@buildwithsygma/sygma-sdk-bitcoin-to-evm-fungible-transfer-example", + "version": "0.1.0", + "type": "module", + "description": "Sygma sdk examples", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/sygmaprotocol/sygma-sdk" + }, + "keywords": [ + "sygma", + "sygmaprotocol", + "buildwithsygma", + "web3", + "bridge", + "bitcoin" + ], + "scripts": { + "transfer:p2tr": "tsx src/transfer.p2tr.ts", + "transfer:p2wpkh": "tsx src/transfer.p2wpkh.ts" + }, + "author": "Sygmaprotocol Product Team", + "license": "LGPL-3.0-or-later", + "devDependencies": { + "dotenv": "^16.3.1", + "eslint": "8", + "ts-node": "10.9.1", + "typescript": "5.0.4" + }, + "dependencies": { + "@buildwithsygma/bitcoin": "workspace:^", + "@buildwithsygma/core": "workspace:^", + "@buildwithsygma/utils": "workspace:^", + "bip32": "^4.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.6", + "ecpair": "^2.1.0", + "tiny-secp256k1": "^2.2.3", + "tsx": "^4.15.4" + } +} diff --git a/examples/bitcoin-to-evm-fungible-transfer/src/blockstreamApi.ts b/examples/bitcoin-to-evm-fungible-transfer/src/blockstreamApi.ts new file mode 100644 index 000000000..a9f3f1628 --- /dev/null +++ b/examples/bitcoin-to-evm-fungible-transfer/src/blockstreamApi.ts @@ -0,0 +1,85 @@ +import { TypeOfAddress } from "@buildwithsygma/bitcoin"; +import type { BitcoinTransferParams } from "@buildwithsygma/bitcoin"; +import type { Network, Signer } from "bitcoinjs-lib"; +import { payments, Psbt } from "bitcoinjs-lib"; + +type SizeCalculationParams = { + utxoData: BitcoinTransferParams["utxoData"]; + network: Network; + publicKey: Buffer; + depositAddress: string; + domainId: number; + amount: bigint; + feeValue: bigint; + changeAddress: string; + signer: Signer; + typeOfAddress: TypeOfAddress; +}; + +/** + * Ee calculate the size of the transaction by using a tx with zero fee => input amount == output amount + * Correctnes of the data is not relevant here, we need to know what's the size is going to be for the amount of inputs passed and the 4 outputs (deposit, change, fee, encoded data) we use to relay the funds + */ +export const calculateSize = ({ + utxoData, + network, + publicKey, + depositAddress, + domainId, + amount, + feeValue, + changeAddress, + signer, + typeOfAddress, +}: SizeCalculationParams): number => { + const pstb = new Psbt({ network: network }); + + const scriptPubKey: Buffer = (typeOfAddress !== TypeOfAddress.P2TR) + ? payments.p2wpkh({ pubkey: publicKey, network: network }).output as Buffer + : payments.p2tr({ internalPubkey: publicKey, network: network }).output as Buffer; + + utxoData.forEach((utxo) => { + const input = { + hash: utxo.utxoTxId, + index: utxo.utxoOutputIndex, + witnessUtxo: { + value: Number(utxo.utxoAmount), + script: scriptPubKey, + }, + }; + + if (typeOfAddress === TypeOfAddress.P2TR) { + (input as any).tapInternalKey = publicKey; + } + + pstb.addInput(input); + }); + + + const outputs = [ + { + script: payments.embed({ + data: [Buffer.from(`${depositAddress}_${domainId}`)], + }).output as Buffer, + value: 0, + }, + { + address: changeAddress, + value: Number(amount), + }, + { + address: changeAddress, + value: Number(feeValue), + }, + { + address: changeAddress, + value: 0, + } + ]; + + outputs.forEach(output => pstb.addOutput(output)); + + pstb.signAllInputs(signer); + pstb.finalizeAllInputs(); + return pstb.extractTransaction(true).virtualSize(); +}; diff --git a/examples/bitcoin-to-evm-fungible-transfer/src/transfer.p2tr.ts b/examples/bitcoin-to-evm-fungible-transfer/src/transfer.p2tr.ts new file mode 100644 index 000000000..e18ee0b6e --- /dev/null +++ b/examples/bitcoin-to-evm-fungible-transfer/src/transfer.p2tr.ts @@ -0,0 +1,121 @@ +import { + createBitcoinFungibleTransfer, + TypeOfAddress, +} from "@buildwithsygma/bitcoin"; +import type { BitcoinTransferParams, UTXOData } from "@buildwithsygma/bitcoin"; +import { BIP32Factory } from "bip32"; +import { mnemonicToSeed } from "bip39"; +import { crypto, initEccLib, networks } from "bitcoinjs-lib"; +import { toXOnly } from "bitcoinjs-lib/src/psbt/bip371"; +import dotenv from "dotenv"; +import * as tinysecp from "tiny-secp256k1"; +import { broadcastTransaction, fetchUTXOS, getFeeEstimates, processUtxos } from '@buildwithsygma/utils' + +import { + calculateSize, +} from "./blockstreamApi.js"; + +dotenv.config(); + +const DESTINATION_CHAIN_ID = 11155111; +const { + SOURCE_CAIPID, + DESTINATION_ADDRESS, + RESOURCE_ID, + BLOCKSTREAM_URL, + EXPLORER_URL, + MNEMONIC, + DERIVATION_PATH, + ADDRESS, + AMOUNT, +} = process.env; + + +if ( + !SOURCE_CAIPID || + !DESTINATION_ADDRESS || + !RESOURCE_ID || + !MNEMONIC || + !DERIVATION_PATH || + !ADDRESS || + !BLOCKSTREAM_URL || + !AMOUNT +) { + throw new Error( + "Missing required environment variables, please make sure .env file exists." + ); +} + +async function btcToEvmTransfer(): Promise { + // pre setup + initEccLib(tinysecp); + const bip32 = BIP32Factory(tinysecp); + console.log("Transfer BTC to EVM"); + const seed = await mnemonicToSeed(MNEMONIC); + const rootKey = bip32.fromSeed(seed, networks.testnet); + const derivedNode = rootKey.derivePath(DERIVATION_PATH); + + const publicKeyDropedDERHeader = toXOnly(derivedNode.publicKey); + + const tweakedSigner = derivedNode.tweak( + crypto.taggedHash("TapTweak", publicKeyDropedDERHeader), + ); + + const feeRate = await getFeeEstimates('5'); + const utxos = await fetchUTXOS( + ADDRESS as unknown as string); + + const processedUtxos = processUtxos(utxos, AMOUNT); + + const mapedUtxos = processedUtxos.map((utxo) => ({ + utxoTxId: utxo.txid, + utxoOutputIndex: utxo.vout, + utxoAmount: BigInt(utxo.value), + })) as unknown as UTXOData[]; + + const size = calculateSize({ + utxoData: mapedUtxos, + network: networks.testnet, + publicKey: publicKeyDropedDERHeader, + depositAddress: ADDRESS as unknown as string, + domainId: DESTINATION_CHAIN_ID, + amount: AMOUNT, + feeValue: BigInt(0), + changeAddress: ADDRESS as unknown as string, + signer: tweakedSigner, + typeOfAddress: TypeOfAddress.P2TR, + }); + + const transferParams: BitcoinTransferParams = { + source: SOURCE_CAIPID, + destination: DESTINATION_CHAIN_ID, + destinationAddress: DESTINATION_ADDRESS, + amount: AMOUNT, + resource: RESOURCE_ID, + utxoData: mapedUtxos, + feeRate: BigInt(Math.ceil(feeRate)), + publicKey: publicKeyDropedDERHeader, + typeOfAddress: TypeOfAddress.P2TR, + network: networks.testnet, + changeAddress: ADDRESS, + size: BigInt(size), + }; + + const transfer = await createBitcoinFungibleTransfer(transferParams); + + const psbt = transfer.getTransferTransaction(); + + console.log("Signing the transaction"); + + psbt.signAllInputs(tweakedSigner); + psbt.finalizeAllInputs(); + + console.log("Extracting the transaction"); + const tx = psbt.extractTransaction(true); + console.log("Transaction hex", tx.toHex()); + + const txId = await broadcastTransaction(tx.toHex()); + console.log("Transaction broadcasted", `${EXPLORER_URL}/tx/${txId}`); +} + +btcToEvmTransfer().finally(() => { }); diff --git a/examples/bitcoin-to-evm-fungible-transfer/src/transfer.p2wpkh.ts b/examples/bitcoin-to-evm-fungible-transfer/src/transfer.p2wpkh.ts new file mode 100644 index 000000000..c0e29d7fd --- /dev/null +++ b/examples/bitcoin-to-evm-fungible-transfer/src/transfer.p2wpkh.ts @@ -0,0 +1,112 @@ +import type { BitcoinTransferParams, UTXOData } from "@buildwithsygma/bitcoin"; +import { + createBitcoinFungibleTransfer, + TypeOfAddress, +} from "@buildwithsygma/bitcoin"; +import { BIP32Factory } from "bip32"; +import { mnemonicToSeed } from "bip39"; +import { initEccLib, networks } from "bitcoinjs-lib"; +import dotenv from "dotenv"; +import * as tinysecp from "tiny-secp256k1"; + +import { + calculateSize, +} from "./blockstreamApi.js"; +import { broadcastTransaction, fetchUTXOS, getFeeEstimates, processUtxos } from "@buildwithsygma/utils"; + +dotenv.config(); + +const DESTINATION_CHAIN_ID = 11155111; +const { + SOURCE_CAIPID, + DESTINATION_ADDRESS, + RESOURCE_ID, + BLOCKSTREAM_URL, + EXPLORER_URL, + MNEMONIC, + DERIVATION_PATH, + ADDRESS, + AMOUNT, +} = process.env; + +if ( + !SOURCE_CAIPID || + !DESTINATION_ADDRESS || + !RESOURCE_ID || + !MNEMONIC || + !DERIVATION_PATH || + !ADDRESS || + !BLOCKSTREAM_URL || + !AMOUNT +) { + throw new Error( + "Missing required environment variables, please make sure .env file exists." + ); +} + +async function btcToEvmTransfer(): Promise { + // pre setup + initEccLib(tinysecp); + const bip32 = BIP32Factory(tinysecp); + console.log("Transfer BTC to EVM"); + const seed = await mnemonicToSeed(MNEMONIC); + const rootKey = bip32.fromSeed(seed, networks.testnet); + const derivedNode = rootKey.derivePath(DERIVATION_PATH); + + const feeRate = await getFeeEstimates('5'); + const utxos = await fetchUTXOS(ADDRESS as unknown as string); + + const processedUtxos = processUtxos(utxos, AMOUNT); + + const mapedUtxos = processedUtxos.map((utxo) => ({ + utxoTxId: utxo.txid, + utxoOutputIndex: utxo.vout, + utxoAmount: BigInt(utxo.value), + })) as unknown as UTXOData[]; + + const size = calculateSize({ + utxoData: mapedUtxos, + network: networks.testnet, + publicKey: derivedNode.publicKey, + depositAddress: ADDRESS as unknown as string, + domainId: DESTINATION_CHAIN_ID, + amount: AMOUNT, + feeValue: BigInt(0), + changeAddress: ADDRESS as unknown as string, + signer: derivedNode, + typeOfAddress: TypeOfAddress.P2WPKH, + }); // aprox estimation of the size of the tx + + const transferParams: BitcoinTransferParams = { + source: SOURCE_CAIPID, + destination: DESTINATION_CHAIN_ID, + destinationAddress: DESTINATION_ADDRESS, + amount: AMOUNT, + resource: RESOURCE_ID, + utxoData: mapedUtxos, + publicKey: derivedNode.publicKey, + typeOfAddress: TypeOfAddress.P2WPKH, + network: networks.testnet, + changeAddress: ADDRESS, + feeRate: BigInt(Math.ceil(feeRate)), + size: BigInt(size), + }; + + const transfer = await createBitcoinFungibleTransfer(transferParams); + + const psbt = transfer.getTransferTransaction(); + + console.log("Signing the transaction"); + + psbt.signAllInputs(derivedNode); + psbt.finalizeAllInputs(); + + console.log("Extracting the transaction"); + const tx = psbt.extractTransaction(true); + console.log("Transaction hex", tx.toHex()); + + const txId = await broadcastTransaction(tx.toHex()); + console.log("Transaction broadcasted", `${EXPLORER_URL}/tx/${txId}`); +} + +btcToEvmTransfer().finally(() => { }); diff --git a/examples/bitcoin-to-evm-fungible-transfer/tsconfig.json b/examples/bitcoin-to-evm-fungible-transfer/tsconfig.json new file mode 100644 index 000000000..4078b3ff7 --- /dev/null +++ b/examples/bitcoin-to-evm-fungible-transfer/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ES2022", + "allowJs": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "downlevelIteration": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/examples/evm-to-bitcoin-fungible-transfer/.env.sample b/examples/evm-to-bitcoin-fungible-transfer/.env.sample new file mode 100644 index 000000000..4bb371177 --- /dev/null +++ b/examples/evm-to-bitcoin-fungible-transfer/.env.sample @@ -0,0 +1,4 @@ +SEPOLIA_RPC_URL="SEPOLIA_RPC_URL_HERE" +BTC_DESTINATION_ADDRESS="YOUR_BTC_DESTINATION_ADDRESS" +PRIVATE_KEY="" +SYGMA_ENV=devnet \ No newline at end of file diff --git a/examples/evm-to-bitcoin-fungible-transfer/README.md b/examples/evm-to-bitcoin-fungible-transfer/README.md new file mode 100644 index 000000000..bc19ff05b --- /dev/null +++ b/examples/evm-to-bitcoin-fungible-transfer/README.md @@ -0,0 +1,81 @@ +# Sygma SDK EVM to BTC example + +This is an example that demonstrates functionality of the protocol using Sygma SDK. The src/transfer.ts script showcases how bitcoins can be transferred from an EVM Sepolia account utilizing `@buildwithsygma/evm` package. + +## Prerequisites + +Before running the script, ensure that you have the following: + +- Node.js +- Yarn (version 3.4.1 or higher) +- A development wallet funded with `sygBTC` tokens from the [Sygma faucet](https://faucet-ui-stage.buildwithsygma.com/) +- The [exported private key](https://support.metamask.io/hc/en-us/articles/360015289632-How-to-export-an-account-s-private-key) of your development wallet +- [Sepolia ETH](https://www.alchemy.com/faucets/ethereum-sepolia) for gas +- An Ethereum [provider](https://www.infura.io/) (in case the hardcoded RPC within the script does not work) + +## Getting started + +### 1. Clone the repository + +To get started, clone this repository to your local machine with: + +```bash +git clone git@github.com:sygmaprotocol/sygma-sdk.git +cd sygma-sdk/ +``` + +### 2. Install dependencies + +Install the project dependencies by running: + +```bash +yarn install +``` + +### 3. Build the sdk + +To start the example you need to build the sdk first with: + +```bash +yarn build:all +``` + +## Usage + +This example uses the `dotenv` module to manage private keys. To run the example, you will need to configure your environment variable to include your test development account's [exported private key](https://support.metamask.io/hc/en-us/articles/360015289632-How-to-export-an-account-s-private-key). A `.env.sample` is provided as a template. + +**DO NOT COMMIT PRIVATE KEYS WITH REAL FUNDS TO GITHUB. DOING SO COULD RESULT IN COMPLETE LOSS OF YOUR FUNDS.** + +Create a `.env` file in the evm-to-evm example folder: + +```bash +cd examples/evm-to-btc-fungible-transfer +touch .env +``` + +Replace between the quotation marks your exported private key: + +`PRIVATE_KEY="YOUR_PRIVATE_KEY_HERE"` + +To send an ERC20 example transfer run: + +```bash +yarn run transfer +``` + +The example will use `ethers` in conjuction with the sygma-sdk to +create a transfer from `Sepolia` to `Testnet BTC` with a test sygBTC token. + +Replace the placeholder values in the `.env` file with your own Ethereum wallet private key. + +## Script Functionality + +This example script performs the following steps: +- initializes the SDK and establishes a connection to the Ethereum provider. +- retrieves the list of supported domains and resources from the SDK configuration. +- Searches for the sygBTC token resource with the specified symbol +- Searches for the Bitcoin Testnet and Sepolia domains in the list of supported domains based on their chain IDs and CaipId +- Constructs a transfer object that defines the details of the sygBTC token transfer +- Retrieves the fee required for the transfer from the SDK. +- Builds the necessary approval transactions for the transfer and sends them using the Ethereum wallet. The approval transactions are required to authorize the transfer of ERC20 token. +- Builds the final transfer transaction and sends it using the Ethereum wallet. diff --git a/examples/evm-to-bitcoin-fungible-transfer/package.json b/examples/evm-to-bitcoin-fungible-transfer/package.json new file mode 100644 index 000000000..e52dbefec --- /dev/null +++ b/examples/evm-to-bitcoin-fungible-transfer/package.json @@ -0,0 +1,35 @@ +{ + "name": "@buildwithsygma/sygma-sdk-evm-to-bitcoin-fungible-transfer", + "version": "0.1.0", + "type": "module", + "description": "Sygma sdk examples", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/sygmaprotocol/sygma-sdk" + }, + "keywords": [ + "sygma", + "sygmaprotocol", + "buildwithsygma", + "web3", + "bridge", + "bitcoin" + ], + "scripts": { + "transfer": "tsx src/transfer.ts" + }, + "author": "Sygmaprotocol Product Team", + "license": "LGPL-3.0-or-later", + "devDependencies": { + "dotenv": "^16.3.1", + "eslint": "8", + "ts-node": "10.9.1", + "typescript": "5.0.4" + }, + "dependencies": { + "@buildwithsygma/core": "workspace:^", + "@buildwithsygma/evm": "workspace:^", + "tsx": "^4.15.4" + } +} diff --git a/examples/evm-to-bitcoin-fungible-transfer/src/transfer.ts b/examples/evm-to-bitcoin-fungible-transfer/src/transfer.ts new file mode 100644 index 000000000..d525b620d --- /dev/null +++ b/examples/evm-to-bitcoin-fungible-transfer/src/transfer.ts @@ -0,0 +1,54 @@ +import { createEvmFungibleAssetTransfer } from "@buildwithsygma/evm"; +import dotenv from "dotenv"; +import { Wallet, providers } from "ethers"; +import Web3HttpProvider from "web3-providers-http"; + +dotenv.config(); + +const privateKey = process.env.PRIVATE_KEY; + +if (!privateKey) { + throw new Error("Missing environment variable: PRIVATE_KEY"); +} + +const SEPOLIA_CHAIN_ID = 11155111; +const BITCOIN_DOMAIN_CAIPID = "bip122:000000000933ea01ad0ee984209779ba"; +const RESOURCE_ID = "0x0000000000000000000000000000000000000000000000000000000000000700"; +const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL || "https://eth-sepolia-public.unifra.io" +const BTC_DESTINATION_ADDRESS = process.env.BTC_DESTINATION_ADDRESS; + +const explorerUrls: Record = { [SEPOLIA_CHAIN_ID]: 'https://sepolia.etherscan.io' }; +const getTxExplorerUrl = (params: { txHash: string; chainId: number }): string => `${explorerUrls[params.chainId]}/tx/${params.txHash}`; + +export async function erc20Transfer(): Promise { + const web3Provider = new Web3HttpProvider(SEPOLIA_RPC_URL); + const ethersWeb3Provider = new providers.Web3Provider(web3Provider); + const wallet = new Wallet(privateKey!, ethersWeb3Provider); + + const params = { + source: SEPOLIA_CHAIN_ID, + destination: BITCOIN_DOMAIN_CAIPID, + sourceNetworkProvider: web3Provider, + resource: RESOURCE_ID, + amount: BigInt(1) * BigInt(1e8), // or any amount to send + destinationAddress: BTC_DESTINATION_ADDRESS, + sourceAddress: await wallet.getAddress(), + }; + + const transfer = await createEvmFungibleAssetTransfer(params); + + const approvals = await transfer.getApprovalTransactions(); + console.log(`Approving Tokens (${approvals.length})...`); + for (const approval of approvals) { + const response = await wallet.sendTransaction(approval); + await response.wait(); + console.log(`Approved, transaction: ${getTxExplorerUrl({ txHash: response.hash, chainId: SEPOLIA_CHAIN_ID })}`); + } + + const transferTx = await transfer.getTransferTransaction(); + const response = await wallet.sendTransaction(transferTx); + await response.wait(); + console.log(`Deposited, transaction: ${getTxExplorerUrl({ txHash: response.hash, chainId: SEPOLIA_CHAIN_ID })}`); +} + +erc20Transfer().finally(() => { }); \ No newline at end of file diff --git a/examples/evm-to-bitcoin-fungible-transfer/tsconfig.json b/examples/evm-to-bitcoin-fungible-transfer/tsconfig.json new file mode 100644 index 000000000..4078b3ff7 --- /dev/null +++ b/examples/evm-to-bitcoin-fungible-transfer/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ES2022", + "allowJs": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "downlevelIteration": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 9f7598972..20826a41f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ "evm:lint": "yarn workspace @buildwithsygma/evm lint", "evm:lint:fix": "yarn workspace @buildwithsygma/evm lint:fix", "evm:test:unit": "yarn workspace @buildwithsygma/evm test:unit", + "bitcoin:build": "yarn workspace @buildwithsygma/bitcoin build:all", + "bitcoin:cleanDist": "yarn workspace @buildwithsygma/bitcoin clean", + "bitcoin:test": "yarn workspace @buildwithsygma/bitcoin test", + "bitcoin:lint": "yarn workspace @buildwithsygma/bitcoin lint", + "bitcoin:lint:fix": "yarn workspace @buildwithsygma/bitcoin lint:fix", "substrate:build": "yarn workspace @buildwithsygma/substrate build:all", "substrate:cleanDist": "yarn workspace @buildwithsygma/substrate clean", "substrate:test": "yarn workspace @buildwithsygma/substrate test", diff --git a/packages/bitcoin/.eslintrc.cjs b/packages/bitcoin/.eslintrc.cjs new file mode 100644 index 000000000..29c27c015 --- /dev/null +++ b/packages/bitcoin/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + extends: '../../.eslintrc.cjs', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + sourceType: 'module', + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/bitcoin/.gitignore b/packages/bitcoin/.gitignore new file mode 100644 index 000000000..15b2f08aa --- /dev/null +++ b/packages/bitcoin/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist +/dist-cjs +/dist-esm +/types + + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.env +.vscode +.history + +# runtime config +public/chainbridge-runtime-config.js + +# IDE +.idea/ + +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions \ No newline at end of file diff --git a/packages/bitcoin/.npmignore b/packages/bitcoin/.npmignore new file mode 100644 index 000000000..f98c1c55b --- /dev/null +++ b/packages/bitcoin/.npmignore @@ -0,0 +1,26 @@ + + +integration/ +coverage/ +dist/ +keysN1/ +keysN2/ +config/ +src/ + +# Ignore configs +setupTests.ts +.eslintrc.cjs +.prettierrc.json +docker-compose.yml +Dockerfile +jest.config.cjs +tsconfig.json +tsconfig.*.json +tsconfig.tsbuildinfo +rollup.config.js +**/__test__/* + +# Ignore random junk +.DS_Store +node_modules/** \ No newline at end of file diff --git a/packages/bitcoin/.prettierrc.json b/packages/bitcoin/.prettierrc.json new file mode 100644 index 000000000..c50ce2de0 --- /dev/null +++ b/packages/bitcoin/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "useTabs": false, + "arrowParens": "avoid" +} diff --git a/packages/bitcoin/README.md b/packages/bitcoin/README.md new file mode 100644 index 000000000..2cd20cd9a --- /dev/null +++ b/packages/bitcoin/README.md @@ -0,0 +1,138 @@ +## Introduction + +This package provides the latest typescript Bitcoin SDK for building products using Sygma Protocol. + +## Installation + +``` +yarn add @buildwithsygma/bitcoin +``` + +or + +``` +npm install @buildwithsygma/bitcoin +``` + +## Environment Setup + +Make sure to set environment variable `SYGMA_ENV` to either `TESTNET` or `MAINNET` prior to using the SDK. + +## Support. + +Bridge configuration and list of supported networks for each environment can be found at: [Sygma bridge shared configuration github](https://github.com/sygmaprotocol/sygma-shared-configuration) + +## Usage + +### Bitcoin transfer + +#### Preparations + +You can use our utils to fetch the UTXOS needed from your address to transfer the assets: + +```typescript +import { + broadcastTransaction, + fetchUTXOS, + getFeeEstimates, + processUtxos, +} from '@buildwithsygma/utils'; +import type { UTXOData } from '@buildwithsygma/bitcoin'; + +// Either P2TR address or P2WPKH address +const utxos = await fetchUTXOS('tb1...' as unknown as string); + +const processedUtxos = processUtxos(utxos, 1e8); + +// Here we map the UTXOs to match the shape that the SDK expect, to use them as inputs in the transaction +const mapedUtxos = processedUtxos.map(utxo => ({ + utxoTxId: utxo.txid, + utxoOutputIndex: utxo.vout, + utxoAmount: BigInt(utxo.value), +})) as unknown as UTXOData[]; + +// You can also get estimation of the fees per block confirmation in either testnet or mainnet +const feeRate = await getFeeEstimates('5'); +``` + +#### Pay to Taproot transfer + +```typescript +import { createBitcoinFungibleTransfer, TypeOfAddress } from '@buildwithsygma/bitcoin'; +import type { BitcoinTransferParams, UTXOData } from '@buildwithsygma/bitcoin'; +import { networks } from 'bitcoinjs-lib'; +import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; + +// Here we create the public key needed for the P2TR transfer. You can reference our bitcoin-to-evm example to check the steps to get the public key and the signer +// derivedNode.publicKey is one of your account given the derivation path that you provide +const publicKeyDropedDERHeader = toXOnly(derivedNode.publicKey); + +const transferParams: BitcoinTransferParams = { + source: 'bip122:000000000933ea01ad0ee984209779ba', + destination: 11155111, + destinationAddress: '0x...', // evm address here + amount: 1e8, + resource: '0x0000000000000000000000000000000000000000000000000000000000000300', + utxoData: mapedUtxos, + feeRate: BigInt(Math.ceil(feeRate)), + publicKey: publicKeyDropedDERHeader, + typeOfAddress: TypeOfAddress.P2TR, + network: networks.testnet, + changeAddress: 'tb1...', // the address to receive the amount that's left after the transaction + size: BigInt(size), // size of the transfer, either based on other transfer, some calculation or the number you want to input here +}; + +const transfer = await createBitcoinFungibleTransfer(transferParams); + +const psbt = transfer.getTransferTransaction(); + +psbt.signAllInputs(signer); // tweaked signer, check our example for further reference on how to get this for P2TR transfers +psbt.finalizeAllInputs(); +const tx = psbt.extractTransaction(true); + +// You can check the hex encoded raw signed transaction +console.log('Transaction hex', tx.toHex()); +``` + +#### Pay to Witness Public Key Hash + +```typescript +import type { BitcoinTransferParams, UTXOData } from '@buildwithsygma/bitcoin'; +import { createBitcoinFungibleTransfer, TypeOfAddress } from '@buildwithsygma/bitcoin'; +import { networks } from 'bitcoinjs-lib'; + +const derivedNode = rootKey.derivePath('your-derivation-path'); + +const transferParams: BitcoinTransferParams = { + source: 'bip122:000000000933ea01ad0ee984209779ba', + destination: 11155111, + destinationAddress: '0x...', // evm address here + amount: 1e8, + resource: '0x0000000000000000000000000000000000000000000000000000000000000300', + utxoData: mapedUtxos, + publicKey: derivedNode.publicKey, + typeOfAddress: TypeOfAddress.P2WPKH, + network: networks.testnet, + changeAddress: 'tb1...', // the address to receive the amount that's left after the transaction + feeRate: BigInt(Math.ceil(feeRate)), + size: BigInt(size), // size of the transfer, either based on other transfer, some calculation or the number you want to input here +}; + +const transfer = await createBitcoinFungibleTransfer(transferParams); + +const psbt = transfer.getTransferTransaction(); + +psbt.signAllInputs(signer); // signer, check our example for further reference on how to get this for P2WPKH transfers +psbt.finalizeAllInputs(); +const tx = psbt.extractTransaction(true); + +// You can check the hex encoded raw signed transaction +console.log('Transaction hex', tx.toHex()); +``` + +## Examples + +The SDK monorepo contains the following examples demonstrating the usage of EVM Package: + +1. [Pay to Taproot address](https://github.com/sygmaprotocol/sygma-sdk/tree/main/examples/bitcoin-to-evm-fungible-transfer) +2. [Pay to Witnes Public Key Hahs](https://github.com/sygmaprotocol/sygma-sdk/tree/main/examples/evm-to-bitcoin-fungible-transfer) diff --git a/packages/bitcoin/jest.config.cjs b/packages/bitcoin/jest.config.cjs new file mode 100644 index 000000000..c85c0e8fd --- /dev/null +++ b/packages/bitcoin/jest.config.cjs @@ -0,0 +1,19 @@ +module.exports = { + roots: ['/src', '/test'], + extensionsToTreatAsEsm: ['.ts', '.tsx'], + verbose: true, + preset: 'ts-jest/presets/default-esm', + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + testEnvironment: 'node', + testTimeout: 15000, + transform: { + '^.+\\.(ts|tsx)?$': ['ts-jest', { useESM: true }], + }, + testPathIgnorePatterns: ['./dist'], + automock: false, + setupFiles: [ + "/test/setupJest.js" + ] + }; diff --git a/packages/bitcoin/package.json b/packages/bitcoin/package.json new file mode 100644 index 000000000..a27faccbc --- /dev/null +++ b/packages/bitcoin/package.json @@ -0,0 +1,66 @@ +{ + "name": "@buildwithsygma/bitcoin", + "version": "1.0.0", + "description": "Core primitives for bridging and message passing", + "main": "dist-esm/index.js", + "types": "types/index.d.ts", + "exports": { + ".": { + "import": "./dist-esm/index.js", + "require": "./dist-cjs/index.js" + } + }, + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/sygmaprotocol/sygma-sdk" + }, + "scripts": { + "test": "jest --watchAll --detectOpenHandles", + "test:unit": "jest --detectOpenHandles", + "run:all": "concurrently \"yarn run prepareNodes\" \"yarn run test\"", + "build:esm": "tsc -p tsconfig.esm.json && echo '{\"type\": \"module\"}' > ./dist-esm/package.json", + "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > ./dist-cjs/package.json", + "build:types": "tsc -p tsconfig.types.json", + "build:all": "yarn build:esm && yarn build:cjs && yarn build:types", + "build:typedocs:html": "typedoc --options typedoc.json", + "build:typedocs:markdown": "typedoc --options typedoc.markdown.json", + "build:typedocs:asjson": "typedoc --options typedoc.asjson.json", + "lint": "eslint 'src/**/*.ts'", + "lint:fix": "yarn run lint --fix", + "run:nodes": "docker-compose -f docker-compose.yml up", + "clean": "rm -rf ./dist ./dist-cjs ./dist-esm ./types", + "watch": "tsc -w -p tsconfig.esm.json && echo '{\"type\": \"module\"}' > ./dist-esm/package.json" + }, + "keywords": [ + "sygma", + "sygmaprotocol", + "buildwithsygma", + "web3", + "bridge", + "bitcoin" + ], + "author": "Sygmaprotocol Product Team", + "license": "LGPL-3.0-or-later", + "devDependencies": { + "@types/jest": "^29.4.0", + "concurrently": "7.0.0", + "eslint": "8", + "hardhat": "2.8.2", + "jest": "^29.4.1", + "jest-environment-jsdom": "^29.4.1", + "jest-extended": "1.2.0", + "jest-fetch-mock": "^3.0.3", + "tiny-secp256k1": "2.2.3", + "ts-jest": "^29.0.5", + "ts-node": "10.9.1", + "typedoc": "^0.24.1", + "typedoc-plugin-markdown": "^3.15.1", + "typescript": "5.0.4" + }, + "dependencies": { + "@buildwithsygma/core": "workspace:^", + "bitcoinjs-lib": "^6.1.6" + } +} diff --git a/packages/bitcoin/src/__test__/fungible.test.ts b/packages/bitcoin/src/__test__/fungible.test.ts new file mode 100644 index 000000000..75f2fcd51 --- /dev/null +++ b/packages/bitcoin/src/__test__/fungible.test.ts @@ -0,0 +1,231 @@ +import { Config } from '@buildwithsygma/core'; +import * as bitcoin from 'bitcoinjs-lib'; +import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; +import * as tinysecp from 'tiny-secp256k1'; + +import { createBitcoinFungibleTransfer } from '../fungible.js'; +import type { BitcoinTransferParams } from '../types.js'; +import { TypeOfAddress } from '../types.js'; + +bitcoin.initEccLib(tinysecp); + +const P2TR_TRANSFER_PARAMS: BitcoinTransferParams = { + sourceAddress: 'tb1pxmrzd94rs6wtg6ewdjfmuu7s88n2kdqc20vzfmadanfaem3n9sdq0vagu0', + source: 'bip122:000000000933ea01ad0ee984209779ba', + destination: 1, + destinationAddress: '0x98729c03c4D5e820F5e8c45558ae07aE63F97461', + amount: BigInt(90000000), + resource: '0x0000000000000000000000000000000000000000000000000000000000000300', + utxoData: [ + { + utxoTxId: 'dbcd2f7e54392fbfeca85d15ce405dfecddc65c42e6f72a1b84c79dcd2eb7e7c', + utxoAmount: BigInt(100000000), + utxoOutputIndex: 0, + }, + ], + publicKey: toXOnly( + Buffer.from('03feca449bd5b50085d23864a006f6ea4da80ff63816033f6437193c66bac7488c', 'hex'), + ), + typeOfAddress: TypeOfAddress.P2TR, + network: bitcoin.networks.testnet, + changeAddress: 'tb1pxmrzd94rs6wtg6ewdjfmuu7s88n2kdqc20vzfmadanfaem3n9sdq0vagu0', + feeRate: BigInt(103), + size: BigInt(400), +}; + +const P2PWKH_TRANSFER_PARAMS: BitcoinTransferParams = { + ...P2TR_TRANSFER_PARAMS, + typeOfAddress: TypeOfAddress.P2WPKH, + publicKey: Buffer.from( + '03feca449bd5b50085d23864a006f6ea4da80ff63816033f6437193c66bac7488c', + 'hex', + ), +}; + +const MOCKED_CONFIG = { + init: jest.fn(), + getDomainConfig: jest + .fn() + .mockReturnValue({ bridge: '', caipId: 'bip122:000000000933ea01ad0ee984209779ba' }), + getDomain: jest.fn().mockReturnValue({ + caipId: 'bip122:000000000933ea01ad0ee984209779ba', + feeAddress: 'tb1p0r2w3ugreaggd7nakw2wd04up6rl8k0cce8eetxwmhnrelgqx87s4zdkd7', + }), + getResources: jest.fn().mockReturnValue([ + { + resourceId: '0x0000000000000000000000000000000000000000000000000000000000000300', + type: 'fungible', + address: 'tb1pxmrzd94rs6wtg6ewdjfmuu7s88n2kdqc20vzfmadanfaem3n9sdq0vagu0', + decimals: 8, + tweak: 'd97ae87c238a8a674bff71db5eeb69519dbd1c57bec70a89f7b06fa2d0e97841', + feeAmount: '1000000', + }, + ]), + findDomainConfigBySygmaId: jest + .fn() + .mockReturnValue({ caipId: 'bip122:000000000933ea01ad0ee984209779ba' }), +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('@buildwithsygma/core', () => ({ + ...jest.requireActual('@buildwithsygma/core'), + Config: jest.fn(), +})); + +describe('Fungible - createBitcoinFungibleTransfer', () => { + beforeAll(() => { + (Config as jest.Mock).mockReturnValue(MOCKED_CONFIG); + }); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + describe('Fungible - createBitcoinFungibleTransfer - P2TR', () => { + it('should create a bitcoin fungible transfer', async () => { + const transfer = await createBitcoinFungibleTransfer(P2TR_TRANSFER_PARAMS); + expect(transfer).toBeTruthy(); + }); + + it('should throw an error when resource is not found', async () => { + const transferParams = { ...P2TR_TRANSFER_PARAMS, resource: 'tb...' }; + const transfer = createBitcoinFungibleTransfer(transferParams); + await expect(transfer).rejects.toThrow('Resource not found.'); + }); + + it('should return PSBT instance', async () => { + const transfer = await createBitcoinFungibleTransfer(P2TR_TRANSFER_PARAMS); + const psbt = transfer.getTransferTransaction(); + expect(psbt).toBeTruthy(); + expect(psbt instanceof bitcoin.Psbt).toBeTruthy(); + }); + + it('should throw if the utxo amount is equal to the amount to transfer', async () => { + const transferParams = { ...P2TR_TRANSFER_PARAMS, amount: BigInt(100000000) }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + + it('should throw if the utxo amount is less than the amount to transfer', async () => { + const transferParams = { ...P2TR_TRANSFER_PARAMS, amount: BigInt(100000001) }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + + it('should throw if public key is incorrect', async () => { + const transferParams = { ...P2TR_TRANSFER_PARAMS, publicKey: Buffer.from('', 'hex') }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + + it('should throw if utxoData is partially defined', async () => { + const transferParams: BitcoinTransferParams = { + ...P2TR_TRANSFER_PARAMS, + utxoData: [ + { + utxoTxId: 'dbcd2f7e54392fbfeca85d15ce405dfecddc65c42e6f72a1b84c79dcd2eb7e7c', + }, + ] as unknown as BitcoinTransferParams['utxoData'], + }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + it('should throw given an array of multiples utxo and amount thats bigger than the sum of the values', async () => { + const transferParams: BitcoinTransferParams = { + ...P2TR_TRANSFER_PARAMS, + utxoData: [ + { + utxoTxId: 'dbcd2f7e54392fbfeca85d15ce405dfecddc65c42e6f72a1b84c79dcd2eb7e7c', + utxoAmount: BigInt(100000000), + utxoOutputIndex: 0, + }, + { + utxoTxId: '56796b5daa09cf5299784593a369669090d26cc70e96b6e5a1a510a417054b21', + utxoAmount: BigInt(100000000), + utxoOutputIndex: 0, + }, + ], + amount: BigInt(600000000), + }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + + it('should process multiple utxo inputs and create a valid PSBT', async () => { + const transferParams: BitcoinTransferParams = { + ...P2TR_TRANSFER_PARAMS, + utxoData: [ + { + utxoTxId: 'dbcd2f7e54392fbfeca85d15ce405dfecddc65c42e6f72a1b84c79dcd2eb7e7c', + utxoAmount: BigInt(100000000), + utxoOutputIndex: 0, + }, + { + utxoTxId: '56796b5daa09cf5299784593a369669090d26cc70e96b6e5a1a510a417054b21', + utxoAmount: BigInt(100000000), + utxoOutputIndex: 0, + }, + ], + amount: BigInt(3000000), + }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + const psbt = transfer.getTransferTransaction(); + expect(psbt).toBeTruthy(); + }); + }); + + describe('Fungible - createBitcoinFungibleTransfer - P2WPKH', () => { + it('should create a bitcoin fungible transfer', async () => { + const transfer = await createBitcoinFungibleTransfer(P2PWKH_TRANSFER_PARAMS); + expect(transfer).toBeTruthy(); + }); + + it('should throw an error when resource is not found', async () => { + const transferParams = { ...P2PWKH_TRANSFER_PARAMS, resource: 'tb...' }; + const transfer = createBitcoinFungibleTransfer(transferParams); + await expect(transfer).rejects.toThrow('Resource not found.'); + }); + + it('should return PSBT instance', async () => { + const transfer = await createBitcoinFungibleTransfer(P2PWKH_TRANSFER_PARAMS); + const psbt = transfer.getTransferTransaction(); + expect(psbt).toBeTruthy(); + expect(psbt instanceof bitcoin.Psbt).toBeTruthy(); + }); + + it('should throw if the utxo amount is equal to the amount to transfer', async () => { + const transferParams = { ...P2PWKH_TRANSFER_PARAMS, amount: BigInt(100000000) }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + + it('should throw if the utxo amount is less than the amount to transfer', async () => { + const transferParams = { ...P2PWKH_TRANSFER_PARAMS, amount: BigInt(100000001) }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + expect(() => transfer.getTransferTransaction()).toThrow( + 'Not enough funds to spend from the UTXO', + ); + }); + + it('should throw if public key is incorrect', async () => { + const transferParams = { ...P2PWKH_TRANSFER_PARAMS, publicKey: Buffer.from('', 'hex') }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + + it('should throw if utxoData is partially defined', async () => { + const transferParams: BitcoinTransferParams = { + ...P2PWKH_TRANSFER_PARAMS, + utxoData: [ + { + utxoTxId: 'dbcd2f7e54392fbfeca85d15ce405dfecddc65c42e6f72a1b84c79dcd2eb7e7c', + }, + ] as unknown as BitcoinTransferParams['utxoData'], + }; + const transfer = await createBitcoinFungibleTransfer(transferParams); + + expect(() => transfer.getTransferTransaction()).toThrow(); + }); + }); +}); diff --git a/packages/bitcoin/src/environment.d.ts b/packages/bitcoin/src/environment.d.ts new file mode 100644 index 000000000..f7f28e351 --- /dev/null +++ b/packages/bitcoin/src/environment.d.ts @@ -0,0 +1,9 @@ +import type { Environment } from '@buildwithsygma/core'; + +declare global { + namespace NodeJS { + interface ProcessEnv { + SYGMA_ENV: Environment; + } + } +} diff --git a/packages/bitcoin/src/fungible.ts b/packages/bitcoin/src/fungible.ts new file mode 100644 index 000000000..3fef457a8 --- /dev/null +++ b/packages/bitcoin/src/fungible.ts @@ -0,0 +1,71 @@ +import { BaseTransfer, Config, Environment } from '@buildwithsygma/core'; +import type { Config as TConfig, BitcoinResource } from '@buildwithsygma/core/types'; +import type { networks } from 'bitcoinjs-lib'; + +import type { + BitcoinTransferParams, + BitcoinTransaction, + TypeOfAddress, + UTXOData, +} from './types.js'; +import { getPsbt } from './utils/index.js'; + +export async function createBitcoinFungibleTransfer( + params: BitcoinTransferParams, +): Promise { + const config = new Config(); + await config.init(process.env.SYGMA_ENV || Environment.MAINNET); + return new BitcoinFungibleTransfer(params, config); +} + +class BitcoinFungibleTransfer extends BaseTransfer { + protected publicKey: Buffer; + protected typeOfAddress: TypeOfAddress; + protected network: networks.Network; + protected changeAddress?: string; + protected feeRate: bigint; + protected utxoData: UTXOData[]; + protected size: bigint; + protected destinationAddress: string; + protected amount: bigint; + protected feeAddress: string; + protected feeAmount: bigint; + + constructor(transfer: BitcoinTransferParams, config: TConfig) { + super(transfer, config); + this.destinationAddress = transfer.destinationAddress; + this.amount = transfer.amount; + this.publicKey = transfer.publicKey; + this.typeOfAddress = transfer.typeOfAddress; + this.network = transfer.network; + this.changeAddress = transfer.changeAddress; + this.feeRate = transfer.feeRate; + this.utxoData = transfer.utxoData; + this.size = transfer.size; + + this.feeAddress = this.sourceDomain.feeAddress as string; + this.feeAmount = BigInt((this.resource as BitcoinResource).feeAmount!); + } + + getTransferTransaction(): BitcoinTransaction { + return getPsbt( + { + source: this.sourceDomain.caipId, + destination: this.destinationDomain.id, + destinationAddress: this.destinationAddress, + amount: this.amount, + resource: this.resource.resourceId, + utxoData: this.utxoData, + publicKey: this.publicKey, + typeOfAddress: this.typeOfAddress, + network: this.network, + feeRate: this.feeRate, + changeAddress: this.changeAddress, + size: this.size, + }, + this.feeAddress, + (this.resource as BitcoinResource).address, + this.feeAmount, + ); + } +} diff --git a/packages/bitcoin/src/index.ts b/packages/bitcoin/src/index.ts new file mode 100644 index 000000000..d88873593 --- /dev/null +++ b/packages/bitcoin/src/index.ts @@ -0,0 +1,2 @@ +export * from './fungible.js'; +export * from './types.js'; diff --git a/packages/bitcoin/src/types.ts b/packages/bitcoin/src/types.ts new file mode 100644 index 000000000..a54db36e3 --- /dev/null +++ b/packages/bitcoin/src/types.ts @@ -0,0 +1,62 @@ +import type { BaseTransferParams } from '@buildwithsygma/core'; +import type { networks, Psbt } from 'bitcoinjs-lib'; + +export enum TypeOfAddress { + P2WPKH = 'P2WPKH', + P2TR = 'P2TR', +} + +export type UTXOData = { + utxoTxId: string; + utxoAmount: bigint; + utxoOutputIndex: number; +}; + +export type CreateInputData = { + utxoData: UTXOData; + publicKey: Buffer; + network: networks.Network; + typeOfAddress: TypeOfAddress; +}; + +export interface BitcoinTransferParams extends BaseTransferParams { + destinationAddress: string; + amount: bigint; + utxoData: UTXOData[]; + publicKey: Buffer; + typeOfAddress: TypeOfAddress; + network: networks.Network; + feeRate: bigint; + changeAddress?: string; + size: bigint; +} + +export type CreatePsbtParams = Pick< + BitcoinTransferParams, + | 'source' + | 'destination' + | 'destinationAddress' + | 'amount' + | 'resource' + | 'utxoData' + | 'publicKey' + | 'typeOfAddress' + | 'network' + | 'feeRate' + | 'changeAddress' + | 'size' +>; + +export type BitcoinTransaction = Psbt; + +export type PaymentReturnData = { + output: Buffer; + address?: string; +}; + +export type BitcoinTransferInputData = { + hash: string | Buffer; + index: number; + witnessUtxo: { value: number; script: Buffer }; + tapInternalKey?: Buffer; +}; diff --git a/packages/bitcoin/src/utils/helpers.ts b/packages/bitcoin/src/utils/helpers.ts new file mode 100644 index 000000000..8373ac1ee --- /dev/null +++ b/packages/bitcoin/src/utils/helpers.ts @@ -0,0 +1,203 @@ +import type { networks, Payment } from 'bitcoinjs-lib'; +import { payments, Psbt } from 'bitcoinjs-lib'; + +import { TypeOfAddress } from '../types.js'; +import type { + BitcoinTransferInputData, + BitcoinTransaction, + PaymentReturnData, + UTXOData, + CreateInputData, + CreatePsbtParams, +} from '../types.js'; + +/** + * Get the scriptPubKey for the given public key and network + * + * @category Helpers + * @param typeOfAddress - type of address to use: currently p2wpkh or p2tr + * @param publicKey - public of the signer + * @param network - network to use + * @returns {scriptPubKey: Buffer} + */ +export function getScriptPubkey( + typeOfAddress: TypeOfAddress, + publicKey: Buffer, + network: networks.Network, +): { + scriptPubKey: Buffer; +} { + const { output } = + typeOfAddress === TypeOfAddress.P2WPKH + ? (payments.p2wpkh({ + pubkey: publicKey, + network, + }) as PaymentReturnData) + : (payments.p2tr({ + internalPubkey: publicKey, + network, + }) as PaymentReturnData); + + return { scriptPubKey: output }; +} + +/** + * Encode the deposit address and the domain id to pass into the OP_RETURN output + * + * @category Helpers + * @param depositAddress - address to deposit + * @param destinationDomainId - destination domain id + * @returns {Payment} + */ +function encodeDepositAddress(depositAddress: string, destinationDomainId: number): Payment { + return payments.embed({ + data: [Buffer.from(`${depositAddress}_${destinationDomainId}`)], + }); +} + +/** + * Create the input data for the PSBT + * + * @category Helpers + * @param utxoData - UTXO data + * @param publicKey - public key of the signer + * @param network - network to use + * @param typeOfAddress - type of address to use + * @returns {BitcoinTransferInputData} + */ +export function createInputData({ + utxoData: { utxoTxId, utxoOutputIndex, utxoAmount }, + publicKey, + network, + typeOfAddress, +}: CreateInputData): BitcoinTransferInputData { + if (typeOfAddress !== TypeOfAddress.P2TR) { + return { + hash: utxoTxId as unknown as Buffer, + index: utxoOutputIndex, + witnessUtxo: { + script: getScriptPubkey(typeOfAddress, publicKey, network).scriptPubKey, + value: Number(utxoAmount), + }, + }; + } + return { + hash: utxoTxId, + index: utxoOutputIndex, + witnessUtxo: { + script: getScriptPubkey(typeOfAddress, publicKey, network).scriptPubKey as unknown as Buffer, + value: Number(utxoAmount), + }, + tapInternalKey: publicKey, + }; +} + +/** + * Check if the amount to transfer is valid + * @category Helpers + * @param amount - amount to transfer + * @param utxoData - UTXO data + * @returns {boolean} + */ +const isValidAmount = (amount: bigint, utxoData: UTXOData[]): boolean => { + return utxoData.reduce((acc, curr) => acc + curr.utxoAmount, BigInt(0)) <= amount; +}; + +/** + * Create the PSBT for the transfer using the input data supplied and adding the ouputs to use for the transaction + * + * @category Helpers + * @param params - params to create the PSBT + * @param feeAddress - fee handler address on BTC + * @param depositAddress - bridge address on BTC + * @param feeAmount - fee amount to be paid + * @returns {BitcoinTransferRequest} + */ +export function getPsbt( + params: CreatePsbtParams, + feeAddress: string, + depositAddress: string, + feeAmount: bigint, +): BitcoinTransaction { + if (!['P2WPKH', 'P2TR'].includes(params.typeOfAddress.toString())) { + throw new Error('Unsuported address type'); + } + + if (params.utxoData.length === 0) { + throw new Error('UTXO data is required'); + } + + if (isValidAmount(params.amount, params.utxoData)) { + throw new Error('Not enough funds to spend from the UTXO'); + } + + const psbt = new Psbt({ network: params.network }); + + if (params.utxoData.length !== 1) { + params.utxoData.forEach(utxo => { + psbt.addInput( + createInputData({ + utxoData: utxo, + publicKey: params.publicKey, + network: params.network, + typeOfAddress: params.typeOfAddress, + }), + ); + }); + } else { + psbt.addInput( + createInputData({ + utxoData: params.utxoData[0], + publicKey: params.publicKey, + network: params.network, + typeOfAddress: params.typeOfAddress, + }), + ); + } + + // OP_RETURN output + psbt.addOutput({ + script: encodeDepositAddress(params.destinationAddress, Number(params.destination)) + .output as unknown as Buffer, + value: 0, + }); + + // Fee output + psbt.addOutput({ + address: feeAddress, + value: Number(feeAmount), + }); + + const minerFee = Math.floor(Number(params.feeRate) * Number(params.size)); + + let amountToSpent: number; + if (params.utxoData.length !== 1) { + amountToSpent = + params.utxoData.reduce((acc, curr) => acc + Number(curr.utxoAmount), 0) - + Number(feeAmount) - + minerFee; + } else { + amountToSpent = Number(params.utxoData[0].utxoAmount) - Number(feeAmount) - minerFee; + } + + if (amountToSpent < params.amount) { + throw new Error('Not enough funds'); + } + + // Amount to bridge + psbt.addOutput({ + address: depositAddress, + value: Number(params.amount), + }); + + if (params.changeAddress && amountToSpent > params.amount) { + const change = Number(amountToSpent) - Number(params.amount); + + psbt.addOutput({ + address: params.changeAddress, + value: change, + }); + } + + return psbt; +} diff --git a/packages/bitcoin/src/utils/index.ts b/packages/bitcoin/src/utils/index.ts new file mode 100644 index 000000000..2942c10a7 --- /dev/null +++ b/packages/bitcoin/src/utils/index.ts @@ -0,0 +1 @@ +export * from './helpers.js'; diff --git a/packages/bitcoin/test/setupJest.js b/packages/bitcoin/test/setupJest.js new file mode 100644 index 000000000..a048a41d0 --- /dev/null +++ b/packages/bitcoin/test/setupJest.js @@ -0,0 +1,2 @@ +// setupJest.js or similar file +require('jest-fetch-mock').enableMocks(); \ No newline at end of file diff --git a/packages/bitcoin/tsconfig.cjs.json b/packages/bitcoin/tsconfig.cjs.json new file mode 100644 index 000000000..000327dd4 --- /dev/null +++ b/packages/bitcoin/tsconfig.cjs.json @@ -0,0 +1,13 @@ +{ + "exclude": [ + "src/**/__test__/**", + "test/**", + ], + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "esModuleInterop": true, + "module": "commonjs", + "outDir": "./dist-cjs" + } +} diff --git a/packages/bitcoin/tsconfig.esm.json b/packages/bitcoin/tsconfig.esm.json new file mode 100644 index 000000000..d8c28947b --- /dev/null +++ b/packages/bitcoin/tsconfig.esm.json @@ -0,0 +1,12 @@ +{ + "exclude": [ + "src/**/__test__/**", + "test/**" + ], + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2020", + "esModuleInterop": true, + "outDir": "./dist-esm" + } +} diff --git a/packages/bitcoin/tsconfig.json b/packages/bitcoin/tsconfig.json new file mode 100644 index 000000000..cf1659b0d --- /dev/null +++ b/packages/bitcoin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": ["node_modules/**"], + "include": ["./src/**/*.ts"] +} diff --git a/packages/bitcoin/tsconfig.types.json b/packages/bitcoin/tsconfig.types.json new file mode 100644 index 000000000..cc2946320 --- /dev/null +++ b/packages/bitcoin/tsconfig.types.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "emitDeclarationOnly": true, + "outDir": "types" + }, + "extends": "./tsconfig.json", + "exclude": ["test", "src/**/__test__/**"] +} diff --git a/packages/btc/src/base-transfer.ts b/packages/btc/src/base-transfer.ts deleted file mode 100644 index 6d770abaa..000000000 --- a/packages/btc/src/base-transfer.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import type { BitcoinResource } from "@buildwithsygma/core/src"; -import type { Config } from "@buildwithsygma/core/types"; - -type BaseTransferParams = { - destinationAddress: string; - amount: bigint; -}; - -type BitcoinTransferRequest = { - destinationAddress: string; - amount: bigint; - depositAddress: string; -}; - -export function createBitcoinTransferRequest( - params: BaseTransferParams, -): Promise { - throw new Error("Method not implemented"); -} - -export abstract class BaseTransfer { - constructor(transfer: BaseTransferParams, config: Config) {} - - private findResource( - resources: BitcoinResource[], - resourceIdentifier: string | BitcoinResource, - ): BitcoinResource | undefined { - throw new Error("Method not implemented"); - } - - /** - * Set resource to be transferred. - * @param {BitcoinResource} resource - */ - setResource(resource: BitcoinResource): void { - throw new Error("Method not implemented"); - } - - getUriEncodedUtxoRequest(btcTransferRequest: BaseTransferParams): string { - throw new Error("Method not implemented"); - } - - getBTCTransferRequest(): BitcoinTransferRequest { - throw new Error("Method not implemented"); - } -} diff --git a/packages/btc/src/index.ts b/packages/btc/src/index.ts deleted file mode 100644 index a94fd7c4c..000000000 --- a/packages/btc/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./base-transfer.js"; \ No newline at end of file diff --git a/packages/core/src/baseTransfer.ts b/packages/core/src/baseTransfer.ts index 8e451959d..088fbcf57 100644 --- a/packages/core/src/baseTransfer.ts +++ b/packages/core/src/baseTransfer.ts @@ -1,17 +1,23 @@ import type { Config } from './config/config.js'; -import type { Domain, Domainlike, EvmResource, SubstrateResource } from './types.js'; +import type { + Domainlike, + EvmResource, + Domain, + SubstrateResource, + BitcoinResource, +} from './types.js'; export interface BaseTransferParams { source: Domainlike; destination: Domainlike; - resource: string | EvmResource | SubstrateResource; + resource: string | EvmResource | SubstrateResource | BitcoinResource; sourceAddress: string; } export abstract class BaseTransfer { protected destinationDomain: Domain; protected sourceDomain: Domain; - protected transferResource: EvmResource | SubstrateResource; + protected transferResource: EvmResource | SubstrateResource | BitcoinResource; protected sygmaConfiguration: Config; protected sourceAddress: string; @@ -23,7 +29,7 @@ export abstract class BaseTransfer { return this.destinationDomain; } - public get resource(): EvmResource | SubstrateResource { + public get resource(): EvmResource | SubstrateResource | BitcoinResource { return this.transferResource; } @@ -32,8 +38,8 @@ export abstract class BaseTransfer { } private findResource( - resource: string | EvmResource | SubstrateResource, - ): EvmResource | SubstrateResource | undefined { + resource: string | EvmResource | SubstrateResource | BitcoinResource, + ): EvmResource | SubstrateResource | BitcoinResource | undefined { return this.sygmaConfiguration.getResources(this.source).find(_resource => { return typeof resource === 'string' ? resource === _resource.resourceId @@ -66,10 +72,10 @@ export abstract class BaseTransfer { } /** * Set resource to be transferred - * @param {EvmResource} resource + * @param {EvmResource | SubstrateResource | BitcoinResource} resource * @returns {BaseTransfer} */ - setResource(resource: EvmResource | SubstrateResource): void { + setResource(resource: EvmResource | SubstrateResource | BitcoinResource): void { this.transferResource = resource; } /** diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 46b299f92..a7e505a1b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1,5 +1,6 @@ import { ConfigUrl } from '../index.js'; import type { + BitcoinConfig, Domain, Domainlike, EthereumConfig, @@ -7,6 +8,7 @@ import type { Resource, SubstrateConfig, SygmaConfig, + SygmaDomainConfig, } from '../types.js'; import { Environment } from '../types.js'; @@ -52,10 +54,10 @@ export class Config { } /** * Creates a domain object from config object - * @param {EthereumConfig | SubstrateConfig} config + * @param {SygmaDomainConfig} config * @returns {Domain} */ - private createDomain(config: EthereumConfig | SubstrateConfig): Domain { + private createDomain(config: SygmaDomainConfig): Domain { return { id: config.id, caipId: config.caipId, @@ -63,6 +65,8 @@ export class Config { name: config.name, type: config.type, parachainId: (config as SubstrateConfig).parachainId, + // used in bitcoin transfers + feeAddress: (config as BitcoinConfig).feeAddress, }; } /** @@ -72,7 +76,7 @@ export class Config { * @param {number} sygmaId * @returns {SubstrateConfig | EthereumConfig} */ - findDomainConfigBySygmaId(sygmaId: number): SubstrateConfig | EthereumConfig { + findDomainConfigBySygmaId(sygmaId: number): SygmaDomainConfig { const domainConfig = this.configuration.domains.find(domain => domain.id === sygmaId); if (!domainConfig) throw new Error(`Domain with sygmaId: ${sygmaId} not found.`); return domainConfig; @@ -81,9 +85,9 @@ export class Config { * Find configuration of the domain * existing in current sygma configuration * @param {Domainlike} domainLike - * @returns {{ config: SubstrateConfig | EthereumConfig | undefined; environment: Environment; }} + * @returns {{ config: SygmaDomainConfig | undefined; environment: Environment; }} */ - findDomainConfig(domainLike: Domainlike): SubstrateConfig | EthereumConfig { + findDomainConfig(domainLike: Domainlike): SygmaDomainConfig { const config = this.configuration.domains.find(domain => { switch (typeof domainLike) { case 'string': @@ -122,7 +126,7 @@ export class Config { * @param {Domainlike} domainLike chain id, caip id or sygma id * @returns {SubstrateConfig | EthereumConfig} */ - getDomainConfig(domainLike: Domainlike): SubstrateConfig | EthereumConfig { + getDomainConfig(domainLike: Domainlike): SubstrateConfig | EthereumConfig | BitcoinConfig { if (!this.initialized) throw new Error('SDK Uninitialized'); const domainConfig = this.findDomainConfig(domainLike); if (!domainConfig) throw new Error('Domain configuration not found.'); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 184b0004e..de3342c5a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,7 +15,7 @@ export enum Environment { export enum Network { EVM = 'evm', SUBSTRATE = 'substrate', - BITCOIN = 'bitcoin', + BITCOIN = 'btc', } export enum SecurityModel { @@ -31,9 +31,10 @@ export type Domain = { iconUrl?: string; type: Network; parachainId?: ParachainId; + feeAddress?: string; }; -export type Resource = EvmResource | SubstrateResource; +export type Resource = EvmResource | SubstrateResource | BitcoinResource; export enum ResourceType { FUNGIBLE = 'fungible', @@ -76,8 +77,10 @@ export type XcmMultiAssetIdType = { }; export type BitcoinResource = BaseResource & { + address: string; script: string; tweak: string; + feeAmount?: number; }; export type SubstrateResource = BaseResource & { @@ -136,6 +139,12 @@ export interface SubstrateConfig extends BaseConfig { parachainId: ParachainId; } +export interface BitcoinConfig extends BaseConfig { + feeAddress: string; +} + +export type SygmaDomainConfig = EthereumConfig | SubstrateConfig | BitcoinConfig; + export type IndexerRoutesResponse = { routes: RouteIndexerType[] }; export type Handler = { @@ -144,7 +153,7 @@ export type Handler = { }; export interface SygmaConfig { - domains: Array; + domains: Array; } export type RouteIndexerType = { diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 4676a579b..c82d6b70f 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -294,8 +294,12 @@ export function isValidEvmAddress(address: string): boolean { * @returns {boolean} */ export function isValidBitcoinAddress(address: string): boolean { - if (process.env.SYGMA_ENV === Environment.TESTNET || process.env.SYGMA_ENV === Environment.DEVNET) + if ( + process.env.SYGMA_ENV === Environment.TESTNET || + process.env.SYGMA_ENV === Environment.DEVNET + ) { return validate(address, BitcoinNetwork.testnet); + } return validate(address, BitcoinNetwork.mainnet); } diff --git a/packages/evm/src/utils/__test__/helpers.test.ts b/packages/evm/src/utils/__test__/helpers.test.ts index 1a00a534b..aa24a1460 100644 --- a/packages/evm/src/utils/__test__/helpers.test.ts +++ b/packages/evm/src/utils/__test__/helpers.test.ts @@ -6,6 +6,7 @@ import { createERCDepositData, toHex, constructSubstrateRecipient, + addressToHex, serializeGenericCallParameters, } from '../helpers.js'; @@ -31,6 +32,16 @@ describe('createERCDepositData', () => { expect(depositData).toEqual(expectedDepositData); }); + + it('should return the correct deposit data - bitcoin', () => { + const tokenAmount = BigInt(100); + const recipientAddress = 'tb1qsfyzl92pv7wkyaj0tfjdtwvcsj840p004jglvp'; + const expectedDepositData = + '0x0000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000002a746231717366797a6c3932707637776b79616a3074666a6474777663736a383430703030346a676c7670'; + + const depositData = createERCDepositData(tokenAmount, recipientAddress); + expect(depositData).toEqual(expectedDepositData); + }); }); describe('constructSubstrateRecipient', () => { @@ -132,3 +143,23 @@ describe('toHex', () => { expect(result).toBe('0x00000000000000000000000000000000000000000000be951906eba2aa800000'); }); }); + +describe('addressToHex', () => { + test('should convert p2tr address to hex', () => { + const address = 'tb1pnyh5nayrmwux72guec3xy7qryjjww6tu9mev3d5347lqcwgus4jsd95d2r'; + const expectedHex = + '0x746231706e7968356e6179726d777578373267756563337879377172796a6a7777367475396d65763364353334376c716377677573346a73643935643272'; + + const result = addressToHex(address); + expect(result).toEqual(expectedHex); + }); + + test('should convert p2wpkh address to hex', () => { + const address = 'tb1qsfyzl92pv7wkyaj0tfjdtwvcsj840p004jglvp'; + const expectedHex = + '0x746231717366797a6c3932707637776b79616a3074666a6474777663736a383430703030346a676c7670'; + + const result = addressToHex(address); + expect(result).toEqual(expectedHex); + }); +}); diff --git a/packages/evm/src/utils/helpers.ts b/packages/evm/src/utils/helpers.ts index 0ad7f4b9c..5e0830552 100644 --- a/packages/evm/src/utils/helpers.ts +++ b/packages/evm/src/utils/helpers.ts @@ -25,9 +25,13 @@ export const createERCDepositData = ( let recipientAddressInBytes; if (utils.isAddress(recipientAddress)) { recipientAddressInBytes = getEVMRecipientAddressInBytes(recipientAddress); - } else { + } else if (parachainId) { recipientAddressInBytes = getSubstrateRecipientAddressInBytes(recipientAddress, parachainId); + } else { + const hexAddress = addressToHex(recipientAddress); + recipientAddressInBytes = utils.arrayify(`${hexAddress}`); } + const depositDataBytes = constructMainDepositData( BigNumber.from(tokenAmount), recipientAddressInBytes, @@ -189,6 +193,17 @@ export function serializeGenericCallParameters( return `0x${serialized}`; } +/** + * Return the address transformed to hex for bitcoin deposits + * + * @category Helpers + * @param address - bitcoin address + * @returns {string} + */ +export const addressToHex = (address: string): string => { + return utils.hexlify(utils.toUtf8Bytes(address)); +}; + /** * Creates the data for permissionless generic handler * diff --git a/packages/utils/src/bitcoin/blockstream.ts b/packages/utils/src/bitcoin/blockstream.ts new file mode 100644 index 000000000..3e940fdc8 --- /dev/null +++ b/packages/utils/src/bitcoin/blockstream.ts @@ -0,0 +1,102 @@ +import { Environment } from '@buildwithsygma/core'; + +const TESTNET_BLOCKSTREAM_URL = 'https://blockstream.info/testnet/api'; +const MAINNET_BLOCKSTREAM_URL = 'https://blockstream.info/api'; + +type FeeEstimates = Record; + +const blockStreamUrl = + process.env.SYGMA_ENV === Environment.MAINNET ? MAINNET_BLOCKSTREAM_URL : TESTNET_BLOCKSTREAM_URL; + +type Utxo = { + txid: string; + vout: number; + status: { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; + }; + value: number; +}; + +/** + * @category Bitcoin Helpers + * @description Get fee estimates from blockstream API + * @param blockConfirmations - number of confirmations + * @returns {Promise} - fee estimate + */ +export async function getFeeEstimates(blockConfirmations: string): Promise { + try { + const response = await fetch(`${blockStreamUrl}/fee-estimates`); + + const data = (await response.json()) as FeeEstimates; + + return data[blockConfirmations]; + } catch (error) { + throw new Error('Failed to get fee estimates'); + } +} + +/** + * @category Bitcoin Helpers + * @description Broadcast transaction to the network + * @param txHex - raw hex string of the signed transaction + * @returns {Promise} - transaction id + */ +export async function broadcastTransaction(txHex: string): Promise { + try { + const response = await fetch(`${blockStreamUrl}/tx`, { + method: 'POST', + body: txHex, + headers: { + 'Content-Type': 'text/plain', + }, + }); + + return await response.text(); + } catch (error) { + throw new Error('Failed to broadcast transaction'); + } +} + +/** + * @category Bitcoin Helpers + * @description Get UTXOs for a given address + * @param address - bitcoin address + * @returns {Promise} - array of UTXOs + */ +export const fetchUTXOS = async (address: string): Promise => { + try { + const response = await fetch(`${blockStreamUrl}/address/${address}/utxo`); + + const data = (await response.json()) as Utxo[]; + + return data; + } catch (error) { + throw new Error('Failed to get utxos'); + } +}; + +/** + * @category Bitcoin Helpers + * @description Process the UTXOs to get the required amount + * @param amount - amount to spend on the transaction + * @param utxo - array of UTXOs to process` + * @returns {Utxo[]} - array of UTXOs + */ +export const processUtxos = (utxo: Utxo[], amount: number): Utxo[] => { + const utoxCumSum = utxo.reduce>((acc, utxo, idx) => { + if (acc.length === 0) { + acc[idx] = { cumSum: utxo.value, index: idx }; + return acc; + } + acc[idx] = { cumSum: acc[idx - 1].cumSum + utxo.value, index: idx }; + return acc; + }, []); + + const utxoPosition = utoxCumSum.findIndex(utxo => utxo.cumSum > amount); + + const dataToReturn = utxo.slice(0, utxoPosition + 1); + return dataToReturn; +}; diff --git a/packages/utils/src/bitcoin/index.ts b/packages/utils/src/bitcoin/index.ts new file mode 100644 index 000000000..1cbc6a294 --- /dev/null +++ b/packages/utils/src/bitcoin/index.ts @@ -0,0 +1 @@ +export * from './blockstream.js'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 53a0dceb9..c2a99f19b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1 +1,2 @@ export * from './liquidity.js'; +export * from './bitcoin/index.js'; diff --git a/packages/utils/src/liquidity.ts b/packages/utils/src/liquidity.ts index 55460e558..77063df09 100644 --- a/packages/utils/src/liquidity.ts +++ b/packages/utils/src/liquidity.ts @@ -1,4 +1,10 @@ -import type { Eip1193Provider, EvmResource, SubstrateResource } from '@buildwithsygma/core'; +import type { + EvmResource, + SubstrateResource, + Eip1193Provider, + EthereumConfig, + SubstrateConfig, +} from '@buildwithsygma/core'; import { Network, ResourceType } from '@buildwithsygma/core'; import type { createEvmFungibleAssetTransfer } from '@buildwithsygma/evm'; import { getEvmHandlerBalance } from '@buildwithsygma/evm'; @@ -21,7 +27,7 @@ export async function hasEnoughLiquidity( ): Promise { const { destination, resource, config } = transfer; const destinationDomainConfig = config.findDomainConfig(destination); - const handler = destinationDomainConfig.handlers.find( + const handler = (destinationDomainConfig as EthereumConfig | SubstrateConfig).handlers.find( handler => handler.type === ResourceType.FUNGIBLE, ); diff --git a/release-please/rp-bitcoin-config.json b/release-please/rp-bitcoin-config.json new file mode 100644 index 000000000..0c867ebaf --- /dev/null +++ b/release-please/rp-bitcoin-config.json @@ -0,0 +1,16 @@ +{ + "plugins": ["node-workspace"], + "separate-pull-requests": true, + "packages": { + "packages/bitcoin": { + "component": "bitcoin", + "releaseType": "node", + "draft": false, + "prerelease": false, + "bumpMinorPreMajor": false, + "bumpPatchForMinorPreMajor": false, + "changelogPath": "CHANGELOG.md", + "versioning": "default" + } + } +} diff --git a/release-please/rp-bitcoin-manifest.json b/release-please/rp-bitcoin-manifest.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/release-please/rp-bitcoin-manifest.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 2dc402624..607b3df8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -415,6 +415,29 @@ __metadata: languageName: node linkType: hard +"@buildwithsygma/bitcoin@workspace:^, @buildwithsygma/bitcoin@workspace:packages/bitcoin": + version: 0.0.0-use.local + resolution: "@buildwithsygma/bitcoin@workspace:packages/bitcoin" + dependencies: + "@buildwithsygma/core": "workspace:^" + "@types/jest": "npm:^29.4.0" + bitcoinjs-lib: "npm:^6.1.6" + concurrently: "npm:7.0.0" + eslint: "npm:8" + hardhat: "npm:2.8.2" + jest: "npm:^29.4.1" + jest-environment-jsdom: "npm:^29.4.1" + jest-extended: "npm:1.2.0" + jest-fetch-mock: "npm:^3.0.3" + tiny-secp256k1: "npm:2.2.3" + ts-jest: "npm:^29.0.5" + ts-node: "npm:10.9.1" + typedoc: "npm:^0.24.1" + typedoc-plugin-markdown: "npm:^3.15.1" + typescript: "npm:5.0.4" + languageName: unknown + linkType: soft + "@buildwithsygma/core@workspace:*, @buildwithsygma/core@workspace:^, @buildwithsygma/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@buildwithsygma/core@workspace:packages/core" @@ -582,6 +605,40 @@ __metadata: languageName: node linkType: hard +"@buildwithsygma/sygma-sdk-bitcoin-to-evm-fungible-transfer-example@workspace:examples/bitcoin-to-evm-fungible-transfer": + version: 0.0.0-use.local + resolution: "@buildwithsygma/sygma-sdk-bitcoin-to-evm-fungible-transfer-example@workspace:examples/bitcoin-to-evm-fungible-transfer" + dependencies: + "@buildwithsygma/bitcoin": "workspace:^" + "@buildwithsygma/core": "workspace:^" + "@buildwithsygma/utils": "workspace:^" + bip32: "npm:^4.0.0" + bip39: "npm:^3.1.0" + bitcoinjs-lib: "npm:^6.1.6" + dotenv: "npm:^16.3.1" + ecpair: "npm:^2.1.0" + eslint: "npm:8" + tiny-secp256k1: "npm:^2.2.3" + ts-node: "npm:10.9.1" + tsx: "npm:^4.15.4" + typescript: "npm:5.0.4" + languageName: unknown + linkType: soft + +"@buildwithsygma/sygma-sdk-evm-to-bitcoin-fungible-transfer@workspace:examples/evm-to-bitcoin-fungible-transfer": + version: 0.0.0-use.local + resolution: "@buildwithsygma/sygma-sdk-evm-to-bitcoin-fungible-transfer@workspace:examples/evm-to-bitcoin-fungible-transfer" + dependencies: + "@buildwithsygma/core": "workspace:^" + "@buildwithsygma/evm": "workspace:^" + dotenv: "npm:^16.3.1" + eslint: "npm:8" + ts-node: "npm:10.9.1" + tsx: "npm:^4.15.4" + typescript: "npm:5.0.4" + languageName: unknown + linkType: soft + "@buildwithsygma/sygma-sdk@workspace:.": version: 0.0.0-use.local resolution: "@buildwithsygma/sygma-sdk@workspace:." @@ -592,7 +649,7 @@ __metadata: languageName: unknown linkType: soft -"@buildwithsygma/utils@workspace:packages/utils": +"@buildwithsygma/utils@workspace:^, @buildwithsygma/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@buildwithsygma/utils@workspace:packages/utils" dependencies: @@ -2140,7 +2197,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0": +"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" checksum: 8c3f005ee72e7b8f9cff756dfae1241485187254e3f743873e22073d63906863df5d4f13d441b7530ea614b7a093f0d889309f28b59850f33b66cb26a779a4a5 @@ -4050,6 +4107,13 @@ __metadata: languageName: node linkType: hard +"base-x@npm:^4.0.0": + version: 4.0.0 + resolution: "base-x@npm:4.0.0" + checksum: 0cb47c94535144ab138f70bb5aa7e6e03049ead88615316b62457f110fc204f2c3baff5c64a1c1b33aeb068d79a68092c08a765c7ccfa133eee1e70e4c6eb903 + languageName: node + linkType: hard + "base58-js@npm:^1.0.0": version: 1.0.5 resolution: "base58-js@npm:1.0.5" @@ -4092,6 +4156,34 @@ __metadata: languageName: node linkType: hard +"bip174@npm:^2.1.1": + version: 2.1.1 + resolution: "bip174@npm:2.1.1" + checksum: d92e142fca85fa4f621dbc9131dafe1da7d69fa7cae03137fa4745d66ffa50561f85ff8c49ca41da8ed1ca65e642415b13dc046531412dfebe6ff03c275e71ae + languageName: node + linkType: hard + +"bip32@npm:^4.0.0": + version: 4.0.0 + resolution: "bip32@npm:4.0.0" + dependencies: + "@noble/hashes": "npm:^1.2.0" + "@scure/base": "npm:^1.1.1" + typeforce: "npm:^1.11.5" + wif: "npm:^2.0.6" + checksum: b74ffd3a96b42a783eca6455dff8f0decc8360025b69d486d5a047b52cca6e8436bc5a9812cbeae2638a50f35da7fcd85b55cfe58272f55a5b31d7ac40a25522 + languageName: node + linkType: hard + +"bip39@npm:^3.1.0": + version: 3.1.0 + resolution: "bip39@npm:3.1.0" + dependencies: + "@noble/hashes": "npm:^1.2.0" + checksum: 68f9673a0d6a851e9635f3af8a85f2a1ecef9066c76d77e6f0d58d274b5bf22a67f429da3997e07c0d2cf153a4d7321f9273e656cac0526f667575ddee28ef71 + languageName: node + linkType: hard + "bitcoin-address-validation@npm:^2.2.3": version: 2.2.3 resolution: "bitcoin-address-validation@npm:2.2.3" @@ -4103,6 +4195,20 @@ __metadata: languageName: node linkType: hard +"bitcoinjs-lib@npm:^6.1.6": + version: 6.1.6 + resolution: "bitcoinjs-lib@npm:6.1.6" + dependencies: + "@noble/hashes": "npm:^1.2.0" + bech32: "npm:^2.0.0" + bip174: "npm:^2.1.1" + bs58check: "npm:^3.0.1" + typeforce: "npm:^1.11.3" + varuint-bitcoin: "npm:^1.1.2" + checksum: 27e77add09051fcbb32266c1a03c6c1eb691cb6b91706c4bfd41e1f4bea76f53529364f4ce22efff7879a3d08940d113df87de64b9422697463432c7b561e78c + languageName: node + linkType: hard + "blakejs@npm:^1.1.0": version: 1.2.1 resolution: "blakejs@npm:1.2.1" @@ -4219,7 +4325,16 @@ __metadata: languageName: node linkType: hard -"bs58check@npm:^2.1.2": +"bs58@npm:^5.0.0": + version: 5.0.0 + resolution: "bs58@npm:5.0.0" + dependencies: + base-x: "npm:^4.0.0" + checksum: 0d1b05630b11db48039421b5975cb2636ae0a42c62f770eec257b2e5c7d94cb5f015f440785f3ec50870a6e9b1132b35bd0a17c7223655b22229f24b2a3491d1 + languageName: node + linkType: hard + +"bs58check@npm:<3.0.0, bs58check@npm:^2.1.2": version: 2.1.2 resolution: "bs58check@npm:2.1.2" dependencies: @@ -4230,6 +4345,16 @@ __metadata: languageName: node linkType: hard +"bs58check@npm:^3.0.1": + version: 3.0.1 + resolution: "bs58check@npm:3.0.1" + dependencies: + "@noble/hashes": "npm:^1.2.0" + bs58: "npm:^5.0.0" + checksum: a01f62351d17cea5f6607f75f6b4b79d3473d018c52f1dfa6f449751062bb079ebfd556ea81c453de657102ab8c5a6b78620161f21ae05f0e5a43543e0447700 + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -5019,6 +5144,17 @@ __metadata: languageName: node linkType: hard +"ecpair@npm:^2.1.0": + version: 2.1.0 + resolution: "ecpair@npm:2.1.0" + dependencies: + randombytes: "npm:^2.1.0" + typeforce: "npm:^1.18.0" + wif: "npm:^2.0.6" + checksum: 206a3c9af725416e6e91515278259319d88c880cf067c4e4d275fee5e746064cc5bc6279bdb2f87a9218ef25dd82921422d05e0952d4b41c8e91c070c504aa70 + languageName: node + linkType: hard + "ejs@npm:^3.1.10": version: 3.1.10 resolution: "ejs@npm:3.1.10" @@ -10794,6 +10930,15 @@ __metadata: languageName: node linkType: hard +"tiny-secp256k1@npm:2.2.3, tiny-secp256k1@npm:^2.2.3": + version: 2.2.3 + resolution: "tiny-secp256k1@npm:2.2.3" + dependencies: + uint8array-tools: "npm:0.0.7" + checksum: 84ca5b88e90fc2a89b90814cec2394716393a9325f318ffede0cb99ff79aa4a63d609c76fc596727fd53192d9163ccaf690dc6817d3e571c625e3668d01177aa + languageName: node + linkType: hard + "tmp@npm:0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -11157,6 +11302,13 @@ __metadata: languageName: node linkType: hard +"typeforce@npm:^1.11.3, typeforce@npm:^1.11.5, typeforce@npm:^1.18.0": + version: 1.18.0 + resolution: "typeforce@npm:1.18.0" + checksum: 011f57effd9ae6d3dd8bb249e09b4ecadb2c2a3f803b27f977ac8b7782834855930bff971ba549bcd5a8cedc8136d8a977c0b7e050cc67deded948181b7ba3e8 + languageName: node + linkType: hard + "typescript@npm:5.0.4": version: 5.0.4 resolution: "typescript@npm:5.0.4" @@ -11213,6 +11365,13 @@ __metadata: languageName: node linkType: hard +"uint8array-tools@npm:0.0.7": + version: 0.0.7 + resolution: "uint8array-tools@npm:0.0.7" + checksum: 7d67aef80f3b6417c1dacc6505d5d82e3441bc6c1706b43698c5322e6fe7078a13a737efa8fcbd0884bd4b589511cc0a90cf4fe7cdd1b62b16007f1b0811ce36 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" @@ -11345,6 +11504,15 @@ __metadata: languageName: node linkType: hard +"varuint-bitcoin@npm:^1.1.2": + version: 1.1.2 + resolution: "varuint-bitcoin@npm:1.1.2" + dependencies: + safe-buffer: "npm:^5.1.1" + checksum: 3d38f8de8192b7a4fc00abea01ed189f1e1e6aee1ebc4192040c5717d2483e0a6a73873fcf6b3c1910d947d338b671470505705fe40c765dc832255dfa2d4210 + languageName: node + linkType: hard + "vscode-oniguruma@npm:^1.7.0": version: 1.7.0 resolution: "vscode-oniguruma@npm:1.7.0" @@ -11589,6 +11757,15 @@ __metadata: languageName: node linkType: hard +"wif@npm:^2.0.6": + version: 2.0.6 + resolution: "wif@npm:2.0.6" + dependencies: + bs58check: "npm:<3.0.0" + checksum: 9ff55fdde73226bbae6a08b68298b6d14bbc22fa4cefac11edaacb2317c217700f715b95dc4432917f73511ec983f1bc032d22c467703b136f4e6ca7dfa9f10b + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5"