Skip to content
This repository has been archived by the owner on Jun 16, 2022. It is now read-only.

Commit

Permalink
feat(btc): create multi-signature transfers (#247)
Browse files Browse the repository at this point in the history
  • Loading branch information
marianogoldman committed Oct 14, 2021
1 parent a8a2c5f commit 4aeeb7b
Show file tree
Hide file tree
Showing 9 changed files with 1,313 additions and 920 deletions.
1,752 changes: 878 additions & 874 deletions common/config/rush/browser-approved-packages.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion common/config/rush/repo-state.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE. It is generated and used by Rush.
{
"pnpmShrinkwrapHash": "b874751da0b7c01df1555177dcf256dafab612ef",
"pnpmShrinkwrapHash": "d8fcb1c7ea3bd5e81111870371204f88b3fb8991",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
}
3 changes: 2 additions & 1 deletion packages/sdk-btc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"bitcoinjs-message": "~2.2.0",
"coinselect": "~3.1.12",
"@ledgerhq/hw-transport-node-hid-singleton": "^6.3.0",
"create-xpub": "~2.1.0"
"create-xpub": "~2.1.0",
"xpub-converter": "~1.0.2"
},
"devDependencies": {
"@jest/globals": "^27.0.6",
Expand Down
24 changes: 13 additions & 11 deletions packages/sdk-btc/source/address.domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,35 @@ export const defaultP2SHSegwitMusigAccountKey = (rootKey: bitcoin.BIP32Interface
export const defaultNativeSegwitMusigAccountKey = (rootKey: bitcoin.BIP32Interface): bitcoin.BIP32Interface =>
rootKeyToAccountKey(rootKey, "48'/1'/0'/2'");

const sort = (a: Buffer, b: Buffer) => Buffer.compare(a, b);

const createMusigPayment = (m: number, pubkeys: Buffer[], network: bitcoin.Network) =>
const createMusigPayment = (minSignatures: number, pubkeys: Buffer[], network: bitcoin.Network) =>
bitcoin.payments.p2ms({
m,
pubkeys: pubkeys.sort(sort),
m: minSignatures,
pubkeys: pubkeys.sort(Buffer.compare),
network,
});

export const legacyMusig = (m: number, pubkeys: Buffer[], network: bitcoin.Network): bitcoin.Payment =>
export const legacyMusig = (minSignatures: number, pubkeys: Buffer[], network: bitcoin.Network): bitcoin.Payment =>
bitcoin.payments.p2sh({
redeem: createMusigPayment(m, pubkeys, network),
redeem: createMusigPayment(minSignatures, pubkeys, network),
network,
});

export const p2SHSegwitMusig = (m: number, pubkeys: Buffer[], network: bitcoin.Network): bitcoin.Payment =>
export const p2SHSegwitMusig = (minSignatures: number, pubkeys: Buffer[], network: bitcoin.Network): bitcoin.Payment =>
bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wsh({
redeem: createMusigPayment(m, pubkeys, network),
redeem: createMusigPayment(minSignatures, pubkeys, network),
network,
}),
network,
});

export const nativeSegwitMusig = (m: number, pubkeys: Buffer[], network: bitcoin.Network): bitcoin.Payment =>
export const nativeSegwitMusig = (
minSignatures: number,
pubkeys: Buffer[],
network: bitcoin.Network,
): bitcoin.Payment =>
bitcoin.payments.p2wsh({
redeem: createMusigPayment(m, pubkeys, network),
redeem: createMusigPayment(minSignatures, pubkeys, network),
network,
});

Expand Down
43 changes: 41 additions & 2 deletions packages/sdk-btc/source/musig-wallet-data-helper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Coins, Exceptions, Http } from "@payvo/sdk";
import { walletUsedAddresses } from "./helpers";
import { post, walletUsedAddresses } from "./helpers";
import * as bitcoin from "bitcoinjs-lib";
import { Bip44Address, MusigDerivationMethod } from "./contracts";
import { Bip44Address, MusigDerivationMethod, UnspentTransaction } from "./contracts";
import { legacyMusig, nativeSegwitMusig, p2SHSegwitMusig } from "./address.domain";

const getDerivationFunction = (
Expand Down Expand Up @@ -88,6 +88,37 @@ export default class MusigWalletDataHelper {
.filter((address) => address.status === "used");
}

public async unspentTransactionOutputs(): Promise<UnspentTransaction[]> {
const addresses = this.allUsedAddresses().map((address) => address.address);

const utxos = await this.#unspentTransactionOutputs(addresses);
// @ts-ignore
return utxos.map((utxo) => {
const address: Bip44Address = this.#signingKeysForAddress(utxo.address);
return {
address: utxo.address,
txId: utxo.txId,
txRaw: utxo.raw,
script: utxo.script,
vout: utxo.outputIndex,
value: utxo.satoshis,
path: address.path,
witnessUtxo: {
script: Buffer.from(utxo.script, "hex"),
value: utxo.satoshis,
},
};
});
}

#signingKeysForAddress(address: string): Bip44Address {
const found = this.allUsedAddresses().find((a) => a.address === address);
if (!found) {
throw new Exceptions.Exception(`Address ${address} not found.`);
}
return found;
}

async #usedAddresses(
addressesGenerator: Generator<Bip44Address[]>,
discoveredAddresses: Bip44Address[],
Expand Down Expand Up @@ -138,4 +169,12 @@ export default class MusigWalletDataHelper {
yield chunk;
}
};

async #unspentTransactionOutputs(addresses: string[]): Promise<UnspentTransaction[]> {
if (addresses.length === 0) {
return [];
}
return (await post(`wallets/transactions/unspent`, { addresses }, this.#httpClient, this.#configRepository))
.data;
}
}
217 changes: 214 additions & 3 deletions packages/sdk-btc/source/transaction.service.test.ts

Large diffs are not rendered by default.

157 changes: 132 additions & 25 deletions packages/sdk-btc/source/transaction.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { BIP32, BIP44 } from "@payvo/cryptography";
import { BIP32 } from "@payvo/cryptography";
import { Contracts, Exceptions, IoC, Services, Signatories } from "@payvo/sdk";
import * as bitcoin from "bitcoinjs-lib";
import { BIP32Interface } from "bitcoinjs-lib";
import coinSelect from "coinselect";
import changeVersionBytes from "xpub-converter";

import { getNetworkConfig } from "./config";
import { BindingType } from "./constants";
import { AddressFactory } from "./address.factory";
import { Bip44Address, BipLevel, Levels, UnspentTransaction } from "./contracts";
import { post } from "./helpers";
import { BipLevel, Levels, MusigDerivationMethod, UnspentTransaction } from "./contracts";
import { LedgerService } from "./ledger.service";
import { jest } from "@jest/globals";

jest.setTimeout(20_000);

const runWithLedgerConnectionIfNeeded = async (
signatory: Signatories.Signatory,
Expand Down Expand Up @@ -47,15 +50,23 @@ export class TransactionService extends Services.AbstractTransactionService {
private readonly transport!: Services.LedgerTransport;

public override async transfer(input: Services.TransferInput): Promise<Contracts.SignedTransactionData> {
if (input.signatory.signingKey() === undefined) {
if (!input.signatory.actsWithMultiSignature() && input.signatory.signingKey() === undefined) {
throw new Exceptions.MissingArgument(this.constructor.name, this.transfer.name, "input.signatory");
}

if (!input.signatory.actsWithMnemonic() && !input.signatory.actsWithLedger()) {
if (
!input.signatory.actsWithMnemonic() &&
!input.signatory.actsWithLedger() &&
!input.signatory.actsWithMultiSignature()
) {
// @TODO Add more options (wif, ledger, extended private key, etc.).
throw new Exceptions.Exception("Need to provide a signatory that can be used for signing transactions.");
}

if (input.signatory.actsWithMultiSignature()) {
return this.#transferMusig(input);
}

const identityOptions = input.signatory.options();
if (identityOptions === undefined) {
throw new Exceptions.Exception(
Expand Down Expand Up @@ -231,26 +242,122 @@ export class TransactionService extends Services.AbstractTransactionService {

throw new Exceptions.Exception(`Invalid level specified: ${levels.purpose}`);
}
async #transferMusig(input: Services.TransferInput): Promise<Contracts.SignedTransactionData> {
const network = getNetworkConfig(this.configRepository);

const multiSignatureAsset: Services.MultiSignatureAsset = input.signatory.asset();

// https://github.com/satoshilabs/slips/blob/master/slip-0132.md#registered-hd-version-bytes
const { accountPublicKeys, method } = this.#keysAndMethod(multiSignatureAsset, network);

// create a musig wallet data helper and find all used addresses
const walledDataHelper = this.addressFactory.musigWalletDataHelper(
multiSignatureAsset.min,
accountPublicKeys.map((extendedPublicKey) => BIP32.fromBase58(extendedPublicKey, network)),
method,
);
await walledDataHelper.discoverAllUsed();

// Derive the sender address (corresponding to first address index for the wallet)
const { address } = walledDataHelper.discoveredSpendAddresses()[0];

// Find first unused the change address
const changeAddress = walledDataHelper.firstUnusedChangeAddress();

// Compute the amount to be transferred
const amount = this.toSatoshi(input.data.amount).toNumber();
const targets = [
{
address: input.data.to,
value: amount,
},
];

// Figure out inputs, outputs and fees
const feeRate = await this.#getFeeRateFromNetwork(input);
const utxos = await walledDataHelper.unspentTransactionOutputs();
const { inputs, outputs, fee } = await this.#selectUtxos(utxos, targets, feeRate);

// Set change address (if any output back to the wallet)
outputs.forEach((output) => {
if (!output.address) {
output.address = changeAddress.address;
}
});

private async unspentTransactionOutputs(bip44Addresses: Bip44Address[]): Promise<UnspentTransaction[]> {
const addresses = bip44Addresses.map((address) => address.address);

const utxos = (
await post(`wallets/transactions/unspent`, { addresses }, this.httpClient, this.configRepository)
).data;

const rawTxs = (
await post(
`wallets/transactions/raw`,
{ transaction_ids: utxos.map((utxo) => utxo.txId) },
this.httpClient,
this.configRepository,
)
).data;

return utxos.map((utxo) => ({
...utxo,
raw: rawTxs[utxo.txId],
}));
const psbt = new bitcoin.Psbt({ network });
inputs.forEach((input) =>
psbt.addInput({
hash: input.txId,
index: input.vout,
...input,
}),
);
outputs.forEach((output) =>
psbt.addOutput({
address: output.address,
value: output.value,
}),
);

const psbtBaseText = psbt.toBase64();
console.log("base64", psbtBaseText);

// @ts-ignore
const tx: bitcoin.Transaction = psbt.__CACHE.__TX;

return this.dataTransferObjectService.signedTransaction(
tx.getId(),
{
sender: address,
recipient: input.data.to,
amount,
fee,
timestamp: new Date(),
},
tx.toHex(),
// psbtBaseText, // TODO where do we return the psbt to be co-signed
);
}

#mainnetPrefixes = { xpub: "legacyMusig", Ypub: "p2SHSegwitMusig", Zpub: "nativeSegwitMusig" };
#testnetPrefixes = { tpub: "legacyMusig", Upub: "p2SHSegwitMusig", Vpub: "nativeSegwitMusig" };

#keysAndMethod(
multiSignatureAsset: Services.MultiSignatureAsset,
network: bitcoin.networks.Network,
): { accountPublicKeys: string[]; method: MusigDerivationMethod } {
const prefixes = multiSignatureAsset.publicKeys.map((publicKey) => publicKey.slice(0, 4));

if (new Set(prefixes).size > 1) {
throw new Exceptions.Exception(`Cannot mix extended public key prefixes.`);
}

let method: MusigDerivationMethod;

if (network === bitcoin.networks.bitcoin) {
if (prefixes.some((prefix) => !this.#mainnetPrefixes[prefix])) {
throw new Exceptions.Exception(
`Extended public key must start with any of ${Object.keys(this.#mainnetPrefixes)}.`,
);
}
method = this.#mainnetPrefixes[prefixes[0]];
} else if (network === bitcoin.networks.testnet) {
if (prefixes.some((prefix) => !this.#testnetPrefixes[prefix])) {
throw new Exceptions.Exception(
`Extended public key must start with any of ${Object.keys(this.#testnetPrefixes)}.`,
);
}
method = this.#testnetPrefixes[prefixes[0]];
} else {
throw new Exceptions.Exception(`Invalid network.`);
}
const accountPublicKeys = multiSignatureAsset.publicKeys.map((publicKey) =>
changeVersionBytes(publicKey, network === bitcoin.networks.bitcoin ? "xpub" : "tpub"),
);
return {
accountPublicKeys,
method,
};
}
}
27 changes: 24 additions & 3 deletions packages/sdk-btc/test/fixtures/musig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,38 @@ export const musig = {
accounts: [
{
mnemonic: "hard produce blood mosquito provide feed open enough access motor chimney swamp",
masterPublicKey:
legacyMasterPath: "m/45'/0",
legacyMasterPublicKey:
"tpubDAAEEpXA719cjhRw18JBeWk8dgMYphpc9vJgeosdtEL9vxrHkumoLijuRVPtLF6qByEG9B3rLgsfofQ1Fben4DgR6FBSLShrZwfdERCfE8L",
p2shSegwitMasterPath: "m/48'/1'/0'/1'",
p2shSegwitMasterPublicKey:
"Upub5T4iARd31HKU9kp1bZPe6amDxNWyb79scrVhhaFf5CEVrUo63aHGgkgR6TPhhNpqWqaTHvbwbEyUJNAHXomgNa7Ht5RUEQ9BpJNiQxoX7hr",
nativeSegwitMasterPath: "m/48'/1'/0'/2'",
nativeSegwitMasterPublicKey:
"Vpub5mtyU6Hx9xrx63Y3W4aGW1LuQkmwrq9xsQNgX7tDAM8DTHhE7vXMZ7Hue2FR8SMAGDW57fy76HFmN1jnckSmeX2cDMWVA1KViot6bLgJZuN",
},
{
mnemonic: "build tuition fuel distance often swallow birth embark nest barely drink beach",
masterPublicKey:
legacyMasterPath: "m/45'/0",
legacyMasterPublicKey:
"tpubDAo8kUYJD3WQa61Dz7neMDiwLwk433izoifv2ZcTks4t1XYxYdaYRCU6nkbNba3yRa12yLbx3EtY8cCQusGES91LbHoS1MJYVNfuAMPqGWL",
p2shSegwitMasterPath: "m/48'/1'/0'/1'",
p2shSegwitMasterPublicKey:
"Upub5SiRggvDtygQJBBCgFYGSmu1GG8qdu6P5Pzgu7x25ZvVY1DE4szxqzfntpxALjavwh5DiT6uQjNsra4Q5ggCWKMV9FnJ4rzGkcHMWttEJhT",
nativeSegwitMasterPath: "m/48'/1'/0'/2'",
nativeSegwitMasterPublicKey:
"Vpub5mYgzMb93fDtChZ2xmY7g3aEgHFjdgQE6P596AiL5zENEcVjDCciGfWmhZJngn6gVmBRh6E1Vp7aZYY7wQkMRTQSKhauGwYAUEdiGbS35D1",
},
{
mnemonic: "mandate pull cat east limit enemy cabin possible success force mountain hood",
masterPublicKey:
legacyMasterPath: "m/45'/0",
legacyMasterPublicKey:
"tpubDBb7ADvzzJ9Wi5R7fGN9iWZG9sp1zqbjFfemC1HNcyWitsuAu8suWUXrLYEFb8fSKW9NTMwDNAeo15HY8m7oi2NMo17xhmNEiAicBNz2SwB",
p2shSegwitMasterPath: "m/48'/1'/0'/1'",
p2shSegwitMasterPublicKey:
"Upub5ScB2WiLZN38iLRDwwpvdhJQBCk8vcaoXytsAiDq7C6BNhE449SdKMRou7Jn54mX9EDNUrTV2Z1jEqhgWeEgfHJ5wmajwgoJ1ap2pR9z9PG",
nativeSegwitMasterPath: "m/48'/1'/0'/2'",
nativeSegwitMasterPublicKey:
"Vpub5mSSLBPFi3acdjk5giwrmA7gXPAJsiLXXKibgjXYycH1gp95t2Pqv3U8dT9kEGxvAdfiN5DGmozDmZ7sJyDuMgfxt4h4KujF7MWt5tQH8py",
},
],
Expand Down

0 comments on commit 4aeeb7b

Please sign in to comment.