diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 1777bf233..2ea927721 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -470,16 +470,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitcoin-ffi" -version = "0.1.3" -source = "git+https://github.com/bitcoindevkit/bitcoin-ffi?rev=39cc12b#39cc12bd32d6adf889b48354adcdb0f3c475aad2" -dependencies = [ - "bitcoin 0.32.7", - "thiserror 1.0.63", - "uniffi", -] - [[package]] name = "bitcoin-hpke" version = "0.13.0" @@ -2525,7 +2515,6 @@ name = "payjoin-ffi" version = "0.24.0" dependencies = [ "bdk", - "bitcoin-ffi", "bitcoin-ohttp", "getrandom 0.2.15", "lazy_static", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 1777bf233..2ea927721 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -470,16 +470,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitcoin-ffi" -version = "0.1.3" -source = "git+https://github.com/bitcoindevkit/bitcoin-ffi?rev=39cc12b#39cc12bd32d6adf889b48354adcdb0f3c475aad2" -dependencies = [ - "bitcoin 0.32.7", - "thiserror 1.0.63", - "uniffi", -] - [[package]] name = "bitcoin-hpke" version = "0.13.0" @@ -2525,7 +2515,6 @@ name = "payjoin-ffi" version = "0.24.0" dependencies = [ "bdk", - "bitcoin-ffi", "bitcoin-ohttp", "getrandom 0.2.15", "lazy_static", diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index b1458f2f8..8ac6c0bb2 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -22,7 +22,6 @@ name = "uniffi-bindgen" path = "uniffi-bindgen.rs" [dependencies] -bitcoin-ffi = { git = "https://github.com/bitcoindevkit/bitcoin-ffi", rev = "39cc12b" } getrandom = "0.2" lazy_static = "1.5.0" ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } @@ -32,7 +31,7 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" thiserror = "2.0.14" tokio = { version = "1.47.1", features = ["full"], optional = true } -uniffi = { version = "0.30.0" } +uniffi = { version = "0.30.0", features = ["cli"] } uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "5bdcc79", optional = true } url = "2.5.4" diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index b84fd8b02..adf5d1743 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -6,7 +6,6 @@ import 'package:test/test.dart'; import "package:convert/convert.dart"; import "package:payjoin/payjoin.dart" as payjoin; -import "package:payjoin/bitcoin.dart" as bitcoin; late payjoin.BitcoindEnv env; late payjoin.BitcoindInstance bitcoind; @@ -91,18 +90,46 @@ class IsScriptOwnedCallback implements payjoin.IsScriptOwned { @override bool callback(Uint8List script) { try { - final scriptObj = bitcoin.Script(script); - final address = bitcoin.Address.fromScript( - scriptObj, - bitcoin.Network.regtest, + final scriptHex = hex.encode(script); + final decodedScript = jsonDecode( + connection.call("decodescript", [jsonEncode(scriptHex)]), ); - // This is a hack due to toString() not being exposed by dart FFI - final address_str = address.toQrUri().split(":")[1]; - final result = connection.call("getaddressinfo", [address_str]); - final decoded = jsonDecode(result); - return decoded["ismine"] == true; + + final candidates = []; + final addressField = decodedScript["address"]; + if (addressField is String) { + candidates.add(addressField); + } + final addresses = decodedScript["addresses"]; + if (addresses is List) { + candidates.addAll(addresses.whereType()); + } + final p2sh = decodedScript["p2sh"]; + if (p2sh is String) { + candidates.add(p2sh); + } + final segwit = decodedScript["segwit"]; + if (segwit is Map) { + final segwitAddress = segwit["address"]; + if (segwitAddress is String) { + candidates.add(segwitAddress); + } + final segwitAddresses = segwit["addresses"]; + if (segwitAddresses is List) { + candidates.addAll(segwitAddresses.whereType()); + } + } + + for (final addr in candidates) { + final info = jsonDecode( + connection.call("getaddressinfo", [jsonEncode(addr)]), + ); + if (info["ismine"] == true) { + return true; + } + } + return false; } catch (e) { - print("An error occurred: $e"); return false; } } @@ -132,7 +159,7 @@ class ProcessPsbtCallback implements payjoin.ProcessPsbt { } payjoin.Initialized create_receiver_context( - bitcoin.Address address, + String address, String directory, payjoin.OhttpKeys ohttp_keys, InMemoryReceiverPersister persister, @@ -174,17 +201,22 @@ List get_inputs(payjoin.RpcClient rpc_connection) { var utxos = jsonDecode(rpc_connection.call("listunspent", [])); List inputs = []; for (var utxo in utxos) { - var txin = bitcoin.TxIn( - bitcoin.OutPoint(utxo["txid"], utxo["vout"]), - bitcoin.Script(Uint8List.fromList([])), - 0, - [], + final txid = utxo["txid"] as String; + final vout = utxo["vout"] as int; + final scriptPubKey = Uint8List.fromList( + hex.decode(utxo["scriptPubKey"] as String), ); - var tx_out = bitcoin.TxOut( - bitcoin.Amount.fromBtc(utxo["amount"]), - bitcoin.Script(Uint8List.fromList(hex.decode(utxo["scriptPubKey"]))), + final amountBtc = utxo["amount"] as num; + final amountSat = (amountBtc * 100000000).round(); + + final txin = payjoin.PlainTxIn( + payjoin.PlainOutPoint(txid, vout), + Uint8List(0), + 0, + [], ); - var psbt_in = payjoin.PsbtInput(tx_out, null, null); + final witnessUtxo = payjoin.PlainTxOut(amountSat, scriptPubKey); + final psbt_in = payjoin.PlainPsbtInput(witnessUtxo, null, null); inputs.add(payjoin.InputPair(txin, psbt_in, null)); } @@ -346,10 +378,8 @@ void main() { bitcoind = env.getBitcoind(); receiver = env.getReceiver(); sender = env.getSender(); - var receiver_address = bitcoin.Address( - jsonDecode(receiver.call("getnewaddress", [])), - bitcoin.Network.regtest, - ); + var receiver_address = + jsonDecode(receiver.call("getnewaddress", [])) as String; var services = payjoin.TestServices.initialize(); services.waitForServicesReady(); @@ -443,30 +473,33 @@ void main() { ) .save(sender_persister); expect(checked_payjoin_proposal_psbt, isNotNull); - var checked_payjoin_proposal_psbt_inner = - (checked_payjoin_proposal_psbt - as payjoin.ProgressPollingForProposalTransitionOutcome) - .inner; + final progressOutcome = + checked_payjoin_proposal_psbt + as payjoin.ProgressPollingForProposalTransitionOutcome; var payjoin_psbt = jsonDecode( - sender.call("walletprocesspsbt", [ - checked_payjoin_proposal_psbt_inner.serializeBase64(), - ]), + sender.call("walletprocesspsbt", [progressOutcome.psbtBase64]), )["psbt"]; var final_psbt = jsonDecode( sender.call("finalizepsbt", [payjoin_psbt, jsonEncode(false)]), )["psbt"]; - var payjoin_tx = bitcoin.Psbt.deserializeBase64(final_psbt).extractTx(); - sender.call("sendrawtransaction", [ - jsonEncode(hex.encode(payjoin_tx.serialize())), - ]); + var final_tx_hex = jsonDecode( + sender.call("finalizepsbt", [final_psbt, jsonEncode(true)]), + )["hex"]; + sender.call("sendrawtransaction", [jsonEncode(final_tx_hex)]); // Check resulting transaction and balances - var network_fees = bitcoin.Psbt.deserializeBase64( - final_psbt, - ).fee().toBtc(); + var decodedTx = jsonDecode( + sender.call("decoderawtransaction", [jsonEncode(final_tx_hex)]), + ); + var network_fees = + (jsonDecode( + sender.call("decodepsbt", [jsonEncode(final_psbt)]), + )["fee"] + as num) + .toDouble(); // Sender sent the entire value of their utxo to the receiver (minus fees) - expect(payjoin_tx.input().length, 2); - expect(payjoin_tx.output().length, 1); + expect(decodedTx["vin"].length, 2); + expect(decodedTx["vout"].length, 1); expect( jsonDecode( receiver.call("getbalances", []), @@ -474,6 +507,6 @@ void main() { 100 - network_fees, ); expect(jsonDecode(sender.call("getbalance", [])), 0.0); - }); + }, timeout: const Timeout(Duration(minutes: 5))); }); } diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 2ec1e557e..945330f7b 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -2,7 +2,6 @@ import 'dart:typed_data'; import 'package:convert/convert.dart'; import 'package:test/test.dart'; import "package:payjoin/payjoin.dart" as payjoin; -import "package:payjoin/bitcoin.dart" as bitcoin; class InMemoryReceiverPersister implements payjoin.JsonReceiverSessionPersister { @@ -106,12 +105,8 @@ void main() { group("Test Persistence", () { test("Test receiver persistence", () { var persister = InMemoryReceiverPersister("1"); - var address = bitcoin.Address( - "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", - bitcoin.Network.signet, - ); payjoin.ReceiverBuilder( - address, + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", payjoin.OhttpKeys.decode( Uint8List.fromList( @@ -131,12 +126,8 @@ void main() { test("Test sender persistence", () { var receiver_persister = InMemoryReceiverPersister("1"); - var address = bitcoin.Address( - "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", - bitcoin.Network.testnet, - ); var receiver = payjoin.ReceiverBuilder( - address, + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", payjoin.OhttpKeys.decode( Uint8List.fromList( diff --git a/payjoin-ffi/javascript/src/index.ts b/payjoin-ffi/javascript/src/index.ts index 26f0154f5..09b82dfb4 100644 --- a/payjoin-ffi/javascript/src/index.ts +++ b/payjoin-ffi/javascript/src/index.ts @@ -1,11 +1,9 @@ // Export the generated bindings to the app. export * as payjoin from "./generated/payjoin.js"; -export * as bitcoin from "./generated/bitcoin.js"; // Now import the bindings so we can: // - initialize them // - export them as namespaced objects as the default export. -import * as bitcoin from "./generated/bitcoin.js"; import * as payjoin from "./generated/payjoin.js"; let initialized = false; @@ -19,7 +17,6 @@ export async function uniffiInitAsync() { // Initialize the generated bindings: mostly checksums, but also callbacks. // - the boolean flag ensures this loads exactly once, even if the JS code // is reloaded (e.g. during development with metro). - bitcoin.default.initialize(); payjoin.default.initialize(); initialized = true; @@ -27,6 +24,5 @@ export async function uniffiInitAsync() { // Export the crates as individually namespaced objects. export default { - bitcoin, payjoin, }; diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 5c41d6ef6..4ce78d710 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -1,4 +1,4 @@ -import { payjoin, bitcoin, uniffiInitAsync } from "../dist/index.js"; +import { payjoin, uniffiInitAsync } from "../dist/index.js"; import * as testUtils from "../test-utils/index.js"; import assert from "assert"; @@ -86,15 +86,57 @@ class IsScriptOwnedCallback implements payjoin.IsScriptOwned { callback(script: ArrayBuffer): boolean { try { - const scriptObj = new bitcoin.Script(script); - const address = bitcoin.Address.fromScript( - scriptObj, - bitcoin.Network.Regtest, + const scriptHex = Buffer.from(script).toString("hex"); + const decodedScript = JSON.parse( + this.connection.call("decodescript", [ + JSON.stringify(scriptHex), + ]), ); - const addressStr = address.toQrUri().split(":")[1]; - const result = this.connection.call("getaddressinfo", [addressStr]); - const decoded = JSON.parse(result); - return decoded.ismine === true; + + const candidates: string[] = []; + if (typeof decodedScript.address === "string") { + candidates.push(decodedScript.address); + } + if (Array.isArray(decodedScript.addresses)) { + candidates.push( + ...decodedScript.addresses.filter( + (addr: unknown): addr is string => + typeof addr === "string", + ), + ); + } + if ( + decodedScript.segwit && + typeof decodedScript.segwit === "object" + ) { + const { address, addresses } = decodedScript.segwit as { + address?: unknown; + addresses?: unknown; + }; + if (typeof address === "string") { + candidates.push(address); + } + if (Array.isArray(addresses)) { + candidates.push( + ...addresses.filter( + (addr: unknown): addr is string => + typeof addr === "string", + ), + ); + } + } + + for (const addr of candidates) { + const info = JSON.parse( + this.connection.call("getaddressinfo", [ + JSON.stringify(addr), + ]), + ); + if (info.ismine === true) { + return true; + } + } + return false; } catch { return false; } @@ -129,7 +171,7 @@ class ProcessPsbtCallback implements payjoin.ProcessPsbt { } function createReceiverContext( - address: bitcoin.Address, + address: string, directory: string, ohttpKeys: payjoin.OhttpKeys, persister: InMemoryReceiverPersister, @@ -172,22 +214,20 @@ function getInputs(rpcConnection: testUtils.RpcClient): payjoin.InputPair[] { const utxos: Utxo[] = JSON.parse(rpcConnection.call("listunspent", [])); const inputs: payjoin.InputPair[] = []; for (const utxo of utxos) { - const txin = bitcoin.TxIn.create({ - previousOutput: bitcoin.OutPoint.create({ + const txin = payjoin.PlainTxIn.create({ + previousOutput: payjoin.PlainOutPoint.create({ txid: utxo.txid, vout: utxo.vout, }), - scriptSig: new bitcoin.Script(new Uint8Array([]).buffer), + scriptSig: new Uint8Array([]), sequence: 0, witness: [], }); - const txOut = bitcoin.TxOut.create({ - value: bitcoin.Amount.fromBtc(utxo.amount), - scriptPubkey: new bitcoin.Script( - new Uint8Array(Buffer.from(utxo.scriptPubKey, "hex")), - ), + const txOut = payjoin.PlainTxOut.create({ + valueSat: BigInt(Math.round(utxo.amount * 100_000_000)), + scriptPubkey: Buffer.from(utxo.scriptPubKey, "hex"), }); - const psbtIn = payjoin.PsbtInput.create({ + const psbtIn = payjoin.PlainPsbtInput.create({ witnessUtxo: txOut, redeemScript: undefined, witnessScript: undefined, @@ -417,10 +457,7 @@ async function testIntegrationV2ToV2(): Promise { const sender = env.getSender(); const receiverAddressJson = receiver.call("getnewaddress", []); - const receiverAddress = new bitcoin.Address( - JSON.parse(receiverAddressJson), - bitcoin.Network.Regtest, - ); + const receiverAddress = JSON.parse(receiverAddressJson); const services = new testUtils.TestServices(); const directory = services.directoryUrl(); @@ -505,47 +542,38 @@ async function testIntegrationV2ToV2(): Promise { body: ohttpContextRequest.request.body, }); const finalResponseBuffer = await finalResponse.arrayBuffer(); - const checkedPayjoinProposalPsbt = sendCtx + const pollOutcome = sendCtx .processResponse(finalResponseBuffer, ohttpContextRequest.ohttpCtx) .save(senderPersister); - assert.notStrictEqual( - checkedPayjoinProposalPsbt, - null, - "Checked payjoin proposal should not be null", - ); assert( - checkedPayjoinProposalPsbt instanceof + pollOutcome instanceof payjoin.PollingForProposalTransitionOutcome.Progress, "Should be progress outcome", ); - if ( - !( - checkedPayjoinProposalPsbt instanceof - payjoin.PollingForProposalTransitionOutcome.Progress - ) - ) { - throw new Error("Expected Progress outcome"); - } - const checkedPayjoinProposalPsbtInner: bitcoin.Psbt = - checkedPayjoinProposalPsbt.inner.inner; const payjoinPsbt = JSON.parse( - sender.call("walletprocesspsbt", [ - checkedPayjoinProposalPsbtInner.serializeBase64(), - ]), + sender.call("walletprocesspsbt", [pollOutcome.inner.psbtBase64]), ).psbt; - const finalPsbt = JSON.parse( + const finalPsbtJson = JSON.parse( sender.call("finalizepsbt", [payjoinPsbt, JSON.stringify(false)]), - ).psbt; - const payjoinTx = bitcoin.Psbt.deserializeBase64(finalPsbt).extractTx(); - sender.call("sendrawtransaction", [ - JSON.stringify(Buffer.from(payjoinTx.serialize()).toString("hex")), - ]); - - const networkFees = bitcoin.Psbt.deserializeBase64(finalPsbt).fee().toBtc(); - assert.strictEqual(payjoinTx.input().length, 2, "Should have 2 inputs"); - assert.strictEqual(payjoinTx.output().length, 1, "Should have 1 output"); + ); + const finalPsbt = finalPsbtJson.psbt as string; + const extraction = JSON.parse( + sender.call("finalizepsbt", [payjoinPsbt, JSON.stringify(true)]), + ); + const finalHex = extraction.hex as string; + sender.call("sendrawtransaction", [JSON.stringify(finalHex)]); + + const decodedPsbt = JSON.parse( + sender.call("decodepsbt", [JSON.stringify(finalPsbt)]), + ); + const networkFees = Number(decodedPsbt.fee); + const decodedTx = JSON.parse( + sender.call("decoderawtransaction", [JSON.stringify(finalHex)]), + ); + assert.strictEqual(decodedTx.vin.length, 2, "Should have 2 inputs"); + assert.strictEqual(decodedTx.vout.length, 1, "Should have 1 output"); const receiverBalance = JSON.parse(receiver.call("getbalances", [])).mine .untrusted_pending; diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index 8259581bb..d1f50ae69 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -1,6 +1,6 @@ import { describe, test, before } from "node:test"; import assert from "node:assert"; -import { payjoin, bitcoin, uniffiInitAsync } from "../dist/index.js"; +import { payjoin, uniffiInitAsync } from "../dist/index.js"; before(async () => { await uniffiInitAsync(); @@ -105,10 +105,7 @@ describe("URI tests", () => { describe("Persistence tests", () => { test("receiver persistence", () => { const persister = new InMemoryReceiverPersister(1); - const address = new bitcoin.Address( - "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", - bitcoin.Network.Signet, - ); + const address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; const ohttpKeys = payjoin.OhttpKeys.decode( new Uint8Array([ 0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a, @@ -141,10 +138,7 @@ describe("Persistence tests", () => { test("sender persistence", () => { const persister = new InMemoryReceiverPersister(1); - const address = new bitcoin.Address( - "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", - bitcoin.Network.Testnet, - ); + const address = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK"; const ohttpKeys = payjoin.OhttpKeys.decode( new Uint8Array([ 0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a, @@ -177,3 +171,47 @@ describe("Persistence tests", () => { assert.ok(withReplyKey, "Sender should be created successfully"); }); }); + +describe("Validation", () => { + test("receiver builder rejects bad address", () => { + assert.throws(() => { + new payjoin.ReceiverBuilder( + "not-an-address", + "https://example.com", + payjoin.OhttpKeys.decode( + new Uint8Array([ + 0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, + 0x4a, 0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, + 0x4d, 0xa1, 0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, + 0x12, 0xd8, 0xa7, 0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, + 0x82, 0x4f, 0xe2, 0xbe, 0xe8, 0xd2, 0x8b, 0xfe, 0x2e, + 0xb2, 0xfc, 0x64, 0x53, 0xbc, 0x4d, 0x31, 0xcd, 0x85, + 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c, 0x53, 0x82, 0xaf, + 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04, 0x00, 0x01, + 0x00, 0x03, + ]).buffer, + ), + ); + }); + }); + + test("input pair rejects invalid outpoint", () => { + assert.throws(() => { + const txin = payjoin.PlainTxIn.create({ + previousOutput: payjoin.PlainOutPoint.create({ + txid: "deadbeef", + vout: 0, + }), + scriptSig: new Uint8Array([]), + sequence: 0, + witness: [], + }); + const psbtIn = payjoin.PlainPsbtInput.create({ + witnessUtxo: undefined, + redeemScript: undefined, + witnessScript: undefined, + }); + new payjoin.InputPair(txin, psbtIn, undefined); + }); + }); +}); diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index bd62d72fd..ccb30e6d5 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -5,7 +5,13 @@ from payjoin import * from typing import Optional -import payjoin.bitcoin as bitcoinffi +import unittest + +try: + import payjoin.bitcoin as bitcoinffi +except ImportError: + bitcoinffi = None + raise unittest.SkipTest("bitcoin_ffi helpers are not available in this binding") # The below sys path setting is required to use the 'payjoin' module in the 'src' directory # This script is in the 'tests' directory and the 'payjoin' module is in the 'src' directory @@ -13,7 +19,6 @@ 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) ) -import unittest from pprint import * diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index e18ac25e3..f08803a84 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -1,6 +1,5 @@ import unittest import payjoin -import payjoin.bitcoin class TestURIs(unittest.TestCase): @@ -52,11 +51,8 @@ def close(self): class TestReceiverPersistence(unittest.TestCase): def test_receiver_persistence(self): persister = InMemoryReceiverPersister(1) - address = payjoin.bitcoin.Address( - "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", payjoin.bitcoin.Network.SIGNET - ) payjoin.ReceiverBuilder( - address, + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", payjoin.OhttpKeys.decode( bytes.fromhex( @@ -88,12 +84,9 @@ class TestSenderPersistence(unittest.TestCase): def test_sender_persistence(self): # Create a receiver to just get the pj uri persister = InMemoryReceiverPersister(1) - address = payjoin.bitcoin.Address( - "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", payjoin.bitcoin.Network.TESTNET - ) receiver = ( payjoin.ReceiverBuilder( - address, + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", payjoin.OhttpKeys.decode( bytes.fromhex( diff --git a/payjoin-ffi/src/bitcoin_ffi.rs b/payjoin-ffi/src/bitcoin_ffi.rs deleted file mode 100644 index 59da4f59d..000000000 --- a/payjoin-ffi/src/bitcoin_ffi.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::sync::Arc; - -pub use bitcoin_ffi::*; -use payjoin::bitcoin; - -#[derive(Debug, Clone, uniffi::Record)] -pub struct PsbtInput { - pub witness_utxo: Option, - pub redeem_script: Option>, - pub witness_script: Option>, -} - -impl PsbtInput { - pub fn new( - witness_utxo: Option, - redeem_script: Option>, - witness_script: Option>, - ) -> Self { - Self { witness_utxo, redeem_script, witness_script } - } -} - -impl From for PsbtInput { - fn from(psbt_input: bitcoin::psbt::Input) -> Self { - Self { - witness_utxo: psbt_input.witness_utxo.map(|s| s.into()), - redeem_script: psbt_input.redeem_script.clone().map(|s| Arc::new(s.into())), - witness_script: psbt_input.witness_script.clone().map(|s| Arc::new(s.into())), - } - } -} - -impl From for bitcoin::psbt::Input { - fn from(psbt_input: PsbtInput) -> Self { - Self { - witness_utxo: psbt_input.witness_utxo.map(|s| s.into()), - redeem_script: psbt_input.redeem_script.map(|s| Arc::unwrap_or_clone(s).into()), - witness_script: psbt_input.witness_script.map(|s| Arc::unwrap_or_clone(s).into()), - ..Default::default() - } - } -} - -#[derive(uniffi::Record)] -pub struct Weight { - pub weight_units: u64, -} - -impl From for Weight { - fn from(weight: bitcoin::Weight) -> Self { Self { weight_units: weight.to_wu() } } -} - -impl From for bitcoin::Weight { - fn from(weight: Weight) -> Self { bitcoin::Weight::from_wu(weight.weight_units) } -} diff --git a/payjoin-ffi/src/lib.rs b/payjoin-ffi/src/lib.rs index 6d23dbd5f..d138f1b3b 100644 --- a/payjoin-ffi/src/lib.rs +++ b/payjoin-ffi/src/lib.rs @@ -1,6 +1,5 @@ #![crate_name = "payjoin_ffi"] -pub mod bitcoin_ffi; pub mod error; #[cfg(feature = "io")] pub mod io; @@ -15,7 +14,6 @@ pub mod uri; pub use payjoin::persist::NoopSessionPersister; -pub use crate::bitcoin_ffi::*; pub use crate::ohttp::*; pub use crate::output_substitution::*; pub use crate::receive::*; diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 3ef52f6bb..0d4d84343 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -89,6 +89,43 @@ impl_persisted_error_from!(payjoin::IntoUrlError, |api_err: payjoin::IntoUrlErro ReceiverError::IntoUrl(Arc::new(api_err.into())) }); +/// Error that may occur when building a receiver session. +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[non_exhaustive] +pub enum ReceiverBuilderError { + /// The provided Bitcoin address is invalid. + #[error("Invalid Bitcoin address: {0}")] + InvalidAddress(Arc), + /// Error that may occur when converting a value into a URL. + #[error("Invalid directory URL: {0}")] + IntoUrl(Arc), +} + +impl From for ReceiverBuilderError { + fn from(value: payjoin::IntoUrlError) -> Self { + ReceiverBuilderError::IntoUrl(Arc::new(value.into())) + } +} + +impl From for ReceiverBuilderError { + fn from(value: payjoin::bitcoin::address::ParseError) -> Self { + ReceiverBuilderError::InvalidAddress(Arc::new(value.into())) + } +} + +/// Error parsing a Bitcoin address. +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[error("Invalid Bitcoin address: {msg}")] +pub struct AddressParseError { + msg: String, +} + +impl From for AddressParseError { + fn from(value: payjoin::bitcoin::address::ParseError) -> Self { + AddressParseError { msg: value.to_string() } + } +} + /// The replyable error type for the payjoin receiver, representing failures need to be /// returned to the sender. /// @@ -151,6 +188,23 @@ pub struct InputContributionError(#[from] receive::InputContributionError); #[error(transparent)] pub struct PsbtInputError(#[from] receive::PsbtInputError); +/// Error constructing an [`InputPair`](crate::InputPair). +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum InputPairError { + /// Provided outpoint could not be parsed. + #[error("Invalid outpoint (txid={txid}, vout={vout})")] + InvalidOutPoint { txid: String, vout: u32 }, + /// PSBT input failed validation in the core library. + #[error("Invalid PSBT input: {0}")] + InvalidPsbtInput(Arc), +} + +impl InputPairError { + pub fn invalid_outpoint(txid: String, vout: u32) -> Self { + InputPairError::InvalidOutPoint { txid, vout } + } +} + /// Error that may occur when a receiver event log is replayed #[derive(Debug, thiserror::Error, uniffi::Object)] #[error(transparent)] diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 16e631a87..963001463 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -3,20 +3,20 @@ use std::sync::{Arc, RwLock}; use std::time::Duration; pub use error::{ - InputContributionError, JsonReply, OutputSubstitutionError, ProtocolError, PsbtInputError, - ReceiverError, SelectionError, SessionError, + AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, + ProtocolError, PsbtInputError, ReceiverBuilderError, ReceiverError, SelectionError, + SessionError, }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{Amount, FeeRate}; use payjoin::persist::{MaybeFatalTransition, NextStateTransition}; -use crate::bitcoin_ffi::{Address, OutPoint, Script, TxOut}; use crate::error::ForeignError; pub use crate::error::{ImplementationError, SerdeJsonError}; use crate::ohttp::OhttpKeys; use crate::receive::error::{ReceiverPersistedError, ReceiverReplayError}; -use crate::uri::error::{FeeRateError, IntoUrlError}; +use crate::uri::error::FeeRateError; use crate::{ClientResponse, OutputSubstitution, Request}; pub mod error; @@ -178,8 +178,8 @@ impl ReceiverSessionHistory { pub fn pj_uri(&self) -> Arc { Arc::new(self.0.pj_uri().into()) } /// Fallback transaction from the session if present - pub fn fallback_tx(&self) -> Option> { - self.0.fallback_tx().map(|tx| Arc::new(tx.into())) + pub fn fallback_tx(&self) -> Option> { + self.0.fallback_tx().map(|tx| payjoin::bitcoin::consensus::encode::serialize(&tx)) } /// Helper method to query the current status of the session. @@ -220,6 +220,110 @@ impl InitialReceiveTransition { #[derive(Clone, Debug, uniffi::Object)] pub struct ReceiverBuilder(payjoin::receive::v2::ReceiverBuilder); +/// Primitive representation of a transaction output for the FFI boundary. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct PlainTxOut { + /// Amount in satoshis. + pub value_sat: u64, + /// Raw scriptPubKey bytes. + pub script_pubkey: Vec, +} + +impl From for payjoin::bitcoin::TxOut { + fn from(value: PlainTxOut) -> Self { + payjoin::bitcoin::TxOut { + value: Amount::from_sat(value.value_sat), + script_pubkey: payjoin::bitcoin::ScriptBuf::from_bytes(value.script_pubkey), + } + } +} + +impl From for PlainTxOut { + fn from(value: payjoin::bitcoin::TxOut) -> Self { + PlainTxOut { + value_sat: value.value.to_sat(), + script_pubkey: value.script_pubkey.into_bytes(), + } + } +} + +/// Primitive representation of a transaction input for the FFI boundary. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct PlainTxIn { + pub previous_output: PlainOutPoint, + pub script_sig: Vec, + pub sequence: u32, + pub witness: Vec>, +} + +impl PlainTxIn { + fn into_core(self) -> Result { + let previous_output = self.previous_output.into_core()?; + Ok(payjoin::bitcoin::TxIn { + previous_output, + script_sig: payjoin::bitcoin::ScriptBuf::from_bytes(self.script_sig), + sequence: payjoin::bitcoin::Sequence(self.sequence), + witness: self.witness.into(), + }) + } +} + +/// Primitive representation of an outpoint for the FFI boundary. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct PlainOutPoint { + /// Hex-encoded txid (big-endian). + pub txid: String, + /// Output index. + pub vout: u32, +} + +impl From for PlainOutPoint { + fn from(value: payjoin::bitcoin::OutPoint) -> Self { + PlainOutPoint { txid: value.txid.to_string(), vout: value.vout } + } +} + +impl PlainOutPoint { + fn into_core(self) -> Result { + let txid = payjoin::bitcoin::Txid::from_str(&self.txid) + .map_err(|_| InputPairError::invalid_outpoint(self.txid, self.vout))?; + Ok(payjoin::bitcoin::OutPoint { txid, vout: self.vout }) + } +} + +/// Primitive representation of a PSBT input for the FFI boundary. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct PlainPsbtInput { + pub witness_utxo: Option, + pub redeem_script: Option>, + pub witness_script: Option>, +} + +impl PlainPsbtInput { + fn into_core(self) -> payjoin::bitcoin::psbt::Input { + payjoin::bitcoin::psbt::Input { + witness_utxo: self.witness_utxo.map(Into::into), + redeem_script: self.redeem_script.map(payjoin::bitcoin::ScriptBuf::from_bytes), + witness_script: self.witness_script.map(payjoin::bitcoin::ScriptBuf::from_bytes), + ..Default::default() + } + } +} + +/// Primitive representation of a weight measurement. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct PlainWeight { + pub weight_units: u64, +} + +impl From for payjoin::bitcoin::Weight { + fn from(value: PlainWeight) -> Self { payjoin::bitcoin::Weight::from_wu(value.weight_units) } +} + +impl From for PlainWeight { + fn from(value: payjoin::bitcoin::Weight) -> Self { PlainWeight { weight_units: value.to_wu() } } +} + #[uniffi::export] impl ReceiverBuilder { /// Creates a new [`Initialized`] with the provided parameters. @@ -233,17 +337,20 @@ impl ReceiverBuilder { /// - [BIP 77: Payjoin Version 2: Serverless Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0077.md) #[uniffi::constructor] pub fn new( - address: Arc
, + address: String, directory: String, ohttp_keys: Arc, - ) -> Result { + ) -> Result { + let parsed_address = payjoin::bitcoin::Address::from_str(address.as_str()) + .map_err(ReceiverBuilderError::from)? + .assume_checked(); Ok(Self( payjoin::receive::v2::ReceiverBuilder::new( - Arc::unwrap_or_clone(address).into(), + parsed_address, directory, Arc::unwrap_or_clone(ohttp_keys).into(), ) - .map_err(IntoUrlError::from)?, + .map_err(ReceiverBuilderError::from)?, )) } @@ -260,9 +367,9 @@ impl ReceiverBuilder { &self, max_effective_fee_rate_sat_per_vb: u64, ) -> Result { - let fee_rate = bitcoin_ffi::FeeRate::from_sat_per_vb(max_effective_fee_rate_sat_per_vb) - .map_err(FeeRateError::from)?; - Ok(Self(self.0.clone().with_max_fee_rate(fee_rate.into()))) + let fee_rate = FeeRate::from_sat_per_vb(max_effective_fee_rate_sat_per_vb) + .ok_or_else(|| FeeRateError::overflow(max_effective_fee_rate_sat_per_vb))?; + Ok(Self(self.0.clone().with_max_fee_rate(fee_rate))) } pub fn build(&self) -> InitialReceiveTransition { @@ -572,7 +679,7 @@ impl_save_for_transition!(MaybeInputsSeenTransition, OutputsUnknown); #[uniffi::export(with_foreign)] pub trait IsOutputKnown: Send + Sync { - fn callback(&self, outpoint: OutPoint) -> Result; + fn callback(&self, outpoint: PlainOutPoint) -> Result; } #[uniffi::export] @@ -584,7 +691,7 @@ impl MaybeInputsSeen { MaybeInputsSeenTransition(Arc::new(RwLock::new(Some( self.0.clone().check_no_inputs_seen_before(&mut |outpoint| { is_known - .callback((*outpoint).into()) + .callback(PlainOutPoint::from(*outpoint)) .map_err(|e| ImplementationError::new(e).into()) }), )))) @@ -672,25 +779,27 @@ impl WantsOutputs { pub fn replace_receiver_outputs( &self, - replacement_outputs: Vec, - drain_script: &Script, + replacement_outputs: Vec, + drain_script_pubkey: Vec, ) -> Result { let replacement_outputs: Vec = - replacement_outputs.iter().map(|o| o.clone().into()).collect(); + replacement_outputs.into_iter().map(Into::into).collect(); + let drain_script = payjoin::bitcoin::ScriptBuf::from_bytes(drain_script_pubkey); self.0 .clone() - .replace_receiver_outputs(replacement_outputs, &drain_script.0) + .replace_receiver_outputs(replacement_outputs, &drain_script) .map(Into::into) .map_err(Into::into) } pub fn substitute_receiver_script( &self, - output_script: &Script, + output_script_pubkey: Vec, ) -> Result { + let output_script = payjoin::bitcoin::ScriptBuf::from_bytes(output_script_pubkey); self.0 .clone() - .substitute_receiver_script(&output_script.0) + .substitute_receiver_script(&output_script) .map(Into::into) .map_err(Into::into) } @@ -776,15 +885,16 @@ pub struct InputPair(payjoin::receive::InputPair); impl InputPair { #[uniffi::constructor] pub fn new( - txin: bitcoin_ffi::TxIn, - psbtin: crate::bitcoin_ffi::PsbtInput, - expected_weight: Option, - ) -> Result { - Ok(Self(payjoin::receive::InputPair::new( - txin.into(), - psbtin.into(), - expected_weight.map(|w| w.into()), - )?)) + txin: PlainTxIn, + psbtin: PlainPsbtInput, + expected_weight: Option, + ) -> Result { + let txin = txin.into_core()?; + let psbtin = psbtin.into_core(); + let expected_weight = expected_weight.map(Into::into); + payjoin::receive::InputPair::new(txin, psbtin, expected_weight) + .map(Self) + .map_err(|err| InputPairError::InvalidPsbtInput(Arc::new(err.into()))) } } @@ -911,7 +1021,7 @@ impl ProvisionalProposal { )))) } - pub fn psbt_to_sign(&self) -> bitcoin_ffi::Psbt { self.0.clone().psbt_to_sign().into() } + pub fn psbt_to_sign(&self) -> String { self.0.clone().psbt_to_sign().to_string() } } #[derive(Clone, uniffi::Object)] @@ -953,14 +1063,14 @@ impl_save_for_transition!(PayjoinProposalTransition, Monitor); #[uniffi::export] impl PayjoinProposal { - pub fn utxos_to_be_locked(&self) -> Vec { - let mut outpoints: Vec = Vec::new(); + pub fn utxos_to_be_locked(&self) -> Vec { + let mut outpoints: Vec = Vec::new(); for o in , >>::into(self.clone()) .utxos_to_be_locked() { - outpoints.push((*o).into()); + outpoints.push(PlainOutPoint::from(*o)); } outpoints } @@ -1081,11 +1191,6 @@ pub trait TransactionExists: Send + Sync { fn callback(&self, txid: String) -> Result>, ForeignError>; } -#[uniffi::export(with_foreign)] -pub trait OutpointSpent: Send + Sync { - fn callback(&self, outpoint: OutPoint) -> Result; -} - #[allow(clippy::type_complexity)] #[derive(uniffi::Object)] pub struct MonitorTransition( diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index 194aed49e..fd6f7983c 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -1,7 +1,6 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; -use bitcoin_ffi::Psbt; pub use error::{BuildSenderError, CreateRequestError, EncapsulationError, ResponseError}; use crate::error::ForeignError; @@ -68,6 +67,28 @@ impl From for payjoin::send::v2::SessionOutcome { fn from(value: SenderSessionOutcome) -> Self { value.0 } } +#[uniffi::export] +impl SenderSessionOutcome { + pub fn is_success(&self) -> bool { + matches!(self.0, payjoin::send::v2::SessionOutcome::Success(_)) + } + + pub fn success_psbt_base64(&self) -> Option { + match &self.0 { + payjoin::send::v2::SessionOutcome::Success(psbt) => Some(psbt.to_string()), + _ => None, + } + } + + pub fn is_failure(&self) -> bool { + matches!(self.0, payjoin::send::v2::SessionOutcome::Failure) + } + + pub fn is_cancelled(&self) -> bool { + matches!(self.0, payjoin::send::v2::SessionOutcome::Cancel) + } +} + #[derive(Clone, uniffi::Enum)] pub enum SendSession { WithReplyKey { inner: Arc }, @@ -151,7 +172,9 @@ impl From for payjoin::send::v2::SessionHistory { #[uniffi::export] impl SenderSessionHistory { /// Fallback transaction from the session if present - pub fn fallback_tx(&self) -> Arc { Arc::new(self.0.fallback_tx().into()) } + pub fn fallback_tx(&self) -> Vec { + payjoin::bitcoin::consensus::encode::serialize(&self.0.fallback_tx()) + } pub fn pj_param(&self) -> Arc { Arc::new(self.0.pj_param().to_owned().into()) } @@ -403,7 +426,7 @@ impl From> for #[derive(uniffi::Enum)] pub enum PollingForProposalTransitionOutcome { - Progress { inner: Arc }, + Progress { psbt_base64: String }, Stasis { inner: Arc }, } @@ -423,7 +446,7 @@ impl ) -> Self { match value { payjoin::persist::OptionalTransitionOutcome::Progress(psbt) => - Self::Progress { inner: Arc::new(psbt.into()) }, + Self::Progress { psbt_base64: psbt.to_string() }, payjoin::persist::OptionalTransitionOutcome::Stasis(state) => Self::Stasis { inner: Arc::new(state.into()) }, } diff --git a/payjoin-ffi/src/test_utils.rs b/payjoin-ffi/src/test_utils.rs index 2a1aa9c25..36eaf34db 100644 --- a/payjoin-ffi/src/test_utils.rs +++ b/payjoin-ffi/src/test_utils.rs @@ -1,12 +1,10 @@ use std::io; use std::sync::Arc; -use bitcoin_ffi::Psbt; use lazy_static::lazy_static; use payjoin_test_utils::corepc_node::AddressType; use payjoin_test_utils::{ - corepc_node, EXAMPLE_URL, INVALID_PSBT, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, - PARSED_PAYJOIN_PROPOSAL, PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO, PAYJOIN_PROPOSAL, + corepc_node, EXAMPLE_URL, INVALID_PSBT, ORIGINAL_PSBT, PAYJOIN_PROPOSAL, PAYJOIN_PROPOSAL_WITH_SENDER_INFO, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION, }; use serde_json::Value; @@ -210,14 +208,3 @@ pub fn payjoin_proposal_with_sender_info() -> String { #[uniffi::export] pub fn receiver_input_contribution() -> String { RECEIVER_INPUT_CONTRIBUTION.to_string() } - -#[uniffi::export] -pub fn parsed_original_psbt() -> Psbt { PARSED_ORIGINAL_PSBT.clone().into() } - -#[uniffi::export] -pub fn parsed_payjoin_proposal() -> Psbt { PARSED_PAYJOIN_PROPOSAL.clone().into() } - -#[uniffi::export] -pub fn parsed_payjoin_proposal_with_sender_info() -> Psbt { - PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO.clone().into() -} diff --git a/payjoin-ffi/src/uri/error.rs b/payjoin-ffi/src/uri/error.rs index 706624ce5..89ec624cc 100644 --- a/payjoin-ffi/src/uri/error.rs +++ b/payjoin-ffi/src/uri/error.rs @@ -29,5 +29,17 @@ pub struct UrlParseError(#[from] url::ParseError); pub struct IntoUrlError(#[from] payjoin::IntoUrlError); #[derive(Debug, thiserror::Error, uniffi::Object)] -#[error(transparent)] -pub struct FeeRateError(#[from] bitcoin_ffi::error::FeeRateError); +#[error("{msg}")] +pub struct FeeRateError { + msg: String, +} + +impl FeeRateError { + pub(crate) fn overflow(value_sat_per_vb: u64) -> Self { + Self { + msg: format!( + "Fee rate {value_sat_per_vb} sat/vB exceeds the supported range for this platform" + ), + } + } +}