ADD: technical release to be able to send outputs with custom scripts (OP_RETURNs in particular)

This commit is contained in:
overtorment 2024-04-23 11:25:20 +01:00
parent f37818a956
commit 472cef126c
10 changed files with 118 additions and 39 deletions

View File

@ -6,7 +6,7 @@ import * as bip39 from 'bip39';
import * as bitcoin from 'bitcoinjs-lib'; import * as bitcoin from 'bitcoinjs-lib';
import { Transaction as BTransaction, Psbt } from 'bitcoinjs-lib'; import { Transaction as BTransaction, Psbt } from 'bitcoinjs-lib';
import b58 from 'bs58check'; import b58 from 'bs58check';
import { CoinSelectReturnInput, CoinSelectTarget } from 'coinselect'; import { CoinSelectReturnInput } from 'coinselect';
import { ECPairFactory } from 'ecpair'; import { ECPairFactory } from 'ecpair';
import { ECPairInterface } from 'ecpair/src/ecpair'; import { ECPairInterface } from 'ecpair/src/ecpair';
@ -15,7 +15,7 @@ import { ElectrumHistory } from '../../blue_modules/BlueElectrum';
import ecc from '../../blue_modules/noble_ecc'; import ecc from '../../blue_modules/noble_ecc';
import { randomBytes } from '../rng'; import { randomBytes } from '../rng';
import { AbstractHDWallet } from './abstract-hd-wallet'; import { AbstractHDWallet } from './abstract-hd-wallet';
import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types';
const ECPair = ECPairFactory(ecc); const ECPair = ECPairFactory(ecc);
const bip32 = BIP32Factory(ecc); const bip32 = BIP32Factory(ecc);
@ -52,10 +52,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
// BIP47 // BIP47
_enable_BIP47: boolean; _enable_BIP47: boolean;
_payment_code: string; _payment_code: string;
_sender_payment_codes: string[]; _sender_payment_codes: string[]; // who can pay us
_payer_payment_codes: string[]; // whom can we pay
_addresses_by_payment_code: Record<string, string[]>; _addresses_by_payment_code: Record<string, string[]>;
_next_free_payment_code_address_index: Record<string, number>; _next_free_payment_code_address_index: Record<string, number>;
_txs_by_payment_code_index: Record<string, Transaction[][]>; _txs_by_payment_code_index: Record<string, Transaction[][]>; // incoming tx e.g. someone paid us
_balances_by_payment_code_index: Record<string, BalanceByIndex>; _balances_by_payment_code_index: Record<string, BalanceByIndex>;
_bip47_instance?: BIP47Interface; _bip47_instance?: BIP47Interface;
@ -73,6 +74,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
this._enable_BIP47 = false; this._enable_BIP47 = false;
this._payment_code = ''; this._payment_code = '';
this._sender_payment_codes = []; this._sender_payment_codes = [];
this._payer_payment_codes = [];
this._next_free_payment_code_address_index = {}; this._next_free_payment_code_address_index = {};
this._txs_by_payment_code_index = {}; this._txs_by_payment_code_index = {};
this._balances_by_payment_code_index = {}; this._balances_by_payment_code_index = {};
@ -1108,7 +1110,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
*/ */
createTransaction( createTransaction(
utxos: CreateTransactionUtxo[], utxos: CreateTransactionUtxo[],
targets: CoinSelectTarget[], targets: CreateTransactionTarget[],
feeRate: number, feeRate: number,
changeAddress: string, changeAddress: string,
sequence: number, sequence: number,
@ -1128,13 +1130,18 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
} }
for (const t of targets) { for (const t of targets) {
if (t.address.startsWith('bc1')) { if (t.address && t.address.startsWith('bc1')) {
// in case address is non-typical and takes more bytes than coinselect library anticipates by default // in case address is non-typical and takes more bytes than coinselect library anticipates by default
t.script = { length: bitcoin.address.toOutputScript(t.address).length + 3 }; t.script = { length: bitcoin.address.toOutputScript(t.address).length + 3 };
} }
if (t.script?.hex) {
// setting length for coinselect lib manually as it is not aware of our field `hex`
t.script.length = t.script.hex.length / 2 - 4;
}
} }
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate);
sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence;
let psbt = new bitcoin.Psbt(); let psbt = new bitcoin.Psbt();
@ -1172,9 +1179,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
}); });
outputs.forEach(output => { outputs.forEach(output => {
// if output has no address - this is change output // if output has no address - this is change output or a custom script output
let change = false; let change = false;
if (!output.address) { // @ts-ignore
if (!output.address && !output.script?.hex) {
change = true; change = true;
output.address = changeAddress; output.address = changeAddress;
} }
@ -1197,6 +1205,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
psbt.addOutput({ psbt.addOutput({
address: output.address, address: output.address,
// @ts-ignore types from bitcoinjs are not exported so we cant define outputData separately and add fields conditionally (either address or script should be present)
script: output.script?.hex ? Buffer.from(output.script.hex, 'hex') : undefined,
value: output.value, value: output.value,
bip32Derivation: bip32Derivation:
change && path && pubkey change && path && pubkey

View File

@ -5,9 +5,9 @@ import { AbstractWallet } from './abstract-wallet';
import { HDSegwitBech32Wallet } from '..'; import { HDSegwitBech32Wallet } from '..';
import * as bitcoin from 'bitcoinjs-lib'; import * as bitcoin from 'bitcoinjs-lib';
import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import coinSelect, { CoinSelectOutput, CoinSelectReturnInput, CoinSelectTarget, CoinSelectUtxo } from 'coinselect'; import coinSelect, { CoinSelectOutput, CoinSelectReturnInput, CoinSelectTarget } from 'coinselect';
import coinSelectSplit from 'coinselect/split'; import coinSelectSplit from 'coinselect/split';
import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types';
import { ECPairAPI, ECPairFactory, Signer } from 'ecpair'; import { ECPairAPI, ECPairFactory, Signer } from 'ecpair';
import ecc from '../../blue_modules/noble_ecc'; import ecc from '../../blue_modules/noble_ecc';
@ -369,24 +369,21 @@ export class LegacyWallet extends AbstractWallet {
} }
coinselect( coinselect(
utxos: CoinSelectUtxo[], utxos: CreateTransactionUtxo[],
targets: CoinSelectTarget[], targets: CreateTransactionTarget[],
feeRate: number, feeRate: number,
changeAddress: string,
): { ): {
inputs: CoinSelectReturnInput[]; inputs: CoinSelectReturnInput[];
outputs: CoinSelectOutput[]; outputs: CoinSelectOutput[];
fee: number; fee: number;
} { } {
if (!changeAddress) throw new Error('No change address provided');
let algo = coinSelect; let algo = coinSelect;
// if targets has output without a value, we want send MAX to it // if targets has output without a value, we want send MAX to it
if (targets.some(i => !('value' in i))) { if (targets.some(i => !('value' in i))) {
algo = coinSelectSplit; algo = coinSelectSplit;
} }
const { inputs, outputs, fee } = algo(utxos, targets, feeRate); const { inputs, outputs, fee } = algo(utxos, targets as CoinSelectTarget[], feeRate);
// .inputs and .outputs will be undefined if no solution was found // .inputs and .outputs will be undefined if no solution was found
if (!inputs || !outputs) { if (!inputs || !outputs) {
@ -417,7 +414,7 @@ export class LegacyWallet extends AbstractWallet {
masterFingerprint: number, masterFingerprint: number,
): CreateTransactionResult { ): CreateTransactionResult {
if (targets.length === 0) throw new Error('No destination provided'); if (targets.length === 0) throw new Error('No destination provided');
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate);
sequence = sequence || 0xffffffff; // disable RBF by default sequence = sequence || 0xffffffff; // disable RBF by default
const psbt = new bitcoin.Psbt(); const psbt = new bitcoin.Psbt();
let c = 0; let c = 0;

View File

@ -957,7 +957,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet {
} }
} }
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate);
sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence;
let psbt = new bitcoin.Psbt(); let psbt = new bitcoin.Psbt();

View File

@ -84,7 +84,7 @@ export class SegwitBech32Wallet extends LegacyWallet {
for (const u of utxos) { for (const u of utxos) {
u.script = { length: 27 }; u.script = { length: 27 };
} }
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate);
sequence = sequence || 0xffffffff; // disable RBF by default sequence = sequence || 0xffffffff; // disable RBF by default
const psbt = new bitcoin.Psbt(); const psbt = new bitcoin.Psbt();
let c = 0; let c = 0;

View File

@ -102,7 +102,7 @@ export class SegwitP2SHWallet extends LegacyWallet {
for (const u of utxos) { for (const u of utxos) {
u.script = { length: 50 }; u.script = { length: 50 };
} }
const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate);
sequence = sequence || 0xffffffff; // disable RBF by default sequence = sequence || 0xffffffff; // disable RBF by default
const psbt = new bitcoin.Psbt(); const psbt = new bitcoin.Psbt();
let c = 0; let c = 0;

View File

@ -1,5 +1,5 @@
import bitcoin from 'bitcoinjs-lib'; import bitcoin from 'bitcoinjs-lib';
import { CoinSelectOutput, CoinSelectReturnInput } from 'coinselect'; import { CoinSelectOutput, CoinSelectReturnInput, CoinSelectUtxo } from 'coinselect';
import { HDAezeedWallet } from './hd-aezeed-wallet'; import { HDAezeedWallet } from './hd-aezeed-wallet';
import { HDLegacyBreadwalletWallet } from './hd-legacy-breadwallet-wallet'; import { HDLegacyBreadwalletWallet } from './hd-legacy-breadwallet-wallet';
import { HDLegacyElectrumSeedP2PKHWallet } from './hd-legacy-electrum-seed-p2pkh-wallet'; import { HDLegacyElectrumSeedP2PKHWallet } from './hd-legacy-electrum-seed-p2pkh-wallet';
@ -31,16 +31,19 @@ export type Utxo = {
}; };
/** /**
* basically the same as coinselect.d.ts/CoinselectUtxo * same as coinselect.d.ts/CoinSelectUtxo
* and should be unified as soon as bullshit with txid/txId is sorted
*/ */
export type CreateTransactionUtxo = { export interface CreateTransactionUtxo extends CoinSelectUtxo {}
txid: string;
txhex: string; /**
vout: number; * if address is missing and `script.hex` is set - this is a custom script (like OP_RETURN)
value: number; */
export type CreateTransactionTarget = {
address?: string;
value?: number;
script?: { script?: {
length: number; length?: number; // either length or hex should be present
hex?: string;
}; };
}; };

View File

@ -7,7 +7,8 @@ import BIP47Factory from '@spsina/bip47';
import assert from 'assert'; import assert from 'assert';
import * as bitcoin from 'bitcoinjs-lib'; import * as bitcoin from 'bitcoinjs-lib';
import ecc from 'tiny-secp256k1'; import ecc from '../../blue_modules/noble_ecc';
const ECPair = ECPairFactory(ecc); const ECPair = ECPairFactory(ecc);
jest.setTimeout(90 * 1000); jest.setTimeout(90 * 1000);
@ -135,5 +136,11 @@ describe('Bech32 Segwit HD (BIP84) with BIP47', () => {
?.scriptPubKey.hex, ?.scriptPubKey.hex,
'6a4c50' + blindedPaymentCode, '6a4c50' + blindedPaymentCode,
); );
assert.strictEqual(
w.getTransactions().find(tx => tx.txid === '06b4c14587182fd0474f265a77b156519b4778769a99c21623863a8194d0fa4f')?.outputs?.[1]
.scriptPubKey.addresses[0],
bobBip47.getNotificationAddress(),
); // transaction is to Bob's notification address
}); });
}); });

View File

@ -1,5 +1,5 @@
import BIP47Factory from '@spsina/bip47'; import BIP47Factory from '@spsina/bip47';
import ecc from 'tiny-secp256k1'; import ecc from '../../blue_modules/noble_ecc';
import assert from 'assert'; import assert from 'assert';
import { HDSegwitBech32Wallet, WatchOnlyWallet } from '../../class'; import { HDSegwitBech32Wallet, WatchOnlyWallet } from '../../class';
@ -23,6 +23,8 @@ describe('Bech32 Segwit HD (BIP84) with BIP47', () => {
expect(bobNotificationAddress).toEqual('1ChvUUvht2hUQufHBXF8NgLhW8SwE2ecGV'); // our notif address expect(bobNotificationAddress).toEqual('1ChvUUvht2hUQufHBXF8NgLhW8SwE2ecGV'); // our notif address
assert.strictEqual(bobWallet.getBIP47NotificationAddress(), '1ChvUUvht2hUQufHBXF8NgLhW8SwE2ecGV'); // our notif address
assert.ok(!bobWallet.weOwnAddress('1JDdmqFLhpzcUwPeinhJbUPw4Co3aWLyzW')); // alice notif address, we dont own it assert.ok(!bobWallet.weOwnAddress('1JDdmqFLhpzcUwPeinhJbUPw4Co3aWLyzW')); // alice notif address, we dont own it
}); });
@ -68,6 +70,8 @@ describe('Bech32 Segwit HD (BIP84) with BIP47', () => {
expect(ourNotificationAddress).toEqual('1EiP2kSqxNqRhn8MPMkrtSEqaWiCWLYyTS'); // our notif address expect(ourNotificationAddress).toEqual('1EiP2kSqxNqRhn8MPMkrtSEqaWiCWLYyTS'); // our notif address
assert.strictEqual(w.getBIP47NotificationAddress(), '1EiP2kSqxNqRhn8MPMkrtSEqaWiCWLYyTS'); // our notif address
// since we dont do network calls in unit test we cant get counterparties payment codes from our notif address, // since we dont do network calls in unit test we cant get counterparties payment codes from our notif address,
// and thus, dont know collaborative addresses with our payers. lets hardcode our counterparty payment code to test // and thus, dont know collaborative addresses with our payers. lets hardcode our counterparty payment code to test
// this functionality // this functionality

View File

@ -1,5 +1,6 @@
import assert from 'assert'; import assert from 'assert';
import { HDSegwitBech32Wallet } from '../../class'; import { HDSegwitBech32Wallet } from '../../class';
import * as bitcoin from 'bitcoinjs-lib';
describe('Bech32 Segwit HD (BIP84)', () => { describe('Bech32 Segwit HD (BIP84)', () => {
it('can create', async function () { it('can create', async function () {
@ -213,7 +214,7 @@ describe('Bech32 Segwit HD (BIP84)', () => {
assert.notStrictEqual(id1, id3); assert.notStrictEqual(id1, id3);
}); });
it('cat createTransaction with a correct feerate (with lenghty segwit address)', () => { it('can createTransaction with a correct feerate (with lenghty segwit address)', () => {
if (!process.env.HD_MNEMONIC_BIP84) { if (!process.env.HD_MNEMONIC_BIP84) {
console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped'); console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped');
return; return;
@ -232,18 +233,71 @@ describe('Bech32 Segwit HD (BIP84)', () => {
}, },
]; ];
const { tx, psbt } = hd.createTransaction( const { tx, psbt, outputs } = hd.createTransaction(
utxo, utxo,
[{ address: 'bc1qtmcfj7lvgjp866w8lytdpap82u7eege58jy52hp4ctk0hsncegyqel8prp' }], // sendMAX [{ address: 'bc1qtmcfj7lvgjp866w8lytdpap82u7eege58jy52hp4ctk0hsncegyqel8prp', value: 546 }],
1, 10,
'bc1qtmcfj7lvgjp866w8lytdpap82u7eege58jy52hp4ctk0hsncegyqel8prp', // change wont actually be used 'bc1qtmcfj7lvgjp866w8lytdpap82u7eege58jy52hp4ctk0hsncegyqel8prp',
); );
assert.strictEqual(outputs.length, 2);
const actualFeerate = psbt.getFee() / tx.virtualSize(); const actualFeerate = psbt.getFee() / tx.virtualSize();
assert.strictEqual( assert.strictEqual(
actualFeerate >= 1.0, Math.round(actualFeerate) >= 10 && actualFeerate <= 11,
true, true,
`bad feerate, got ${actualFeerate}, expected at least 1; fee: ${psbt.getFee()}; virsualSize: ${tx.virtualSize()} vbytes; ${tx.toHex()}`, `bad feerate, got ${actualFeerate}, expected at least 10; fee: ${psbt.getFee()}; virsualSize: ${tx.virtualSize()} vbytes; ${tx.toHex()}`,
);
});
it('can createTransaction with OP_RETURN', () => {
if (!process.env.HD_MNEMONIC_BIP84) {
console.error('process.env.HD_MNEMONIC_BIP84 not set, skipped');
return;
}
const hd = new HDSegwitBech32Wallet();
hd.setSecret(process.env.HD_MNEMONIC_BIP84);
assert.ok(hd.validateMnemonic());
const utxo = [
{
address: 'bc1q063ctu6jhe5k4v8ka99qac8rcm2tzjjnuktyrl',
vout: 0,
txid: '8b0ab2c7196312e021e0d3dc73f801693826428782970763df6134457bd2ec20',
value: 69909,
wif: '-',
},
];
const { tx, psbt, outputs } = hd.createTransaction(
utxo,
[
{ address: hd._getExternalAddressByIndex(0), value: 546 },
{ script: { hex: '00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff' }, value: 0 },
],
150,
hd._getInternalAddressByIndex(0),
);
assert.strictEqual(outputs.length, 3); // destination, op_return, change
assert.ok(!outputs[1].address); // should not be there as it should be OP_RETURN
const decodedTx = bitcoin.Transaction.fromHex(tx.toHex());
// console.log(decodedTx.outs);
assert.strictEqual(decodedTx.outs[0].value, 546); // first output - destination
assert.strictEqual(decodedTx.outs[1].value, 0); // second output - op_return
assert.ok(decodedTx.outs[2].value > 0); // third output - change
assert.strictEqual(decodedTx.outs[1].script.toString('hex'), '00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff'); // custom script that we are passing
// console.log(outputs);
const actualFeerate = psbt.getFee() / tx.virtualSize();
assert.strictEqual(
Math.round(actualFeerate) >= 150 && actualFeerate < 151,
true,
`bad feerate, got ${actualFeerate}, expected at least 11; fee: ${psbt.getFee()}; virsualSize: ${tx.virtualSize()} vbytes; ${tx.toHex()}`,
); );
}); });

View File

@ -7,6 +7,10 @@ declare module 'coinselect' {
}; };
}; };
/**
* not an accurate definition since coinselect lib can ignore certain fields, and just passes through unknown fields,
* which we actually rely on
*/
export type CoinSelectUtxo = { export type CoinSelectUtxo = {
vout: number; vout: number;
value: number; value: number;