Skip to content

Commit 9eda9f2

Browse files
Merge pull request #5980 from BitGo/WIN-5199
feat(sdk-coin-icp): added txn hash generation logic
2 parents 2acd35d + 59d20a8 commit 9eda9f2

File tree

5 files changed

+139
-6
lines changed

5 files changed

+139
-6
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 AccountIdentifierHash {
199+
hash: Buffer<ArrayBuffer>;
200+
}

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export class Transaction extends BaseTransaction {
9595
const serializedTxFormatJsonString = serializedTxFormatBuffer.toString('utf-8');
9696
const jsonRawTransaction: RawTransaction = JSON.parse(serializedTxFormatJsonString);
9797
const payloadsData = jsonRawTransaction.serializedTxHex;
98+
this._payloadsData = payloadsData;
9899
const parsedTx = await this.parseUnsignedTransaction(payloadsData.unsigned_transaction);
99100
const senderPublicKeyHex = jsonRawTransaction.publicKey;
100101
const transactionType = parsedTx.operations[0].type;
@@ -113,12 +114,13 @@ export class Transaction extends BaseTransaction {
113114
this._icpTransactionData.memo = parsedTx.metadata.memo;
114115
}
115116
this._utils.validateRawTransaction(this._icpTransactionData);
117+
this._id = this.generateTransactionId();
116118
break;
117119
default:
118120
throw new Error('Invalid transaction type');
119121
}
120122
} catch (error) {
121-
throw new InvalidTransactionError(`Invalid transaction type: ${error.message}`);
123+
throw new InvalidTransactionError(`Invalid transaction: ${error.message}`);
122124
}
123125
}
124126

@@ -130,6 +132,9 @@ export class Transaction extends BaseTransaction {
130132
throw new Error('signatures length is not matching');
131133
}
132134
this._signaturePayload = signaturePayloads;
135+
if (this._id === undefined || this._id === null) {
136+
this._id = this.generateTransactionId();
137+
}
133138
}
134139

135140
/** @inheritdoc */
@@ -299,4 +304,20 @@ export class Transaction extends BaseTransaction {
299304
canSign(key: BaseKey): boolean {
300305
return true;
301306
}
307+
308+
/**
309+
* Generates a unique transaction ID for the current transaction.
310+
* The transaction ID is derived using the unsigned transaction data,
311+
* the sender's address, and the receiver's address.
312+
*
313+
* @returns {string} The generated transaction ID.
314+
*/
315+
private generateTransactionId(): string {
316+
const id = this._utils.getTransactionId(
317+
this.unsignedTransaction,
318+
this.icpTransactionData.senderAddress,
319+
this.icpTransactionData.receiverAddress
320+
);
321+
return id;
322+
}
302323
}

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

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,24 @@ import {
2020
SendArgs,
2121
PayloadsData,
2222
CurveType,
23+
AccountIdentifierHash,
24+
CborUnsignedTransaction,
2325
} from './iface';
2426
import { KeyPair as IcpKeyPair } from './keyPair';
25-
const { encode, decode } = require('cbor-x/index-no-eval'); // 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.
27+
const { encode, decode, Encoder } = require('cbor-x/index-no-eval'); // 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.
2628
import js_sha256 from 'js-sha256';
2729
import BigNumber from 'bignumber.js';
2830
import { secp256k1 } from '@noble/curves/secp256k1';
2931
import protobuf from 'protobufjs';
3032

33+
//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.Root.fromJSON(require(path.resolve(__dirname, './staticProtoDefinition.json')));
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,84 @@ 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(unsignedTransaction: string, senderAddress: string, receiverAddress: string): string {
688+
try {
689+
const decodedTxn = utils.cborDecode(utils.blobFromHex(unsignedTransaction)) as CborUnsignedTransaction;
690+
const updates = decodedTxn.updates as unknown as [string, HttpCanisterUpdate][];
691+
for (const [, update] of updates) {
692+
const updateArgs = update.arg;
693+
const sendArgs = utils.fromArgs(updateArgs);
694+
const transactionHash = this.generateTransactionHash(sendArgs, senderAddress, receiverAddress);
695+
return transactionHash;
696+
}
697+
throw new Error('No updates found in the unsigned transaction.');
698+
} catch (error) {
699+
throw new Error(`Unable to compute transaction ID: ${error.message}`);
700+
}
701+
}
702+
703+
safeBigInt(value: unknown): number | bigint {
704+
if (typeof value === 'bigint') {
705+
return value;
706+
}
707+
708+
if (typeof value === 'number') {
709+
const isUnsafe = value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER;
710+
return isUnsafe ? BigInt(value) : value;
711+
}
712+
713+
throw new Error(`Invalid type: expected a number or bigint, but received ${typeof value}`);
714+
}
715+
716+
generateTransactionHash(sendArgs: SendArgs, senderAddress: string, receiverAddress: string): string {
717+
const senderAccount = this.accountIdentifier(senderAddress);
718+
const receiverAccount = this.accountIdentifier(receiverAddress);
719+
720+
const transferFields = new Map<any, any>([
721+
[0, senderAccount],
722+
[1, receiverAccount],
723+
[2, new Map([[0, this.safeBigInt(Number(sendArgs.payment.receiverGets.e8s))]])],
724+
[3, new Map([[0, sendArgs.maxFee.e8s]])],
725+
]);
726+
727+
const operationMap = new Map([[2, transferFields]]);
728+
const txnFields = new Map<any, any>([
729+
[0, operationMap],
730+
[1, this.safeBigInt(sendArgs.memo?.memo || 0)], // TODO: remove 0 value as memo will always be set, WIN-5232
731+
[2, new Map([[0, BigInt(sendArgs.createdAtTime.timestampNanos)]])],
732+
]);
733+
734+
const processedTxn = this.getProcessedTransactionMap(txnFields);
735+
const serializedTxn = encoder.encode(processedTxn);
736+
return crypto.createHash('sha256').update(serializedTxn).digest('hex');
737+
}
738+
739+
accountIdentifier(accountAddress: string): AccountIdentifierHash {
740+
const bytes = Buffer.from(accountAddress, 'hex');
741+
if (bytes.length === 32) {
742+
return { hash: bytes.slice(4) };
743+
}
744+
throw new Error(`Invalid AccountIdentifier: 64 hex chars, got ${accountAddress.length}`);
745+
}
746+
747+
getProcessedTransactionMap(txnMap: Map<any, any>): Map<any, any> {
748+
const operationMap = txnMap.get(0);
749+
const transferMap = operationMap.get(2);
750+
transferMap.set(0, this.serializeAccountIdentifier(transferMap.get(0)));
751+
transferMap.set(1, this.serializeAccountIdentifier(transferMap.get(1)));
752+
return txnMap;
753+
}
754+
755+
serializeAccountIdentifier(accountHash: AccountIdentifierHash): string {
756+
if (accountHash && accountHash.hash) {
757+
const hashBuffer = accountHash.hash;
758+
const checksum = Buffer.alloc(4);
759+
checksum.writeUInt32BE(crc32.buf(hashBuffer) >>> 0, 0);
760+
return Buffer.concat([checksum, hashBuffer]).toString('hex').toLowerCase();
761+
}
762+
throw new Error('Invalid accountHash format');
763+
}
676764
}
677765

678766
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 OnChainTransactionHash = '87f2e7ca80961bdc3a1fe761553a8a7f8ac5bf28b71f4e1fba807cf352a27f52';
199+
198200
export const payloadsDataWithoutMemo = {
199201
payloads: [
200202
{

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,29 @@ 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.id;
96+
should.equal(transactionHash, testData.OnChainTransactionHash);
9597
const broadcastTxn = txBuilder.transaction.toBroadcastFormat();
9698
broadcastTxn.should.be.a.String();
9799
should.equal(broadcastTxn, signedTxn);
98100
});
99101

102+
it('should generate a correct txn hash', async () => {
103+
sinon.stub(txn._utils, 'validateExpireTime').returns(true);
104+
const unsignedTxn = txBuilder.transaction.unsignedTransaction;
105+
unsignedTxn.should.be.a.String();
106+
const payloadsData = txBuilder.transaction.payloadsData;
107+
const serializedTxFormat = {
108+
serializedTxHex: payloadsData,
109+
publicKey: testData.accounts.account1.publicKey,
110+
};
111+
const serializedTxHex = Buffer.from(JSON.stringify(serializedTxFormat), 'utf-8').toString('hex');
112+
await txn.fromRawTransaction(serializedTxHex);
113+
const transactionHash = txBuilder.transaction.id;
114+
should.equal(transactionHash, testData.OnChainTransactionHash);
115+
sinon.restore();
116+
});
117+
100118
it('should build a txn then parse it and then again build', async () => {
101119
sinon.restore(); // do not stub getMetaData
102120
txBuilder = factory.getTransferBuilder();

0 commit comments

Comments
 (0)