diff --git a/react/react-bitcoin/package.json b/react/react-bitcoin/package.json index 5c26268..a699aca 100644 --- a/react/react-bitcoin/package.json +++ b/react/react-bitcoin/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", "@reown/appkit": "1.6.2", "@reown/appkit-adapter-bitcoin": "1.6.2", + "bitcoinjs-lib": "^6.1.7", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/react/react-bitcoin/src/App.tsx b/react/react-bitcoin/src/App.tsx index 9457602..eee9749 100644 --- a/react/react-bitcoin/src/App.tsx +++ b/react/react-bitcoin/src/App.tsx @@ -1,4 +1,5 @@ import { createAppKit } from '@reown/appkit/react' +import { useState } from 'react' import { networks, projectId, metadata, bitcoinAdapter } from './config' import { ActionButtonList } from './components/ActionButtonList' import { InfoList } from './components/InfoList' @@ -20,20 +21,39 @@ createAppKit({ }) export function App() { +/* sendSignPSBT: (hash: string ) => void; + sendSignMsg: (hash: string) => void; + sendSendTx: (hash: string) => void; */ + + const [psbt, setPSBT] = useState(""); + const [signedMsg, setSignedMsg] = useState(""); + const [txHash, setTxHash] = useState(""); + + const receivePSBT = (hash: string) => { + setPSBT(hash); + }; + + const receiveSignedMsg = (signedMsg: string) => { + setSignedMsg(signedMsg); // Update the state with the transaction hash + }; + + const receiveTxHash= (hash: string) => { + setTxHash(hash) + } return (
Reown

AppKit bitcoin React dApp Example

- +

This projectId only works on localhost.
Go to Reown Cloud to get your own.

- +
) } diff --git a/react/react-bitcoin/src/components/ActionButtonList.tsx b/react/react-bitcoin/src/components/ActionButtonList.tsx index 1b423aa..3e8cb17 100644 --- a/react/react-bitcoin/src/components/ActionButtonList.tsx +++ b/react/react-bitcoin/src/components/ActionButtonList.tsx @@ -1,10 +1,21 @@ -import { useDisconnect, useAppKit, useAppKitNetwork } from '@reown/appkit/react' +import { useDisconnect, useAppKit, useAppKitNetwork, useAppKitProvider, useAppKitAccount } from '@reown/appkit/react' +import type { BitcoinConnector } from '@reown/appkit-adapter-bitcoin' import { networks } from '../config' +import { createPSBT } from '../utils/BitcoinUtil'; -export const ActionButtonList = () => { +interface ActionButtonListProps { + sendSignPSBT: (hash: string ) => void; + sendSignMsg: (hash: string) => void; + sendSendTx: (hash: string) => void; +} + + +export const ActionButtonList = ({ sendSignPSBT, sendSignMsg, sendSendTx }: ActionButtonListProps) => { const { disconnect } = useDisconnect(); const { open } = useAppKit(); - const { switchNetwork } = useAppKitNetwork(); + const { switchNetwork, caipNetwork } = useAppKitNetwork(); + const { isConnected, address } = useAppKitAccount(); + const { walletProvider } = useAppKitProvider('bip122') const handleDisconnect = async () => { try { @@ -13,11 +24,59 @@ export const ActionButtonList = () => { console.error("Failed to disconnect:", error); } }; + + // function to sing a msg + const handleSignMsg = async () => { + if (!walletProvider || !address) throw Error('user is disconnected') + + // raise the modal to sign the message + const signature = await walletProvider.signMessage({ + address, + message: "Hello Reown AppKit!" + }); + + sendSignMsg(signature); + } + + // function to send a tx + const handleSendTx = async () => { + if (!walletProvider || !address) throw Error('user is disconnected') + const recipientAddress = address; + + const signature = await walletProvider.sendTransfer({ + recipient: recipientAddress, + amount: "1000" + }) + + sendSendTx(signature); + } + + // function to sign a PSBT + const handleSignPSBT = async () => { + if (!walletProvider || !address || !caipNetwork) throw Error('user is disconnected'); + const amount = 10000; + const recipientAddress = address; + + const params = await createPSBT(caipNetwork, amount, address, recipientAddress); + + params.broadcast = false // change to true to broadcast the tx + + const signature = await walletProvider.signPSBT(params) + sendSignPSBT(signature.psbt); + } + return ( -
- - - -
+ <> + {isConnected ? ( +
+ + + + + + +
+ ) : null} + ) } diff --git a/react/react-bitcoin/src/components/InfoList.tsx b/react/react-bitcoin/src/components/InfoList.tsx index d74d57d..3ee1022 100644 --- a/react/react-bitcoin/src/components/InfoList.tsx +++ b/react/react-bitcoin/src/components/InfoList.tsx @@ -7,7 +7,13 @@ import { useWalletInfo } from '@reown/appkit/react' -export const InfoList = () => { +interface InfoListProps { + psbt: string; + signedMsg: string; + txHash: string; +} + +export const InfoList = ({psbt, signedMsg, txHash}: InfoListProps) => { const { themeMode, themeVariables } = useAppKitTheme(); const state = useAppKitState(); const {address, caipAddress, isConnected, status} = useAppKitAccount(); @@ -20,6 +26,30 @@ export const InfoList = () => { return ( < > + {psbt && ( +
+

PSBT

+
+                Hash: {psbt}
+
+
+ )} + {txHash && ( +
+

Sign Tx

+
+                Hash: {txHash}
+
+
+ )} + {signedMsg && ( +
+

Sign MSG

+
+                {signedMsg}
+
+
+ )}

useAppKit

diff --git a/react/react-bitcoin/src/utils/BitcoinUtil.ts b/react/react-bitcoin/src/utils/BitcoinUtil.ts
new file mode 100644
index 0000000..d3982a5
--- /dev/null
+++ b/react/react-bitcoin/src/utils/BitcoinUtil.ts
@@ -0,0 +1,176 @@
+import type { CaipNetwork, CaipNetworkId } from '@reown/appkit'
+import { bitcoinTestnet } from '@reown/appkit/networks'
+import type { BitcoinConnector } from '@reown/appkit-adapter-bitcoin'
+import { Psbt, networks as bitcoinNetworks, address as bitcoinAddress, payments as bitcoinPayments, type Network, initEccLib } from 'bitcoinjs-lib';
+import * as bitcoinPSBTUtils from 'bitcoinjs-lib/src/psbt/psbtutils'
+import ecc from '@bitcoinerlab/secp256k1'
+
+initEccLib(ecc);
+
+export type CreateSignPSBTParams = {
+    senderAddress: string
+    recipientAddress: string
+    network: CaipNetwork
+    amount: number
+    utxos: UTXO[]
+    feeRate: number
+    memo?: string
+  }
+
+export type UTXO = {
+    txid: string
+    vout: number
+    value: number
+    status: {
+        confirmed: boolean
+        block_height: number
+        block_hash: string
+        block_time: number
+    }
+}
+export const calculateChange = (utxos: UTXO[], amount: number, feeRate: number): number => {
+    const inputSum = utxos.reduce((sum, utxo) => sum + utxo.value, 0)
+    /**
+     * 10 bytes: This is an estimated fixed overhead for the transaction.
+     * 148 bytes: This is the average size of each input (UTXO).
+     * 34 bytes: This is the size of each output.
+     * The multiplication by 2 indicates that there are usually two outputs in a typical transaction (one for the recipient and one for change)
+     */
+    const estimatedSize = 10 + 148 * utxos.length + 34 * 2
+    const fee = estimatedSize * feeRate
+    const change = inputSum - amount - fee
+
+    return change
+}
+
+export const getBitcoinNetwork = (networkId: CaipNetworkId): Network => {
+    return isTestnet(networkId) ? bitcoinNetworks.testnet : bitcoinNetworks.bitcoin
+}
+
+export const isTestnet = (networkId: CaipNetworkId): boolean => {
+    return networkId === bitcoinTestnet.caipNetworkId
+}
+
+export const getFeeRate = async () => {
+    const defaultFeeRate = 2
+    try {
+      const response = await fetch('https://mempool.space/api/v1/fees/recommended')
+      if (response.ok) {
+        const data = await response.json()
+
+        if (data?.fastestFee) {
+          return parseInt(data.fastestFee, 10)
+        }
+      }
+    } catch (e) {
+      // eslint-disable-next-line no-console
+      console.error('Error fetching fee rate', e)
+    }
+
+    return defaultFeeRate
+  }
+
+  //
+  // Get the utxos ... List of unspent transactions that the sender has
+  //
+  export  const getUTXOs = async (address: string, isTestnet: boolean = false): Promise => {
+        const response = await fetch(
+            `https://mempool.space${isTestnet ? '/testnet' : ''}/api/address/${address}/utxo`
+        )
+        return await response.json();
+  }
+
+  //
+  // Get the payment by address ... The type of address that the sender has
+  //
+  export const getPaymentByAddress = (
+    address: string,
+    network: bitcoinNetworks.Network
+  ): bitcoinPayments.Payment => {
+    const output = bitcoinAddress.toOutputScript(address, network)
+
+    if (bitcoinPSBTUtils.isP2MS(output)) {
+      return bitcoinPayments.p2ms({ output, network })
+    } else if (bitcoinPSBTUtils.isP2PK(output)) {
+      return bitcoinPayments.p2pk({ output, network })
+    } else if (bitcoinPSBTUtils.isP2PKH(output)) {
+      return bitcoinPayments.p2pkh({ output, network })
+    } else if (bitcoinPSBTUtils.isP2WPKH(output)) {
+      return bitcoinPayments.p2wpkh({ output, network })
+    } else if (bitcoinPSBTUtils.isP2WSHScript(output)) {
+      return bitcoinPayments.p2wsh({ output, network })
+    } else if (bitcoinPSBTUtils.isP2SHScript(output)) {
+      return bitcoinPayments.p2sh({ output, network })
+    } else if (bitcoinPSBTUtils.isP2TR(output)) {
+      return bitcoinPayments.p2tr({ output, network })
+    }
+
+    throw new Error('Unsupported payment type')
+  }
+//
+// Create a psbt ... The PSBT that will be signed by the sender in the wallet
+//
+export const createPSBT = async (caipNetwork: CaipNetwork, amount: number, address: string, recipientAddress: string): Promise => {
+    // get the bitcoin network from our caipNetwork
+    const network = getBitcoinNetwork(caipNetwork.caipNetworkId)
+    // get the payment by address ... this is the type of address that the sender has
+    const payment = getPaymentByAddress(address, network)
+    // get the utxos ... this is the list of unspent transactions that the sender has
+    const utxos = await getUTXOs(address, isTestnet(caipNetwork.caipNetworkId))
+    // get the fee rate ... this is the fee per byte
+    const feeRate = await getFeeRate()
+    // calculate the change ... this is the amount of satoshis that will be sent back to the sender
+    const change = calculateChange(utxos, amount, feeRate)
+    // the memo is the message that will be embedded in the transaction
+    const memo = "Hello Reown AppKit!";
+
+    const psbt = new Psbt({network: network});
+
+    // check if the payment output is valid
+    if (!payment.output) throw new Error('Invalid payment output');
+    // check if the change is greater than 0 ... this means the sender has enough funds
+    //if (change < 0) throw new Error('Insufficient funds');
+    
+    if (change > 0) {
+      psbt.addOutput({
+        address: address,
+        value: change //BigInt(change)
+      })
+    }
+
+    // add the inputs to the psbt
+    for (const utxo of utxos) {
+      psbt.addInput({
+        hash: utxo.txid,
+        index: utxo.vout,
+        witnessUtxo: {
+          script: payment.output,
+          value: utxo.value
+        }
+      })
+    }
+
+    // add the output to the psbt ... this is the recipient address and the amount of satoshis that will be sent to the recipient
+    psbt.addOutput({
+      address: recipientAddress,
+      value: amount
+    })
+
+    if (memo) {
+        const data = Buffer.from(memo, 'utf8')
+        const embed = bitcoinPayments.embed({ data: [data] })
+
+        if (!embed.output) throw new Error('Invalid embed output');
+
+        psbt.addOutput({
+            script: embed.output,
+            value: 0
+        })
+    }
+
+    return {
+        psbt: psbt.toBase64(),
+        signInputs: [],
+        broadcast: false
+    }
+}
\ No newline at end of file