payjoin-react-native
Version:
Library to Payjoin written in react native
278 lines (238 loc) • 8.31 kB
text/typescript
import bitcoin from 'bitcoinjs-lib';
import axios from 'axios';
import { Buffer } from 'buffer';
// Define types for UTXO and PayJoin response
interface Utxo {
txid: string;
vout: number;
hex: string;
timelock: number;
amount: number;
address: string;
preimage: string;
scriptPubKey: string;
confirmations: number;
is_coinbase: boolean;
mempool: string;
height: number;
}
interface PayJoinRequest {
psbt: string;
}
interface PayJoinResponse {
psbt: string;
}
// Configure Bitcoin network (using testnet as example)
const network = bitcoin.networks.testnet;
// Fetch UTXOs from a blockchain API (using Blockstream as example)
async function fetchUtxos(senderAddress: string): Promise<Utxo[]> {
try {
const response = await axios.get(`https://blockstream.info/testnet/api/address/${senderAddress}/utxo`);
const utxos = response.data.map((utxo: any) => ({
txid: utxo.txid,
vout: utxo.vout,
hex: '',
timelock: 0,
amount: utxo.value,
address: senderAddress,
preimage: '',
scriptPubKey: '',
confirmations: utxo.status.confirmed ? 6 : 0,
is_coinbase: false,
mempool: utxo.status.confirmed ? 'confirmed' : 'pending',
height: utxo.status.block_height || 0
}));
for (const utxo of utxos) {
const txResponse = await axios.get(`https://blockstream.info/testnet/api/tx/${utxo.txid}/hex`);
utxo.hex = txResponse.data;
// Fetch scriptPubKey
const txDetails = await axios.get(`https://blockstream.info/testnet/api/tx/${utxo.txid}`);
utxo.scriptPubKey = txDetails.data.vout[utxo.vout].scriptpubkey;
}
return utxos;
} catch (error) {
throw new Error(`Failed to fetch UTXOs: ${error}`);
}
}
// Validate Payjoin V2 PSBT (BIP-78 compliance)
function validatePayjoinPsbt(originalPsbt: bitcoin.Psbt, modifiedPsbt: bitcoin.Psbt, maxFeeContribution: number): boolean {
try {
// Check that original outputs are preserved
const originalOutputs = originalPsbt.data.outputs;
const modifiedOutputs = modifiedPsbt.data.outputs;
// Verify that the receiver hasn't modified the original payment output
for (const origOutput of originalOutputs) {
const matchingOutput = modifiedOutputs.find(
out => out.address === origOutput.address && out.value === origOutput.value
);
if (!matchingOutput && origOutput.address && origOutput.value) {
throw new Error('Receiver modified original outputs');
}
}
// Check that additional inputs belong to receiver (basic check, may need more robust validation)
const originalInputs = originalPsbt.data.inputs.map(input => input.hash.toString('hex') + input.index);
const modifiedInputs = modifiedPsbt.data.inputs.map(input => input.hash.toString('hex') + input.index);
const newInputs = modifiedInputs.filter(input => !originalInputs.includes(input));
if (newInputs.length === 0) {
throw new Error('Receiver did not contribute any inputs');
}
// Calculate fee contribution
const originalFee = originalPsbt.getFee();
const modifiedFee = modifiedPsbt.getFee();
const feeDifference = modifiedFee - originalFee;
if (feeDifference > maxFeeContribution) {
throw new Error(`Receiver's fee contribution (${feeDifference}) exceeds max allowed (${maxFeeContribution})`);
}
// Ensure no unexpected script modifications
for (let i = 0; i < originalPsbt.data.inputs.length; i++) {
if (originalPsbt.data.inputs[i].witnessUtxo?.script.toString('hex') !==
modifiedPsbt.data.inputs[i].witnessUtxo?.script.toString('hex')) {
throw new Error('Receiver modified sender input scripts');
}
}
return true;
} catch (error) {
console.error(`Payjoin validation failed: ${error}`);
return false;
}
}
// Create initial PSBT with Payjoin V2 considerations
async function createInitialPayJoinTx(
senderAddress: string,
receiverAddress: string,
amountToSend: number,
feeRate: number = 1000,
maxFeeContribution: number = 10000 // Max fee contribution in satoshis
): Promise<bitcoin.Psbt> {
try {
const psbt = new bitcoin.Psbt({ network });
// Fetch UTXOs for sender
const utxos = await fetchUtxos(senderAddress);
if (!utxos.length) {
throw new Error('No UTXOs available');
}
// Calculate total available amount and add inputs
let totalInput = 0;
for (const utxo of utxos) {
psbt.addInput({
hash: utxo.txid,
index: utxo.vout,
nonWitnessUtxo: Buffer.from(utxo.hex, 'hex'),
witnessUtxo: {
script: Buffer.from(utxo.scriptPubKey, 'hex'),
value: BigInt(utxo.amount)
}
});
totalInput += utxo.amount;
}
// Add receiver output
psbt.addOutput({
address: receiverAddress,
value: BigInt(amountToSend)
});
// Add change output (rough estimate, will be adjusted later)
const estimatedFee = feeRate * 200 + maxFeeContribution; // Account for receiver's potential contribution
const change = totalInput - amountToSend - estimatedFee;
if (change > 546) { // Dust threshold
psbt.addOutput({
address: senderAddress,
value: BigInt(change)
});
}
// Add Payjoin V2 parameters
psbt.setMaximumFeeRate(feeRate * 2); // Allow some flexibility for receiver's fee contribution
return psbt;
} catch (error) {
throw new Error(`Failed to create initial PSBT: ${error}`);
}
}
// Send PSBT to receiver's PayJoin endpoint
async function sendPayJoinRequest(psbt: bitcoin.Psbt, endpoint: string): Promise<bitcoin.Psbt> {
try {
const response = await axios.post<PayJoinResponse>(
endpoint,
{ psbt: psbt.toBase64() },
{
headers: { 'Content-Type': 'application/json' }
}
);
const modifiedPsbt = bitcoin.Psbt.fromBase64(response.data.psbt, { network });
// Validate the modified PSBT
if (!validatePayjoinPsbt(psbt, modifiedPsbt, 10000)) {
throw new Error('Invalid Payjoin response from receiver');
}
return modifiedPsbt;
} catch (error) {
throw new Error(`PayJoin request failed: ${error}`);
}
}
// Broadcast transaction to the network
async function broadcastTransaction(txHex: string): Promise<string> {
try {
const response = await axios.post(
'https://blockstream.info/testnet/api/tx',
txHex
);
return response.data; // Returns txid
} catch (error) {
throw new Error(`Broadcast failed: ${error}`);
}
}
// Finalize, sign, and broadcast PayJoin transaction
async function finalizeAndBroadcast(
modifiedPsbt: bitcoin.Psbt,
senderKeyPair: any
): Promise<string> {
try {
// Sign all inputs
modifiedPsbt.signAllInputs(senderKeyPair);
// Finalize all inputs
modifiedPsbt.finalizeAllInputs();
// Extract and broadcast the transaction
const tx = modifiedPsbt.extractTransaction();
const txHex = tx.toHex();
const txid = await broadcastTransaction(txHex);
return txid;
} catch (error) {
throw new Error(`Failed to finalize and broadcast: ${error}`);
}
}
// Example usage for Payjoin V2
async function executePayJoin(
senderWIF: string,
senderAddress: string,
receiverAddress: string,
amount: number,
payjoinEndpoint: string
) {
try {
// Generate key pair from WIF
const ECPair = require('ecpair');
const senderKeyPair = ECPair.fromWIF(senderWIF, network);
// Create initial transaction with Payjoin V2 parameters
const initialPsbt = await createInitialPayJoinTx(
senderAddress,
receiverAddress,
amount,
1000, // feeRate
10000 // maxFeeContribution
);
// Send to receiver for PayJoin contribution
const modifiedPsbt = await sendPayJoinRequest(initialPsbt, payjoinEndpoint);
// Finalize and broadcast
const txid = await finalizeAndBroadcast(modifiedPsbt, senderKeyPair);
console.log(`Payjoin V2 transaction broadcasted: ${txid}`);
return txid;
} catch (error) {
console.error(`PayJoin V2 failed: ${error}`);
throw error;
}
}
export {
createInitialPayJoinTx,
sendPayJoinRequest,
finalizeAndBroadcast,
broadcastTransaction,
fetchUtxos,
validatePayjoinPsbt
};