Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions modules/sdk-coin-icp/src/icp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ import * as mpc from '@bitgo/sdk-lib-mpc';

import {
CurveType,
IcpCertificate,
LEDGER_CANISTER_ID,
TESTNET_LEDGER_CANISTER_ID,
PayloadsData,
PUBLIC_NODE_REQUEST_ENDPOINT,
PublicNodeSubmitResponse,
RecoveryOptions,
RecoveryTransaction,
REQUEST_STATUS_REJECTED,
REQUEST_STATUS_REPLIED,
ROOT_PATH,
Signatures,
SigningPayload,
Expand Down Expand Up @@ -284,8 +287,18 @@ export class Icp extends BaseCoin {

const decodedResponse = utils.cborDecode(response.data) as PublicNodeSubmitResponse;

if (decodedResponse.status === 'replied') {
// it is considered a success because ICP returns response in a CBOR map with a status of 'replied'
if (decodedResponse.status === REQUEST_STATUS_REPLIED) {
// The outer status 'replied' means the IC accepted and processed the HTTP call.
// The certificate contains the actual canister execution result. A canister can
// still reject the call (e.g. insufficient funds) even when the outer status is
// 'replied', so we must inspect the certificate's request_status subtree.
if (decodedResponse.certificate) {
const certificate = utils.cborDecode(Buffer.from(decodedResponse.certificate)) as IcpCertificate;
const { requestStatus, rejectMessage } = utils.extractRequestStatusFromCertificate(certificate);
if (requestStatus === REQUEST_STATUS_REJECTED) {
throw new Error(`Transaction rejected by canister: ${rejectMessage ?? 'unknown reason'}`);
}
}
return {}; // returned empty object as ICP does not return a txid
} else {
throw new Error(`Unexpected response status from node: ${decodedResponse.status}`);
Expand Down
24 changes: 23 additions & 1 deletion modules/sdk-coin-icp/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
TssVerifyAddressOptions,
} from '@bitgo/sdk-core';

export const REQUEST_STATUS_REPLIED = 'replied';
export const REQUEST_STATUS_REJECTED = 'rejected';

export const MAX_INGRESS_TTL = 5 * 60 * 1000_000_000; // 5 minutes in nanoseconds
export const PERMITTED_DRIFT = 60 * 1000_000_000; // 60 seconds in nanoseconds
export const LEDGER_CANISTER_ID = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 2, 1, 1]); // Uint8Array value for "00000000000000020101" and the string value is "ryjl3-tyaaa-aaaaa-aaaba-cai"
Expand Down Expand Up @@ -207,7 +210,26 @@ export interface UnsignedSweepRecoveryTransaction {

export interface PublicNodeSubmitResponse {
status: string;
}
certificate?: Uint8Array;
}

export interface IcpCertificate {
tree: IcpHashTree;
signature: Uint8Array;
}

// ICP labeled hash tree node types per the IC HTTP spec:
// [0] = Empty
// [1, left, right] = Fork
// [2, label, subtree] = Labeled
// [3, value] = Leaf
// [4, hash] = Pruned
export type IcpHashTree =
| [0]
| [1, IcpHashTree, IcpHashTree]
| [2, Uint8Array, IcpHashTree]
| [3, Uint8Array]
| [4, Uint8Array];

export interface AccountIdentifierHash {
hash: Buffer<ArrayBuffer>;
Expand Down
139 changes: 139 additions & 0 deletions modules/sdk-coin-icp/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
CurveType,
AccountIdentifierHash,
CborUnsignedTransaction,
IcpCertificate,
IcpHashTree,
MAX_INGRESS_TTL,
} from './iface';
import { KeyPair as IcpKeyPair } from './keyPair';
Expand Down Expand Up @@ -774,6 +776,143 @@ export class Utils implements BaseUtils {
}
throw new Error('Invalid accountHash format');
}

/**
* Looks up a path in an ICP labeled hash tree.
*
* The ICP state tree uses a specific CBOR array encoding:
* [0] = Empty
* [1, l, r] = Fork
* [2, label, t] = Labeled
* [3, value] = Leaf
* [4, hash] = Pruned
*
* @param tree The root node of the ICP labeled hash tree.
* @param path Ordered path segments (as Buffers or strings).
* @returns The leaf value as a Buffer, or undefined if not found.
*/
lookupPathInTree(tree: IcpHashTree, path: (Buffer | string)[]): Buffer | undefined {
if (path.length === 0) {
if (tree[0] === 3) {
return Buffer.from(tree[1] as Uint8Array);
}
return undefined;
}

const [head, ...rest] = path;
const labelBytes = typeof head === 'string' ? Buffer.from(head, 'utf8') : head;

return this.findLabeledSubtree(tree, labelBytes, rest);
}

private findLabeledSubtree(tree: IcpHashTree, label: Buffer, remainingPath: (Buffer | string)[]): Buffer | undefined {
const nodeType = tree[0];

if (nodeType === 1) {
// Fork: search both sides
const left = this.findLabeledSubtree(tree[1] as IcpHashTree, label, remainingPath);
if (left !== undefined) return left;
return this.findLabeledSubtree(tree[2] as IcpHashTree, label, remainingPath);
}

if (nodeType === 2) {
// Labeled: check if this label matches
const nodeLabel = Buffer.from(tree[1] as Uint8Array);
if (nodeLabel.equals(label)) {
return this.lookupPathInTree(tree[2] as IcpHashTree, remainingPath);
}
}

return undefined;
}

/**
* Extracts the request status from an ICP v3 API call response certificate.
*
* For a `/api/v3/canister/call` response where the outer status is 'replied',
* the certificate tree contains the actual request outcome at the path
* ["request_status", <request_id>, "status"].
*
* @param certificate The decoded ICP certificate object.
* @returns Object containing the request status and optional reject message.
*/
extractRequestStatusFromCertificate(certificate: IcpCertificate): {
requestStatus: string;
rejectMessage?: string;
rejectCode?: number;
} {
const tree = certificate.tree;

// The status node is nested under ["request_status", <request_id_bytes>, "status"].
// We don't know the request_id in advance, so we search the tree for any "status"
// leaf inside the "request_status" subtree, skipping the intermediate request_id level.
const statusFromSubtree = this.searchForLeafInSubtree(tree, 'request_status', 'status');
if (statusFromSubtree === undefined) {
// No request_status/status found — treat as unknown (not a rejection)
return { requestStatus: 'unknown' };
}

const requestStatus = statusFromSubtree.toString('utf8');

if (requestStatus === 'rejected') {
const rejectMsg = this.searchForLeafInSubtree(tree, 'request_status', 'reject_message');
const rejectCodeBuf = this.searchForLeafInSubtree(tree, 'request_status', 'reject_code');
return {
requestStatus,
rejectMessage: rejectMsg?.toString('utf8'),
rejectCode: rejectCodeBuf ? rejectCodeBuf.readUIntBE(0, rejectCodeBuf.length) : undefined,
};
}

return { requestStatus };
}

/**
* Searches for a labeled leaf nested inside a named outer path segment.
* Used to traverse the request_status subtree without knowing the intermediate
* request_id hash key.
*/
private searchForLeafInSubtree(tree: IcpHashTree, outerLabel: string, innerLabel: string): Buffer | undefined {
const outer = Buffer.from(outerLabel, 'utf8');
const inner = Buffer.from(innerLabel, 'utf8');
return this.findNestedLabel(tree, outer, inner, false);
}

private findNestedLabel(
tree: IcpHashTree,
outerLabel: Buffer,
innerLabel: Buffer,
insideOuter: boolean
): Buffer | undefined {
const nodeType = tree[0];

if (nodeType === 1) {
// Fork
const left = this.findNestedLabel(tree[1] as IcpHashTree, outerLabel, innerLabel, insideOuter);
if (left !== undefined) return left;
return this.findNestedLabel(tree[2] as IcpHashTree, outerLabel, innerLabel, insideOuter);
}

if (nodeType === 2) {
const nodeLabel = Buffer.from(tree[1] as Uint8Array);
if (!insideOuter && nodeLabel.equals(outerLabel)) {
// Entered the outer subtree — now search for innerLabel anywhere inside
return this.findNestedLabel(tree[2] as IcpHashTree, outerLabel, innerLabel, true);
}
if (insideOuter && nodeLabel.equals(innerLabel)) {
// Found the inner label inside the outer subtree
const subtree = tree[2] as IcpHashTree;
if (subtree[0] === 3) {
return Buffer.from(subtree[1] as Uint8Array);
}
return undefined;
}
// Continue searching inside same subtree
return this.findNestedLabel(tree[2] as IcpHashTree, outerLabel, innerLabel, insideOuter);
}

return undefined;
}
}

const utils = new Utils();
Expand Down
9 changes: 8 additions & 1 deletion modules/sdk-coin-icp/test/resources/icp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,9 +376,16 @@ export const TxnIdWithDefaultMemo = 'a8c20ea0d9c984400f01b7f9d5325773862751c3277

export const TxnIdWithMemo = 'ac2157cdca60e8c54c127434893dc6f65548aefa0603d3f3232f01ec8f2e0c82';

export const PublicNodeApiBroadcastResponse =
// CBOR-encoded ICP v3 /call response where the outer status is 'replied' but the
// certificate's request_status tree shows 'rejected' (e.g. insufficient funds).
// Used to verify that broadcastTransaction correctly surfaces canister rejections.
export const PublicNodeApiBroadcastResponseRejected =
'd9d9f7a266737461747573677265706c6965646b6365727469666963617465590317d9d9f7a2647472656583018301820458200548bee8d4a0ada144f09841a1814e0c7b06a9c525e19d07990c27a8d5dfebe983018204582059964f7dcd455bb5333f0424188250c1bafdfee505b6d4bffce6b93d84b02fbc83024e726571756573745f7374617475738301820458208ec4679d2b1049ab4154b9113c88a7bacc1772831a799d0337e650fe093339648302582086ab628166c7c823c4fac064f42bcb6c6939aec9a63ce88a2eef553475fd55958301830183024a6572726f725f636f646582034649433035303383024b72656a6563745f636f646582034105830183024e72656a6563745f6d65737361676582035901984572726f722066726f6d2043616e69737465722072796a6c332d74796161612d61616161612d61616162612d6361693a2043616e69737465722063616c6c656420606963302e74726170602077697468206d6573736167653a20746865206465626974206163636f756e7420646f65736e2774206861766520656e6f7567682066756e647320746f20636f6d706c65746520746865207472616e73616374696f6e2c2063757272656e742062616c616e63653a20302e303030303030303020546f6b656e2e0a436f6e736964657220677261636566756c6c792068616e646c696e67206661696c757265732066726f6d20746869732063616e6973746572206f7220616c746572696e67207468652063616e697374657220746f2068616e646c6520657863657074696f6e732e2053656520646f63756d656e746174696f6e3a20687474703a2f2f696e7465726e6574636f6d70757465722e6f72672f646f63732f63757272656e742f7265666572656e6365732f657865637574696f6e2d6572726f727323747261707065642d6578706c696369746c7983024673746174757382034872656a6563746564830182045820535b0b886d8f16a1d4909618e38af89fa407ef6f606c1e667ab31015f35db57f83024474696d658203498ad7d790e682f69918697369676e61747572655830a26f5ae0c1396f6005487223031062886416c7d4ea37a1b2a7b64a287b376a5f4495cffd99a69bf4f4fd95e53794f415';

// CBOR-encoded ICP v3 /call success response: status='replied' with no certificate field.
// Represents a synchronous canister call that was accepted and completed successfully.
export const PublicNodeApiBroadcastResponse = 'd9d9f7a166737461747573677265706c696564';

export const RecoverySignedTransactionWithDefaultMemo =
'b9000367636f6e74656e74b900066c726571756573745f747970656463616c6c6b63616e69737465725f69644a000000000000000201016b6d6574686f645f6e616d656773656e645f70626361726758430a02080012080a0608f0c5eadc031a0308904e2a220a20c3d30f404955975adaba89f2e1ebc75c1f44a6a204578afce8f3780d64fe252e3a0a0880a48596eb92b599186673656e646572581db32b534dcc87771406ce0d93595733ca9c635f0f2f3e29559e8806a1026e696e67726573735f6578706972791b1832d4ce93deb2006d73656e6465725f7075626b6579d84058583056301006072a8648ce3d020106052b8104000a0342000482ef63ee315d6323c616970c93267ca92ee81a691027f9c856ccd23f414f32c5209b7b6f64f42e66c9c5adf064cf0f372ae1b27c9a0fcbc4dcaa8fb5cd5ba0e66a73656e6465725f7369675840cd8f6cde4f72597767b740c3eae6bc8374afa73fd099a474c7d02e60dee5b3f3239829c223f0aec28ca6fe28d735068b7ab77bef6ef89f0a7307e9fdb5f15d09';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ describe('ICP transaction recovery', async () => {
);
});

it('should fail to recover if canister rejects the transaction (e.g. insufficient funds)', async () => {
nock.cleanAll();
const rejectedResponse = Buffer.from(testData.PublicNodeApiBroadcastResponseRejected, 'hex');
nock(nodeUrl).post(broadcastEndpoint).reply(200, rejectedResponse);
await icp.recover(recoveryParams).should.rejectedWith(/Transaction rejected by canister/);
});

it('should fail to recover txn if balance is low', async () => {
sinon.restore();
setupLowBalanceStubs();
Expand Down
Loading
Loading