Skip to content

Commit

Permalink
feat(abstract-utxo): add descriptor support for offline vault
Browse files Browse the repository at this point in the history
TICKET: BTC-1731
  • Loading branch information
OttoAllmendinger committed Jan 16, 2025
1 parent ad33065 commit 7082893
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 0 deletions.
13 changes: 13 additions & 0 deletions modules/abstract-utxo/src/names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ export function getChainFromNetwork(n: utxolib.Network): string {
}
}

/**
* @param coinName - the name of the coin (e.g. 'btc', 'bch', 'ltc'). Also called 'chain' in some contexts.
* @returns the network for a coin. This is the mainnet network for the coin.
*/
export function getNetworkFromChain(coinName: string): utxolib.Network {
for (const network of utxolib.getNetworkList()) {
if (getChainFromNetwork(network) === coinName) {
return network;
}
}
throw new Error(`Unknown chain ${coinName}`);
}

export function getFullNameFromNetwork(n: utxolib.Network): string {
const name = getNetworkName(n);

Expand Down
29 changes: 29 additions & 0 deletions modules/abstract-utxo/src/offlineVault/OfflineVaultHalfSigned.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as utxolib from '@bitgo/utxo-lib';
import { BIP32Interface } from '@bitgo/utxo-lib';

import { getNetworkFromChain } from '../names';

import { OfflineVaultUnsigned } from './OfflineVaultUnsigned';
import { DescriptorTransaction, getHalfSignedPsbt } from './descriptor';

export type OfflineVaultHalfSigned = {
halfSigned: { txHex: string };
};

function createHalfSignedFromPsbt(psbt: utxolib.Psbt): OfflineVaultHalfSigned {
return { halfSigned: { txHex: psbt.toHex() } };
}

export function createHalfSigned(coin: string, prv: string | BIP32Interface, tx: unknown): OfflineVaultHalfSigned {
const network = getNetworkFromChain(coin);
if (typeof prv === 'string') {
prv = utxolib.bip32.fromBase58(prv);
}
if (!OfflineVaultUnsigned.is(tx)) {
throw new Error('unsupported transaction type');
}
if (DescriptorTransaction.is(tx)) {
return createHalfSignedFromPsbt(getHalfSignedPsbt(tx, prv, network));
}
throw new Error('unsupported transaction type');
}
35 changes: 35 additions & 0 deletions modules/abstract-utxo/src/offlineVault/OfflineVaultUnsigned.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as utxolib from '@bitgo/utxo-lib';
import { Triple } from '@bitgo/sdk-core';
import * as t from 'io-ts';

export const XPubWithDerivationPath = t.intersection(
[t.type({ xpub: t.string }), t.partial({ derivationPath: t.string })],
'XPubWithDerivationPath'
);

export type XPubWithDerivationPath = t.TypeOf<typeof XPubWithDerivationPath>;

/**
* This is the transaction payload that is sent to the offline vault to sign.
*/
export const OfflineVaultUnsigned = t.type(
{
xpubsWithDerivationPath: t.type({
user: XPubWithDerivationPath,
backup: XPubWithDerivationPath,
bitgo: XPubWithDerivationPath,
}),
coinSpecific: t.type({ txHex: t.string }),
},
'BaseTransaction'
);

export type OfflineVaultUnsigned = t.TypeOf<typeof OfflineVaultUnsigned>;

type WithXpub = { xpub: string };
type NamedKeys = { user: WithXpub; backup: WithXpub; bitgo: WithXpub };
export function toKeyTriple(xpubs: NamedKeys): Triple<utxolib.BIP32Interface> {
return [xpubs.user.xpub, xpubs.backup.xpub, xpubs.bitgo.xpub].map((xpub) =>
utxolib.bip32.fromBase58(xpub)
) as Triple<utxolib.BIP32Interface>;
}
1 change: 1 addition & 0 deletions modules/abstract-utxo/src/offlineVault/descriptor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './transaction';
43 changes: 43 additions & 0 deletions modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as utxolib from '@bitgo/utxo-lib';
import * as t from 'io-ts';

import { NamedDescriptor } from '../../descriptor';
import { OfflineVaultUnsigned, toKeyTriple } from '../OfflineVaultUnsigned';
import {
getValidatorOneOfTemplates,
getValidatorSignedByUserKey,
getValidatorSome,
toDescriptorMapValidate,
} from '../../descriptor/validatePolicy';
import { DescriptorMap } from '../../core/descriptor';
import { signPsbt } from '../../transaction/descriptor';

export const DescriptorTransaction = t.intersection(
[OfflineVaultUnsigned, t.type({ descriptors: t.array(NamedDescriptor) })],
'DescriptorTransaction'
);

export type DescriptorTransaction = t.TypeOf<typeof DescriptorTransaction>;

export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransaction): DescriptorMap {
const { descriptors, xpubsWithDerivationPath } = tx;
const pubkeys = toKeyTriple(xpubsWithDerivationPath);
const policy = getValidatorSome([
// allow all 2-of-3-ish descriptors where the keys match the wallet keys
getValidatorOneOfTemplates(['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop']),
// allow all descriptors signed by the user key
getValidatorSignedByUserKey(),
]);
return toDescriptorMapValidate(descriptors, pubkeys, policy);
}

export function getHalfSignedPsbt(
tx: DescriptorTransaction,
prv: utxolib.BIP32Interface,
network: utxolib.Network
): utxolib.Psbt {
const psbt = utxolib.bitgo.createPsbtDecode(tx.coinSpecific.txHex, network);
const descriptorMap = getDescriptorsFromDescriptorTransaction(tx);
signPsbt(psbt, descriptorMap, prv, { onUnknownInput: 'throw' });
return psbt;
}
2 changes: 2 additions & 0 deletions modules/abstract-utxo/src/offlineVault/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as descriptor from './descriptor';
export * from './OfflineVaultHalfSigned';
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"walletKeys": [
"xprv9zXudaVgkhXsjTeXgpJ3K62R6bZDYQ5L5TVYfhGPLDDLNhfLtCYwHm4R4aMnMMeRpHSiM5Krxxbrux7iz99f7rSmZyddtBogiSch4PRVVXZ",
"xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b",
"xprv9s21ZrQH143K3Kh6W9VDkrpUSDrikEYKbbEKyB5Xn9bJeBPRSRSyWqQ5Fzoujj4eFmRKrxFPipYtfVqyu3aNYH4Lojrdhemi4aUdX8CjD8W"
],
"response": {
"txBase64": "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",
"descriptors": [
{
"name": "external",
"value": "wsh(multi(2,xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV/0/*,xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b/0/*,xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd/0/*))#gw7wwcku",
"signatures": [],
"lastIndex": 1
},
{
"name": "internal",
"value": "wsh(multi(2,xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV/1/*,xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b/1/*,xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd/1/*))#sw3k4yqr",
"signatures": [],
"lastIndex": 0
}
],
"formatVersion": 1,
"coin": "btc",
"pubs": [
"xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV",
"xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b",
"xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd"
],
"xpubsWithDerivationPath": {
"user": {
"xpub": "xpub6DXG362ab56Awwiznqq3gDy9edPhwroBSgR9U5fztYkKFVzVRjsBqZNtusyuT3U841NogK9ByRFHz6Zhb5jTGWkiJyu3SVBqKiPFzw2GcyV",
"derivedFromParentWithSeed": "143700591154482"
},
"backup": {
"xpub": "xpub6CYwP1gQmPLwzD67x2nBoxiKquYs2CQExaqyQ3KbcntynZCoUGKErxXNg6Mft3x7r5c1xtHTp827ZRQY7dwUa8XYjQ5RCPUwtTPAe6bSv8b",
"derivedFromParentWithSeed": "210593312354420"
},
"bitgo": {
"xpub": "xpub661MyMwAqRbcFomZcB2E7zmCzFhD9hGAxp9vmZV9LV8HWyiZyxmE4diZ7EREPnU5XdHjx1w9Xp2o7qtP3mGmWM6SWYD1zGcA2VuU6VEjomd"
}
},
"walletId": "6788d3cf8eaf7aa1db27d4575218103c",
"walletLabel": "UtxoWalletClient",
"amount": "199995579",
"address": "bc1q9cp8uk0qpal0h4hzrp2gvjy5hmwune773pnp2qtfwap22ryrnuxsehzwgh",
"pendingApprovalId": "6788d3cf8eaf7aa1db27d4f563e0e411",
"creatorId": "6788d3ce8eaf7aa1db27d18bc5baa93d",
"creatorEmail": "testuser-1737020360683@example.com",
"createDate": "2025-01-16T09:39:27.667Z",
"enterpriseId": "6788d3ce8eaf7aa1db27d2a664daec54",
"enterpriseName": "Foo Enterprises",
"enterpriseFeatureFlags": [
"enableMMI"
],
"videoId": {
"approver": "",
"date": "",
"link": "",
"exception": "",
"waived": true
},
"coinSpecific": {
"txHex": "70736274ff0100dd020000000201010101010101010101010101010101010101010101010101010101010101010000000000fdffffff01010101010101010101010101010101010101010101010101010101010101010100000000fdffffff0340420f00000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0d80841e00000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0dfbe9bd0b0000000022002072cba07a4f95a936089851622eea3b63232de777a4a627b72a3c4958f06f33a1000000000001012b00e1f505000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0d010569522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2206023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220602d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220602f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e800000000000000000001012b00e1f505000000002200202e027e59e00f7efbd6e21854864894beddc9e7de88661501697742a50c839f0d010569522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2206023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220602d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220602f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e8000000000000000000010169522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2202023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220202d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220202f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e8000000000000000000010169522102f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e2102d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df21023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b53ae2202023bdb693c4bf735870b5cbe279bf3e86b11dfbbdf2fd542945bbcef8d96dd177b0c59e62a710000000000000000220202d212e44dac0b4586cab689c566963ecc994d300508939180cbb707e13a8c32df0c1508470b0000000000000000220202f70923b37875101b1d49b0849befef4b7bb9f90f3f571f7a3187f98eb45a061e0c2a1493e800000000000000000001016952210344a042f622eb71a19f80ef8bfb864f2068ed2d0643d165ba6ce674134d9cb9462103fd3ced6d7adb7b6efb9e07a8e60425f73fb882a6a2eae41bf303aa110c0e85712102b4b094b52276da9686a9a037964d9b637bba5c2b3f2f7b8521fba94851029a1653ae220202b4b094b52276da9686a9a037964d9b637bba5c2b3f2f7b8521fba94851029a160c59e62a71000000000100000022020344a042f622eb71a19f80ef8bfb864f2068ed2d0643d165ba6ce674134d9cb9460c2a1493e80000000001000000220203fd3ced6d7adb7b6efb9e07a8e60425f73fb882a6a2eae41bf303aa110c0e85710c1508470b000000000100000000",
"inputIds": [
"0101010101010101010101010101010101010101010101010101010101010101:0",
"0101010101010101010101010101010101010101010101010101010101010101:1"
]
},
"recipientsInfo": [],
"keyDerivationPath": "143700591154482"
}
}
80 changes: 80 additions & 0 deletions modules/abstract-utxo/test/offlineVault/halfSigned.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as fs from 'fs';
import assert from 'assert';
import crypto from 'crypto';

import * as t from 'io-ts';
import * as utxolib from '@bitgo/utxo-lib';

import { createHalfSigned } from '../../src/offlineVault';
import { DescriptorTransaction } from '../../src/offlineVault/descriptor';

function getFixturesNames(): string[] {
// I'm using sync here because mocha cannot do async setup
// eslint-disable-next-line no-sync
return fs.readdirSync(__dirname + '/fixtures').filter((f) => f.endsWith('.json'));
}

const Fixture = t.type({
walletKeys: t.array(t.string),
response: t.unknown,
});

type Fixture = t.TypeOf<typeof Fixture>;

async function readFixture(name: string): Promise<Fixture> {
const data = JSON.parse(await fs.promises.readFile(__dirname + '/fixtures/' + name, 'utf-8'));
if (!Fixture.is(data)) {
throw new Error(`Invalid fixture ${name}`);
}
return data;
}

function withRotatedXpubs(tx: DescriptorTransaction): DescriptorTransaction {
const { user, backup, bitgo } = tx.xpubsWithDerivationPath;
return {
...tx,
xpubsWithDerivationPath: {
user: bitgo,
backup: user,
bitgo: backup,
},
};
}

function withRandomXpubs(tx: DescriptorTransaction) {
function randomXpub() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return utxolib.bip32.fromSeed(Buffer.from(bytes)).neutered().toBase58();
}
return {
...tx,
xpubsWithDerivationPath: {
user: randomXpub(),
backup: randomXpub(),
bitgo: randomXpub(),
},
};
}

function withoutDescriptors(tx: DescriptorTransaction): DescriptorTransaction {
return {
...tx,
descriptors: [],
};
}

describe('OfflineVaultHalfSigned', function () {
for (const fixtureName of getFixturesNames()) {
it(`can sign fixture ${fixtureName}`, async function () {
const { walletKeys, response } = await readFixture(fixtureName);
const prv = utxolib.bip32.fromBase58(walletKeys[0]);
createHalfSigned('btc', prv, response);

assert(DescriptorTransaction.is(response));
const mutations = [withRotatedXpubs(response), withRandomXpubs(response), withoutDescriptors(response)];
for (const mutation of mutations) {
assert.throws(() => createHalfSigned('btc', prv, mutation));
}
});
}
});

0 comments on commit 7082893

Please sign in to comment.