Skip to content

Commit 37ff071

Browse files
Merge pull request #5952 from BitGo/WIN-5199
feat(sdk-coin-icp): Added txn hash generation logic
2 parents fe9ef0a + 71d83d6 commit 37ff071

File tree

6 files changed

+115
-7
lines changed

6 files changed

+115
-7
lines changed

modules/sdk-coin-icp/src/lib/iface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,7 @@ export interface RecoveryOptions {
194194
export interface PublicNodeSubmitResponse {
195195
status: string;
196196
}
197+
198+
export interface AccountIdentifierHex {
199+
hash: Buffer<ArrayBuffer>;
200+
}

modules/sdk-coin-icp/src/lib/transaction.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class Transaction extends BaseTransaction {
3434
protected _signedTransaction: string;
3535
protected _signaturePayload: Signatures[];
3636
protected _createdTimestamp: number | bigint | undefined;
37+
protected _txnId: string | undefined;
3738
protected _utils: Utils;
3839

3940
constructor(_coinConfig: Readonly<CoinConfig>, utils: Utils) {
@@ -89,6 +90,14 @@ export class Transaction extends BaseTransaction {
8990
return this._createdTimestamp;
9091
}
9192

93+
set txnId(txnId: string) {
94+
this._txnId = txnId;
95+
}
96+
97+
get txnId(): string | undefined {
98+
return this._txnId;
99+
}
100+
92101
async fromRawTransaction(rawTransaction: string): Promise<void> {
93102
try {
94103
const jsonRawTransaction: RawTransaction = JSON.parse(rawTransaction);
@@ -115,7 +124,7 @@ export class Transaction extends BaseTransaction {
115124
throw new Error('Invalid transaction type');
116125
}
117126
} catch (error) {
118-
throw new InvalidTransactionError('Invalid raw transaction');
127+
throw new InvalidTransactionError(`Invalid raw transaction: ${error.message}`);
119128
}
120129
}
121130

@@ -137,7 +146,7 @@ export class Transaction extends BaseTransaction {
137146
switch (this._icpTransactionData.transactionType) {
138147
case OperationType.TRANSACTION:
139148
const txData: TxData = {
140-
id: this._id,
149+
id: this._id, //TODO set transaction ID
141150
sender: this._icpTransactionData.senderAddress,
142151
senderPublicKey: this._icpTransactionData.senderPublicKeyHex,
143152
recipient: this._icpTransactionData.receiverAddress,

modules/sdk-coin-icp/src/lib/transactionBuilder.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
1313
protected _memo: number | BigInt;
1414
protected _receiverId: string;
1515
protected _amount: string;
16+
protected _txnId: string | undefined;
1617

1718
constructor(_coinConfig: Readonly<CoinConfig>) {
1819
super(_coinConfig);
@@ -169,5 +170,10 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
169170
this._transaction.signaturePayload
170171
);
171172
this._transaction.signedTransaction = signedTransactionBuilder.getSignTransaction();
173+
this._transaction.txnId = utils.getTransactionId(
174+
this._transaction.unsignedTransaction,
175+
this._sender,
176+
this._receiverId
177+
);
172178
}
173179
}

modules/sdk-coin-icp/src/lib/utils.ts

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,25 @@ import {
1919
SendArgs,
2020
PayloadsData,
2121
CurveType,
22+
AccountIdentifierHex,
23+
CborUnsignedTransaction,
2224
} from './iface';
2325
import { KeyPair as IcpKeyPair } from './keyPair';
24-
import { decode, encode } from 'cbor-x'; // The "cbor-x" library is used here because it supports modern features like BigInt. do not replace it with "cbor as "cbor" is not compatible with Rust's serde_cbor when handling big numbers.
26+
import { decode, encode, Encoder } from 'cbor-x'; // The "cbor-x" library is used here because it supports modern features like BigInt. do not replace it with "cbor as "cbor" is not compatible with Rust's serde_cbor when handling big numbers.
2527
import js_sha256 from 'js-sha256';
2628
import BigNumber from 'bignumber.js';
2729
import { secp256k1 } from '@noble/curves/secp256k1';
2830
import protobuf from 'protobufjs';
2931
import { protoDefinition } from './protoDefinition';
3032

33+
// Create a custom encoder that avoids tagging
34+
const encoder = new Encoder({
35+
structuredClone: false,
36+
useToJSON: false,
37+
mapsAsObjects: false,
38+
largeBigIntToFloat: false,
39+
});
40+
3141
export class Utils implements BaseUtils {
3242
/** @inheritdoc */
3343
isValidSignature(signature: string): boolean {
@@ -603,18 +613,18 @@ export class Utils implements BaseUtils {
603613
return principalBytes;
604614
}
605615

606-
async fromArgs(arg: Uint8Array): Promise<SendArgs> {
616+
fromArgs(arg: Uint8Array): SendArgs {
607617
const root = protobuf.parse(protoDefinition).root;
608618
const SendRequestMessage = root.lookupType('SendRequest');
609619
const args = SendRequestMessage.decode(arg) as unknown as SendArgs;
610620
const transformedArgs: SendArgs = {
611-
payment: { receiverGets: { e8s: args.payment.receiverGets.e8s } },
612-
maxFee: { e8s: args.maxFee.e8s },
621+
payment: { receiverGets: { e8s: Number(args.payment.receiverGets.e8s) } },
622+
maxFee: { e8s: Number(args.maxFee.e8s) },
613623
to: { hash: Buffer.from(args.to.hash) },
614624
createdAtTime: { timestampNanos: BigNumber(args.createdAtTime.timestampNanos.toString()).toNumber() },
615625
};
616626
if (args.memo !== undefined && args.memo !== null) {
617-
transformedArgs.memo = { memo: BigInt(args.memo?.memo?.toString()) };
627+
transformedArgs.memo = { memo: Number(args.memo?.memo?.toString()) };
618628
}
619629
return transformedArgs;
620630
}
@@ -673,6 +683,81 @@ export class Utils implements BaseUtils {
673683
const s = Buffer.from(signature.s.toString(16).padStart(64, '0'), 'hex');
674684
return Buffer.concat([r, s]).toString('hex');
675685
};
686+
687+
getTransactionId(encodedUnsignedTransaction: string, sender: string, receiver: string): string {
688+
const unsignedTransaction = utils.cborDecode(
689+
utils.blobFromHex(encodedUnsignedTransaction)
690+
) as CborUnsignedTransaction;
691+
for (const [, update] of unsignedTransaction.updates as unknown as [string, HttpCanisterUpdate][]) {
692+
const args = update.arg;
693+
const sendArgs = utils.fromArgs(args);
694+
const hash = this.generateTransactionHash(sendArgs, sender, receiver);
695+
return hash;
696+
}
697+
throw new Error('Unable to compute transaction ID: no updates found in the unsigned transaction.');
698+
}
699+
700+
safeBigInt(val: unknown): number | bigint {
701+
if (typeof val === 'bigint') return val;
702+
if (typeof val !== 'number') throw new Error('Expected number or bigint');
703+
704+
if (val > Number.MAX_SAFE_INTEGER || val < Number.MIN_SAFE_INTEGER) {
705+
return BigInt(val);
706+
}
707+
return val;
708+
}
709+
710+
generateTransactionHash(sendArgs: SendArgs, sender: string, receiver: string): string {
711+
const from = this.accountIdentifier(sender);
712+
const to = this.accountIdentifier(receiver);
713+
714+
const transferMap = new Map<any, any>([
715+
[0, from],
716+
[1, to],
717+
[2, new Map([[0, this.safeBigInt(Number(sendArgs.payment.receiverGets.e8s))]])],
718+
[3, new Map([[0, sendArgs.maxFee.e8s]])],
719+
]);
720+
721+
const operationMap = new Map([[2, transferMap]]);
722+
const txnMap = new Map<any, any>([
723+
[0, operationMap],
724+
[1, this.safeBigInt(sendArgs.memo?.memo ?? 0)], //TODO need to make memo as optional, ticket: WIN-5209
725+
[2, new Map([[0, BigInt(sendArgs.createdAtTime.timestampNanos)]])],
726+
]);
727+
728+
const transactionMap = this.getProcessedTransactionMap(txnMap);
729+
const serializedTxn = encoder.encode(transactionMap);
730+
return crypto.createHash('sha256').update(serializedTxn).digest('hex');
731+
}
732+
733+
accountIdentifier(hexStr: string): AccountIdentifierHex {
734+
const bytes = Buffer.from(hexStr, 'hex');
735+
if (bytes.length === 28) {
736+
return { hash: bytes };
737+
}
738+
if (bytes.length === 32) {
739+
return { hash: bytes.slice(4) };
740+
}
741+
throw new Error(`Invalid AccountIdentifier: expected 56 or 64 hex chars, got ${hexStr.length}`);
742+
}
743+
744+
getProcessedTransactionMap(txn: Map<any, any>): Map<any, any> {
745+
const operationMap = txn.get(0);
746+
const transferMap = operationMap.get(2);
747+
transferMap.set(0, this.serializeAccountIdentifier(transferMap.get(0)));
748+
transferMap.set(1, this.serializeAccountIdentifier(transferMap.get(1)));
749+
return txn;
750+
}
751+
752+
serializeAccountIdentifier(accountHex: AccountIdentifierHex): string {
753+
if (accountHex && accountHex.hash) {
754+
const hash = accountHex.hash;
755+
const checksum = Buffer.alloc(4);
756+
checksum.writeUInt32BE(crc32.buf(hash) >>> 0, 0);
757+
return Buffer.concat([checksum, hash]).toString('hex').toLowerCase();
758+
}
759+
throw new Error('Invalid accountHex format');
760+
}
676761
}
677762

678763
const utils = new Utils();

modules/sdk-coin-icp/test/resources/icp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ export const payloadsData = {
195195
'b90002677570646174657381826b5452414e53414354494f4eb900056b63616e69737465725f69644a000000000000000201016b6d6574686f645f6e616d656773656e645f70626361726758400a0308d20912040a02080a1a0308904e2a220a20c3d30f404955975adaba89f2e1ebc75c1f44a6a204578afce8f3780d64fe252e3a0a0880a48596eb92b599186673656e646572581dd5fc1dc4d74d4aa35d81cf345533d20548113412d32fffdcece2f68a026e696e67726573735f6578706972791b000000000000000070696e67726573735f6578706972696573811b1832d4ce93deb200',
196196
};
197197

198+
export const transactionHash = '87f2e7ca80961bdc3a1fe761553a8a7f8ac5bf28b71f4e1fba807cf352a27f52';
199+
198200
export const payloadsDataWithoutMemo = {
199201
payloads: [
200202
{

modules/sdk-coin-icp/test/unit/transactionBuilder/transactionBuilder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ describe('ICP Transaction Builder', async () => {
9292
const signedTxn = txBuilder.transaction.signedTransaction;
9393
signedTxn.should.be.a.String();
9494
should.equal(signedTxn, testData.SignedTransaction);
95+
const transactionHash = txBuilder.transaction.txnId;
96+
should.equal(transactionHash, testData.transactionHash);
9597
const broadcastTxn = txBuilder.transaction.toBroadcastFormat();
9698
broadcastTxn.should.be.a.String();
9799
should.equal(broadcastTxn, signedTxn);

0 commit comments

Comments
 (0)