Skip to content

Commit

Permalink
refactor: bitcoin sign transaction (#52)
Browse files Browse the repository at this point in the history
Co-authored-by: Suraj Tiwari <surajtiwari020@gmail.com>
Co-authored-by: Ujjwal Kumar <31813384+Ujjwal0501@users.noreply.github.com>
Co-authored-by: Md Irshad Ansari <irshadjsr21@gmail.com>
  • Loading branch information
4 people authored Aug 9, 2023
1 parent 7f1403f commit 4375dba
Show file tree
Hide file tree
Showing 18 changed files with 486 additions and 405 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-glasses-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cypherock/sdk-app-btc': patch
---

sign txn proto, test cases and flow update
2 changes: 2 additions & 0 deletions packages/app-btc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"@cypherock/sdk-core": "workspace:^0.0.16",
"@cypherock/sdk-interfaces": "workspace:^0.0.11",
"@cypherock/sdk-utils": "workspace:^0.0.13",
"axios": "^1.3.4",
"bip66": "^1.1.5",
"long": "^5.2.1",
"protobufjs": "^7.2.2"
},
Expand Down
17 changes: 5 additions & 12 deletions packages/app-btc/src/operations/signTxn/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,17 @@ export const assertSignTxnParams = (params: ISignTxnParams) => {

assert(input.value, `txn.inputs[${i}].value should be defined`);
assert(input.address, `txn.inputs[${i}].address should be define`);
assert(input.chainIndex, `txn.inputs[${i}].chainIndex should be define`);
assert(input.changeIndex, `txn.inputs[${i}].changeIndex should be define`);
assert(
input.addressIndex,
`txn.inputs[${i}].addressIndex should be define`,
);
assert(input.prevIndex, `txn.inputs[${i}].addressIndex should be define`);

assert(input.prevTxnId, `txn.inputs[${i}].prevTxnId should not be empty`);
assert(
input.prevTxnHash,
`txn.inputs[${i}].prevTxnHash should not be empty`,
);
assert(
isHex(input.prevTxnHash),
`txn.inputs[${i}].prevTxnHash should be valid hex string`,
isHex(input.prevTxnId),
`txn.inputs[${i}].prevTxnId should be valid hex string`,
);

if (input.prevTxn) {
Expand All @@ -55,13 +52,9 @@ export const assertSignTxnParams = (params: ISignTxnParams) => {
assert(output.address, `txn.outputs[${i}].address should be define`);

if (output.isChange) {
assert(
output.chainIndex,
`txn.outputs[${i}].chainIndex should be define when it's a change output`,
);
assert(
output.addressIndex,
`txn.outputs[${i}].addressIndex should be define when it's a change output`,
`txn.outputs[${i}].addressIndex should be define when it's a change output`,
);
}
}
Expand Down
74 changes: 41 additions & 33 deletions packages/app-btc/src/operations/signTxn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import {
assertOrThrowInvalidResult,
OperationHelper,
logger as rootLogger,
getBitcoinJsLib,
getNetworkFromPath,
getCoinTypeFromPath,
} from '../../utils';
import { getRawTxnHash } from '../../services/transaction';
import {
addressToScriptPubKey,
createSignedTransaction,
} from '../../utils/transaction';
import { assertSignTxnParams } from './helpers';
import { ISignTxnParams, ISignTxnResult } from './types';

Expand All @@ -29,14 +33,6 @@ const signTxnDefaultParams = {
},
};

const addressToScriptPubKey = (address: string, derivationPath: number[]) => {
const network = getNetworkFromPath(derivationPath);

return getBitcoinJsLib()
.address.toOutputScript(address, network)
.toString('hex');
};

export const signTxn = async (
sdk: ISDK,
params: ISignTxnParams,
Expand Down Expand Up @@ -72,56 +68,62 @@ export const signTxn = async (
meta: {
version: signTxnDefaultParams.version,
locktime: params.txn.locktime ?? signTxnDefaultParams.locktime,
inputSize: params.txn.inputs.length,
outputSize: params.txn.inputs.length,
hashType: params.txn.hashType ?? signTxnDefaultParams.hashtype,
inputCount: params.txn.inputs.length,
outputCount: params.txn.outputs.length,
sighash: params.txn.hashType ?? signTxnDefaultParams.hashtype,
},
});
const { metaAccepted } = await helper.waitForResult();
assertOrThrowInvalidResult(metaAccepted);

// duplicate locally and fill `prevTxn` if missing; we need completed inputs for preparing signed transaction
const inputs = JSON.parse(JSON.stringify(params.txn.inputs));
for (let i = 0; i < params.txn.inputs.length; i += 1) {
const { input: inputRequest } = await helper.waitForResult();
assertOrThrowInvalidResult(inputRequest);
assertOrThrowInvalidResult(inputRequest.index === i);

const input = params.txn.inputs[i];
// Device needs transaction hash which is reversed byte order of the transaction id
const prevTxnHash = Buffer.from(input.prevTxnId, 'hex')
.reverse()
.toString('hex');
const prevTxn =
input.prevTxn ??
(await getRawTxnHash({
hash: input.prevTxnId,
coinType: getCoinTypeFromPath(params.derivationPath),
}));
inputs[i].prevTxn = prevTxn;
await helper.sendQuery({
input: {
prevTxn: hexToUint8Array(input.prevTxn),
prevTxnHash: hexToUint8Array(input.prevTxnHash),
prevIndex: input.prevIndex,
prevTxn: hexToUint8Array(prevTxn),
prevTxnHash: hexToUint8Array(prevTxnHash),
prevOutputIndex: input.prevIndex,
scriptPubKey: hexToUint8Array(
addressToScriptPubKey(input.address, params.derivationPath),
),
value: input.value,
sequence: input.sequence ?? signTxnDefaultParams.input.sequence,
chainIndex: input.chainIndex,
changeIndex: input.changeIndex,
addressIndex: input.addressIndex,
},
});
const { inputAccepted } = await helper.waitForResult();
assertOrThrowInvalidResult(inputAccepted);
}

for (let i = 0; i < params.txn.outputs.length; i += 1) {
const { output: outputRequest } = await helper.waitForResult();
assertOrThrowInvalidResult(outputRequest);
assertOrThrowInvalidResult(outputRequest.index === i);

const output = params.txn.outputs[i];
for (const output of params.txn.outputs) {
await helper.sendQuery({
output: {
scriptPubKey: hexToUint8Array(
addressToScriptPubKey(output.address, params.derivationPath),
),
value: output.value,
isChange: output.isChange,
chainIndex: output.chainIndex,
addressIndex: output.addressIndex,
changesIndex: output.addressIndex,
},
});
const { outputAccepted } = await helper.waitForResult();
assertOrThrowInvalidResult(outputAccepted);
}

const { verified } = await helper.waitForResult();
assertOrThrowInvalidResult(verified);

forceStatusUpdate(SignTxnStatus.SIGN_TXN_STATUS_VERIFY);

const signatures: string[] = [];
Expand All @@ -140,7 +142,13 @@ export const signTxn = async (
}

forceStatusUpdate(SignTxnStatus.SIGN_TXN_STATUS_CARD);
const signedTransaction: string = createSignedTransaction({
inputs,
outputs: params.txn.outputs,
signatures,
derivationPath: params.derivationPath,
});

logger.info('Completed');
return { signatures };
return { signedTransaction, signatures };
};
16 changes: 8 additions & 8 deletions packages/app-btc/src/operations/signTxn/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,24 @@ import { SignTxnStatus } from '../../proto/generated/types';

export type SignTxnEventHandler = (event: SignTxnStatus) => void;

export interface ISignTxnInput {
prevTxnHash: string;
export interface ISignTxnInputData {
prevTxnId: string;
prevIndex: number;
value: string;
address: string;

chainIndex: number;
changeIndex: number;
addressIndex: number;

prevTxn: string;
prevTxn?: string;
sequence?: number;
}

export interface ISignTxnOutput {
export interface ISignTxnOutputData {
value: string;
address: string;

isChange: boolean;
chainIndex?: number;
addressIndex?: number;
}

Expand All @@ -30,13 +29,14 @@ export interface ISignTxnParams {
onEvent?: SignTxnEventHandler;

txn: {
inputs: ISignTxnInput[];
outputs: ISignTxnOutput[];
inputs: ISignTxnInputData[];
outputs: ISignTxnOutputData[];
locktime?: number;
hashType?: number;
};
}

export interface ISignTxnResult {
signatures: string[];
signedTransaction: string;
}
11 changes: 11 additions & 0 deletions packages/app-btc/src/services/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { http } from '../utils/http';

const baseURL = '/v2/transaction';

export const getRawTxnHash = async (params: {
coinType: string;
hash: string;
}): Promise<string> => {
const res = await http.post(`${baseURL}/hex`, params);
return res.data.data;
};
3 changes: 3 additions & 0 deletions packages/app-btc/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module 'bip66' {
export const decode: any;
}
4 changes: 4 additions & 0 deletions packages/app-btc/src/utils/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import axios from 'axios';
import { config } from '@cypherock/sdk-utils';

export const http = axios.create({ baseURL: config.API_CYPHEROCK });
1 change: 1 addition & 0 deletions packages/app-btc/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './logger';
export * from './appId';
export * from './bitcoinjs-lib';
export * from './networks';
export * from './transaction';
16 changes: 16 additions & 0 deletions packages/app-btc/src/utils/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ const purposeMap: Record<number, purposeType | undefined> = {
[LEGACY_PURPOSE]: 'legacy',
};

export const coinIndexToCoinTypeMap: Record<number, string | undefined> = {
[BITCOIN_COIN_INDEX]: 'btc',
[TESTNET_COIN_INDEX]: 'btct',
[LITECOIN_COIN_INDEX]: 'ltc',
[DOGECOIN_COIN_INDEX]: 'doge',
[DASH_COIN_INDEX]: 'dash',
};

export const getNetworkFromPath = (path: number[]) => {
const coinIndex = path[1];
const network = coinIndexToNetworkMap[coinIndex];
Expand All @@ -88,6 +96,14 @@ export const getPurposeType = (path: number[]) => {
return purposeType;
};

export const getCoinTypeFromPath = (path: number[]) => {
const coinIndex = path[1];
const network = coinIndexToCoinTypeMap[coinIndex];

assert(network, `Coin index: 0x${coinIndex.toString(16)} not supported`);
return network;
};

const supportedPurposeMap: Record<number, purposeType[] | undefined> = {
[BITCOIN_COIN_INDEX]: ['legacy', 'segwit'],
[LITECOIN_COIN_INDEX]: ['legacy', 'segwit'],
Expand Down
92 changes: 92 additions & 0 deletions packages/app-btc/src/utils/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as bip66 from 'bip66';
import type { Signer } from 'bitcoinjs-lib';

import { assert } from '@cypherock/sdk-utils';
import { getBitcoinJsLib } from './bitcoinjs-lib';
import { getNetworkFromPath } from './networks';

import { ISignTxnInputData, ISignTxnOutputData } from '../operations/types';

export const addressToScriptPubKey = (
address: string,
derivationPath: number[],
) => {
const network = getNetworkFromPath(derivationPath);

const key = getBitcoinJsLib()
.address.toOutputScript(address, network)
.toString('hex');

return key;
};

export function isScriptSegwit(script: string) {
return script.startsWith('0014');
}

export const createSignedTransaction = (params: {
inputs: ISignTxnInputData[];
outputs: ISignTxnOutputData[];
signatures: string[];
derivationPath: number[];
}) => {
const { inputs, outputs, signatures, derivationPath } = params;

const bitcoinjs = getBitcoinJsLib();
const transaction = new bitcoinjs.Psbt();

for (const input of inputs) {
const script = addressToScriptPubKey(input.address, derivationPath);

const isSegwit = isScriptSegwit(script);

const txnInput: any = {
hash: input.prevTxnId,
index: input.prevIndex,
};

if (isSegwit) {
txnInput.witnessUtxo = {
script: Buffer.from(script, 'hex'),
value: parseInt(input.value, 10),
};
} else {
assert(input.prevTxn, 'prevTxn is required in input');
txnInput.nonWitnessUtxo = Buffer.from(input.prevTxn, 'hex');
}

transaction.addInput(txnInput);
}

for (const output of outputs) {
transaction.addOutput({
address: output.address,
value: parseInt(output.value, 10),
});
}

for (let i = 0; i < inputs.length; i += 1) {
const signature = signatures[i];

const derLength = parseInt(signature.slice(4, 6), 16) * 2;
const derEncoded = signature.slice(2, derLength + 6);
const decoded = bip66.decode(Buffer.from(derEncoded, 'hex'));

const signer: Signer = {
publicKey: Buffer.from(signature.slice(signature.length - 66), 'hex'),
sign: () =>
Buffer.concat([
decoded.r.subarray(decoded.r.length - 32),
decoded.s.subarray(decoded.s.length - 32),
]),
};

transaction.signInput(i, signer);
}

transaction.finalizeAllInputs();

const hex = transaction.extractTransaction().toHex();

return hex;
};
Loading

0 comments on commit 4375dba

Please sign in to comment.