ADD: psbt cosign

This commit is contained in:
Ivan 2021-02-18 16:37:43 +03:00 committed by GitHub
parent e3c1867a16
commit 5322edc9bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 380 additions and 6 deletions

View File

@ -60,6 +60,7 @@ import SendCreate from './screen/send/create';
import Confirm from './screen/send/confirm';
import PsbtWithHardwareWallet from './screen/send/psbtWithHardwareWallet';
import PsbtMultisig from './screen/send/psbtMultisig';
import PsbtMultisigQRCode from './screen/send/psbtMultisigQRCode';
import Success from './screen/send/success';
import Broadcast from './screen/send/broadcast';
import IsItMyAddress from './screen/send/isItMyAddress';
@ -78,7 +79,6 @@ import DrawerList from './screen/wallets/drawerList';
import { isTablet } from 'react-native-device-info';
import SettingsPrivacy from './screen/settings/SettingsPrivacy';
import LNDViewAdditionalInvoicePreImage from './screen/lnd/lndViewAdditionalInvoicePreImage';
import PsbtMultisigQRCode from './screen/send/psbtMultisigQRCode';
const defaultScreenOptions =
Platform.OS === 'ios'

View File

@ -1048,4 +1048,51 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
}
return false;
}
calculateHowManySignaturesWeHaveFromPsbt(psbt) {
let sigsHave = 0;
for (const inp of psbt.data.inputs) {
if (inp.finalScriptSig || inp.finalScriptWitness || inp.partialSig) sigsHave++;
}
return sigsHave;
}
/**
* Tries to signs passed psbt object (by reference). If there are enough signatures - tries to finalize psbt
* and returns Transaction (ready to extract hex)
*
* @param psbt {Psbt}
* @returns {{ tx: Transaction }}
*/
cosignPsbt(psbt) {
const mnemonic = this.secret;
const seed = bip39.mnemonicToSeed(mnemonic);
const hdRoot = HDNode.fromSeed(seed);
for (let cc = 0; cc < psbt.inputCount; cc++) {
try {
psbt.signInputHD(cc, hdRoot);
} catch (e) {} // protects agains duplicate cosignings
if (!psbt.inputHasHDKey(cc, hdRoot)) {
for (const derivation of psbt.data.inputs[cc].bip32Derivation || []) {
const splt = derivation.path.split('/');
const internal = +splt[splt.length - 2];
const index = +splt[splt.length - 1];
const wif = this._getWIFByIndex(internal, index);
const keyPair = bitcoin.ECPair.fromWIF(wif);
try {
psbt.signInput(cc, keyPair);
} catch (e) {} // protects agains duplicate cosignings or if this output can't be signed with current wallet
}
}
}
let tx = false;
if (this.calculateHowManySignaturesWeHaveFromPsbt(psbt) === psbt.inputCount) {
tx = psbt.finalizeAllInputs().extractTransaction();
}
return { tx };
}
}

View File

@ -119,6 +119,10 @@ export class AbstractWallet {
return false;
}
allowCosignPsbt() {
return false;
}
weOwnAddress(address) {
throw Error('not implemented');
}

View File

@ -21,6 +21,10 @@ export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet {
return true;
}
allowCosignPsbt() {
return true;
}
getXpub() {
if (this._xpub) {
return this._xpub; // cache hit

View File

@ -32,4 +32,8 @@ export class HDSegwitBech32Wallet extends AbstractHDElectrumWallet {
allowPayJoin() {
return true;
}
allowCosignPsbt() {
return true;
}
}

View File

@ -21,6 +21,10 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet {
return true;
}
allowCosignPsbt() {
return true;
}
/**
* Get internal/external WIF by wallet index
* @param {Boolean} internal

View File

@ -105,6 +105,7 @@ export class DynamicQRCode extends Component {
return (
<View style={animatedQRCodeStyle.container}>
<TouchableOpacity
testID="DynamicCode"
style={animatedQRCodeStyle.qrcodeContainer}
onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);

29
helpers/scan-qr.js Normal file
View File

@ -0,0 +1,29 @@
/**
* Helper function that navigates to ScanQR screen, and returns promise that will resolve with the result of a scan,
* and then navigates back. If QRCode scan was closed, promise resolves to null.
*
* @param navigateFunc {function}
* @param currentScreenName {string}
*
* @return {Promise<string>}
*/
module.exports = function (navigateFunc, currentScreenName) {
return new Promise(resolve => {
const params = {};
params.showFileImportButton = true;
params.onBarScanned = function (data) {
setTimeout(() => resolve(data.data || data), 1);
navigateFunc(currentScreenName);
};
params.onDismiss = function () {
setTimeout(() => resolve(null), 1);
};
navigateFunc('ScanQRCodeRoot', {
screen: 'ScanQRCode',
params,
});
});
};

View File

@ -212,6 +212,7 @@
"input_total": "Total:",
"permission_camera_message": "We need your permission to use your camera.",
"permission_camera_title": "Permission to use camera",
"psbt_sign": "Sign a transaction",
"open_settings": "Open Settings",
"permission_storage_later": "Ask me later",
"permission_storage_message": "BlueWallet needs your permission to access your storage to save this file.",
@ -348,7 +349,7 @@
"details_title": "Transaction",
"details_to": "Output",
"details_transaction_details": "Transaction Details",
"enable_hw": "This wallet is not being used in conjunction with a hardware wallet. Would you like to enable hardware wallet use?",
"enable_offline_signing": "This wallet is not being used in conjunction with an offline signing. Would you wish to enable it now?",
"list_conf": "Conf: {number}",
"pending": "Pending",
"list_title": "Transactions",

View File

@ -91,7 +91,7 @@ const ScanQRCode = () => {
const navigation = useNavigation();
const route = useRoute();
const showFileImportButton = route.params.showFileImportButton || false;
const { launchedBy, onBarScanned } = route.params;
const { launchedBy, onBarScanned, onDismiss } = route.params;
const scannedCache = {};
const { colors } = useTheme();
const isFocused = useIsFocused();
@ -239,6 +239,7 @@ const ScanQRCode = () => {
} else {
navigation.goBack();
}
if (onDismiss) onDismiss();
};
const handleCameraStatusChange = event => {

View File

@ -28,6 +28,7 @@ import Privacy from '../../blue_modules/Privacy';
import { BitcoinUnit } from '../../models/bitcoinUnits';
import loc from '../../loc';
import { BlueCurrentTheme } from '../../components/themes';
import { DynamicQRCode } from '../../components/DynamicQRCode';
const currency = require('../../blue_modules/currency');
export default class SendCreate extends Component {
@ -45,6 +46,8 @@ export default class SendCreate extends Component {
satoshiPerByte: props.route.params.satoshiPerByte,
wallet: props.route.params.wallet,
feeSatoshi: props.route.params.feeSatoshi,
showAnimatedQr: props.route.params.showAnimatedQr ?? false,
psbt: props.route.params.psbt,
};
}
@ -137,6 +140,7 @@ export default class SendCreate extends Component {
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<ScrollView>
<BlueCard style={styles.card}>
{this.state.showAnimatedQr && this.state.psbt ? <DynamicQRCode value={this.state.psbt.toHex()} capacity={666} /> : null}
<BlueText style={styles.cardText}>{loc.send.create_this_is_hex}</BlueText>
<TextInput testID="TxhexInput" style={styles.cardTx} height={72} multiline editable value={this.state.tx} />
@ -206,7 +210,6 @@ const styles = StyleSheet.create({
},
root: {
flex: 1,
paddingTop: 19,
backgroundColor: BlueCurrentTheme.colors.elevated,
},
card: {

View File

@ -51,6 +51,7 @@ import { BlueStorageContext } from '../../blue_modules/storage-context';
const currency = require('../../blue_modules/currency');
const prompt = require('../../blue_modules/prompt');
const fs = require('../../blue_modules/fs');
const scanqr = require('../../helpers/scan-qr');
const btcAddressRx = /^[a-zA-Z0-9]{26,35}$/;
@ -1109,6 +1110,48 @@ export default class SendDetails extends Component {
);
};
handlePsbtSign = async () => {
this.setState({ isAdvancedTransactionOptionsVisible: false, isLoading: true });
await new Promise(resolve => setTimeout(resolve, 100)); // sleep for animations
const scannedData = await scanqr(this.props.navigation.navigate, this.props.route.name);
if (!scannedData) return this.setState({ isLoading: false });
/** @type {HDSegwitBech32Wallet} */
const wallet = this.state.fromWallet;
let tx;
let psbt;
try {
psbt = bitcoin.Psbt.fromBase64(scannedData);
tx = wallet.cosignPsbt(psbt).tx;
} catch (e) {
alert(e.message);
return;
} finally {
this.setState({ isLoading: false });
}
if (!tx) return this.setState({ isLoading: false });
// we need to remove change address from recipients, so that Confirm screen show more accurate info
const changeAddresses = [];
for (let c = 0; c < wallet.next_free_change_address_index + wallet.gap_limit; c++) {
changeAddresses.push(wallet._getInternalAddressByIndex(c));
}
const recipients = psbt.txOutputs.filter(({ address }) => !changeAddresses.includes(address));
this.props.navigation.navigate('CreateTransaction', {
fee: new BigNumber(psbt.getFee()).dividedBy(100000000).toNumber(),
feeSatoshi: psbt.getFee(),
wallet,
tx: tx.toHex(),
recipients,
satoshiPerByte: psbt.getFeeRate(),
showAnimatedQr: true,
psbt,
});
};
hideAdvancedTransactionOptionsModal = () => {
Keyboard.dismiss();
this.setState({ isAdvancedTransactionOptionsVisible: false });
@ -1203,6 +1246,15 @@ export default class SendDetails extends Component {
component={TouchableOpacity}
onPress={this.handleCoinControl}
/>
{this.state.fromWallet.allowCosignPsbt() && (
<BlueListItem
testID="PsbtSign"
title={loc.send.psbt_sign}
hideChevron
component={TouchableOpacity}
onPress={this.handlePsbtSign}
/>
)}
</View>
</KeyboardAvoidingView>
</BottomModal>

View File

@ -492,7 +492,7 @@ const WalletTransactions = () => {
} else {
Alert.alert(
loc.wallets.details_title,
loc.transactions.enable_hw,
loc.transactions.enable_offline_signing,
[
{
text: loc._.ok,

View File

@ -361,7 +361,7 @@ describe('BlueWallet UI Tests', () => {
process.env.TRAVIS && require('fs').writeFileSync(lockFile, '1');
});
it('can import BIP84 mnemonic, fetch balance & transactions, then create a transaction', async () => {
it('can import BIP84 mnemonic, fetch balance & transactions, then create a transaction; then cosign', async () => {
const lockFile = '/tmp/travislock.' + hashIt(jasmine.currentTest.fullName);
if (process.env.TRAVIS) {
if (require('fs').existsSync(lockFile))
@ -486,6 +486,28 @@ describe('BlueWallet UI Tests', () => {
assert.strictEqual(transaction.outs.length, 1, 'should be single output, no change');
assert.ok(transaction.outs[0].value > 100000);
// now, testing cosign psbt:
await device.pressBack();
await device.pressBack();
await element(by.id('SendButton')).tap();
await element(by.id('advancedOptionsMenuButton')).tap();
await element(by.id('PsbtSign')).tap();
// tapping 10 times invisible button is a backdoor:
for (let c = 0; c <= 5; c++) {
await element(by.id('ScanQrBackdoorButton')).tap();
await sleep(1000);
}
// 1 input, 2 outputs. wallet can fully sign this tx
const psbt =
'cHNidP8BAFICAAAAAXYa7FEQBAQ2X0B48aHHKKgzkVuHfQ2yCOi3v9RR0IqlAQAAAAAAAACAAegDAAAAAAAAFgAUSnH40G+jiJfreeRb36cs641KFm8AAAAAAAEBH5YVAAAAAAAAFgAUTKHjDm4OJQSbvy9uzyLYi5i5XIoiBgMQcGrP5TIMrdvb73yB4WnZvkPzKr1EzJXJYBHWmlPJZRgAAAAAVAAAgAAAAIAAAACAAQAAAD4AAAAAAA==';
await element(by.id('scanQrBackdoorInput')).replaceText(psbt);
await element(by.id('scanQrBackdoorOkButton')).tap();
// this is fully-signed tx, "this is tx hex" help text should appear
await yo('DynamicCode');
process.env.TRAVIS && require('fs').writeFileSync(lockFile, '1');
});

202
tests/unit/cosign.test.js Normal file
View File

@ -0,0 +1,202 @@
/* global it, describe */
import assert from 'assert';
import * as bitcoin from 'bitcoinjs-lib';
import { HDLegacyP2PKHWallet, HDSegwitBech32Wallet, HDSegwitP2SHWallet } from '../../class';
describe('AbstractHDElectrumWallet.cosign', () => {
it('different descendants of AbstractHDElectrumWallet can cosign one transaction', async () => {
if (!process.env.HD_MNEMONIC || !process.env.HD_MNEMONIC_BIP49) {
console.error('process.env.HD_MNEMONIC or HD_MNEMONIC_BIP49 not set, skipped');
return;
}
const w1 = new HDLegacyP2PKHWallet();
w1.setSecret(process.env.HD_MNEMONIC);
assert.ok(w1.validateMnemonic());
const w1Utxo = [
{
height: 554830,
value: 10000,
address: '186FBQmCV5W1xY7ywaWtTZPAQNciVN8Por',
vout: 0,
txid: '4f65c8cb159585c00d4deba9c5b36a2bcdfb1399a561114dcf6f2d0c1174bc5f',
amount: 10000,
confirmations: 1,
txhex:
'01000000000101e8d98effbb4fba4f0a89bcf217eb5a7e2f8efcae44f32ecacbc5d8cc3ce683c301000000171600148ba6d02e74c0a6e000e8b174eb2ed44e5ea211a6ffffffff0510270000000000001976a9144dc6cbf64df9ab106cee812c7501960b93e9217788ac204e0000000000001976a914bc2db6b74c8db9b188711dcedd511e6a305603f588ac30750000000000001976a9144dc6cbf64df9ab106cee812c7501960b93e9217788ac409c0000000000001976a914bc2db6b74c8db9b188711dcedd511e6a305603f588ac204716000000000017a914e286d58e53f9247a4710e51232cce0686f16873c8702483045022100af3800cd8171f154785cf13f46c092f61c1668f97db432bb4e7ed7bc812a8c6d022051bddca1eaf1ad8b5f3bd0ccde7447e56fd3c8709e5906f02ec6326e9a5b2ff30121039a421d5eb7c9de6590ae2a471cb556b60de8c6b056beb907dbdc1f5e6092f58800000000',
},
];
const w2 = new HDSegwitBech32Wallet();
w2.setSecret(process.env.HD_MNEMONIC);
assert.ok(w2.validateMnemonic());
const w2Utxo = [
{
height: 563077,
value: 50000,
address: 'bc1qt4t9xl2gmjvxgmp5gev6m8e6s9c85979ta7jeh',
vout: 1,
txid: 'ad00a92409d8982a1d7f877056dbed0c4337d2ebab70b30463e2802279fb936d',
amount: 50000,
confirmations: 1,
},
];
const w3 = new HDSegwitP2SHWallet();
w3.setSecret(process.env.HD_MNEMONIC_BIP49);
assert.ok(w3.validateMnemonic());
const w3Utxo = [
{
height: 591862,
value: 26000,
address: '3C5iv2Hp6nfuhkfTZibb7GJPkXj367eurD',
txid: 'fe9c4d1b240f270e9cda227c48e29b2983cb26aaab183b34454871d5d9acc987',
vout: 0,
amount: 26000,
confirmations: 1,
},
];
// now let's create transaction with 3 different inputs for each wallet and one output
// maybe in future bitcoin-js will support psbt.join() and this test can be simplified to:
// const { psbt } = w1.createTransaction(w1Utxo, [{address: w1._getExternalAddressByIndex(0)}], 1, w1._getInternalAddressByIndex(0), undefined, true)
// const { psbt:psbt2 } = w2.createTransaction(w2Utxo, [{address: w2._getExternalAddressByIndex(0)}], 1, w2._getInternalAddressByIndex(0), undefined, true)
// const { psbt:psbt3 } = w3.createTransaction(w3Utxo, [{address: w3._getExternalAddressByIndex(0)}], 1, w3._getInternalAddressByIndex(0), undefined, true)
// psbt.join(psbt2, psbt3)
// but for now, we will construct psbt by hand
const sequence = HDSegwitBech32Wallet.defaultRBFSequence;
const masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
const psbt = new bitcoin.Psbt();
// add one input from each wallet
{
// w1
const input = w1Utxo[0];
const pubkey = w1._getPubkeyByAddress(input.address);
const path = w1._getDerivationPathByAddress(input.address, 44);
psbt.addInput({
hash: input.txid,
index: input.vout,
sequence,
bip32Derivation: [
{
masterFingerprint: masterFingerprintBuffer,
path,
pubkey,
},
],
// non-segwit inputs now require passing the whole previous tx as Buffer
nonWitnessUtxo: Buffer.from(input.txhex, 'hex'),
});
}
{
// w2
const input = w2Utxo[0];
const pubkey = w2._getPubkeyByAddress(input.address);
const path = w2._getDerivationPathByAddress(input.address);
const p2wpkh = bitcoin.payments.p2wpkh({ pubkey });
psbt.addInput({
hash: input.txid,
index: input.vout,
sequence,
bip32Derivation: [
{
masterFingerprint: masterFingerprintBuffer,
path,
pubkey,
},
],
witnessUtxo: {
script: p2wpkh.output,
value: input.value,
},
});
}
{
// w3
const input = w3Utxo[0];
const pubkey = w3._getPubkeyByAddress(input.address);
const path = w3._getDerivationPathByAddress(input.address, 49);
const p2wpkh = bitcoin.payments.p2wpkh({ pubkey });
const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh });
psbt.addInput({
hash: input.txid,
index: input.vout,
sequence,
bip32Derivation: [
{
masterFingerprint: masterFingerprintBuffer,
path,
pubkey,
},
],
witnessUtxo: {
script: p2sh.output,
value: input.amount || input.value,
},
redeemScript: p2wpkh.output,
});
}
// send all to the one output
psbt.addOutput({
address: w1._getExternalAddressByIndex(0),
value: 10000,
});
assert.strictEqual(
psbt.toBase64(),
'cHNidP8BAKcCAAAAA1+8dBEMLW/PTRFhpZkT+80rarPFqetNDcCFlRXLyGVPAAAAAAAAAACAbZP7eSKA4mMEs3Cr69I3Qwzt21Zwh38dKpjYCSSpAK0BAAAAAAAAAICHyazZ1XFIRTQ7GKuqJsuDKZviSHwi2pwOJw8kG02c/gAAAAAAAAAAgAEQJwAAAAAAABl2qRRNxsv2TfmrEGzugSx1AZYLk+khd4isAAAAAAABAP1gAQEAAAAAAQHo2Y7/u0+6TwqJvPIX61p+L478rkTzLsrLxdjMPOaDwwEAAAAXFgAUi6bQLnTApuAA6LF06y7UTl6iEab/////BRAnAAAAAAAAGXapFE3Gy/ZN+asQbO6BLHUBlguT6SF3iKwgTgAAAAAAABl2qRS8Lba3TI25sYhxHc7dUR5qMFYD9YisMHUAAAAAAAAZdqkUTcbL9k35qxBs7oEsdQGWC5PpIXeIrECcAAAAAAAAGXapFLwttrdMjbmxiHEdzt1RHmowVgP1iKwgRxYAAAAAABepFOKG1Y5T+SR6RxDlEjLM4GhvFoc8hwJIMEUCIQCvOADNgXHxVHhc8T9GwJL2HBZo+X20MrtOfte8gSqMbQIgUb3coerxrYtfO9DM3nRH5W/TyHCeWQbwLsYybppbL/MBIQOaQh1et8neZZCuKkcctVa2DejGsFa+uQfb3B9eYJL1iAAAAAAiBgMW6EolVvMKGZVBYz9d2meHcQzKsmdxtwhPTJ4RBPR2ZxgAAAAALAAAgAAAAIAAAACAAAAAAAAAAAAAAQEfUMMAAAAAAAAWABRdVlN9SNyYZGw0RlmtnzqBcHoXxSIGAnqv8b0nSBLQEkZL4l3AZYcoektXhnjljJSaEzufuTx/GAAAAABUAACAAAAAgAAAAIAAAAAAAQAAAAABASCQZQAAAAAAABepFHH8oGeDfo3SSYkgJqW15AVPiyXhhwEEFgAUojm2oMvHqtwud2Q942MGphZ/rRUiBgICrDvRWeVNwx5lhCrV+aELTrAk6DhkoxmyfeZe4IsqORgAAAAAMQAAgAAAAIAAAACAAAAAAAAAAAAAAA==',
);
// now signing this psbt usign wallets one by one
// because BW users will pass psbt from one device to another base64 encoded, let's do the same
let tx;
assert.strictEqual(w1.calculateHowManySignaturesWeHaveFromPsbt(psbt), 0);
tx = w1.cosignPsbt(psbt).tx;
assert.strictEqual(w1.calculateHowManySignaturesWeHaveFromPsbt(psbt), 1);
assert.strictEqual(tx, false); // not yet fully-signed
tx = w2.cosignPsbt(psbt).tx;
assert.strictEqual(w2.calculateHowManySignaturesWeHaveFromPsbt(psbt), 2);
assert.strictEqual(tx, false); // not yet fully-signed
tx = w3.cosignPsbt(psbt).tx; // GREAT SUCCESS!
assert.strictEqual(w3.calculateHowManySignaturesWeHaveFromPsbt(psbt), 3);
assert.ok(tx);
assert.strictEqual(
tx.toHex(),
'020000000001035fbc74110c2d6fcf4d1161a59913fbcd2b6ab3c5a9eb4d0dc0859515cbc8654f000000006a473044022041df555e5f6a3769fafdbe23bfe29de84a1341b8fd85ffd279e238309c5df07702207cf1628b35ccacdb7d34e20fd46a3bc8adc0b1bd3b63249a3a4442b5a993d73501210316e84a2556f30a199541633f5dda6787710ccab26771b7084f4c9e1104f47667000000806d93fb792280e26304b370abebd237430ceddb5670877f1d2a98d80924a900ad01000000000000008087c9acd9d5714845343b18abaa26cb83299be2487c22da9c0e270f241b4d9cfe0000000017160014a239b6a0cbc7aadc2e77643de36306a6167fad15000000800110270000000000001976a9144dc6cbf64df9ab106cee812c7501960b93e9217788ac0002483045022100efe66403aba1441041dfdeff1f24b5e89ab5728ae7ceb9edb264eee004d5883c02207bf03cb611c9322086ac75fa97c374e9540c911359ede4f62de3c94c429ea2320121027aaff1bd274812d012464be25dc06587287a4b578678e58c949a133b9fb93c7f0247304402207a99c115f0b372d151caf991bb5af9f880e7d87625eeb4233fefa671489ed8e702200e5675b92e4e22b2fe37f563b2a0e75fb81def5a6efb431c7ca3b654ef63fe5801210202ac3bd159e54dc31e65842ad5f9a10b4eb024e83864a319b27de65ee08b2a3900000000',
);
});
it('HDSegwitBech32Wallet can cosign psbt with correct fingerprint', async () => {
if (!process.env.MNEMONICS_COBO) {
console.error('process.env.HD_MNEMONIC or HD_MNEMONIC_BIP49 not set, skipped');
return;
}
const w = new HDSegwitBech32Wallet();
w.setSecret(process.env.MNEMONICS_COBO);
assert.ok(w.validateMnemonic());
const psbtWithCorrectFpBase64 =
'cHNidP8BAFUCAAAAAfsmeQ1mJJqC9cD0DxDRFQoG2hvU6S4koB0jl+8TEDKjAAAAAAD/////AQpfAAAAAAAAGXapFBkSnVPmMZuvGdugWb6tFm35Crj1iKwAAAAAAAEBH8p3AAAAAAAAFgAUf8fcrCg92McSzWkmw+UAluC4IjsiBgLfsmddhS3oxlnlGrUPDBVoVHSMa8RcXlGsyhfc8CcGpRjTfq2IVAAAgAAAAIAAAACAAAAAAAQAAAAAAA==';
const psbtWithCorrectFp = bitcoin.Psbt.fromBase64(psbtWithCorrectFpBase64);
assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbtWithCorrectFp), 0);
const { tx } = w.cosignPsbt(psbtWithCorrectFp);
assert.ok(tx && tx.toHex());
assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbtWithCorrectFp), 1);
});
});