Skip to content

Commit

Permalink
Merge pull request #5390 from BitGo/WIN-4247
Browse files Browse the repository at this point in the history
feat(sdk-coin-icp): added address creation and validation logic
  • Loading branch information
mohd-kashif authored Jan 29, 2025
2 parents c259e2d + 5f28145 commit b480997
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 11 deletions.
8 changes: 7 additions & 1 deletion modules/sdk-coin-icp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@
},
"dependencies": {
"@bitgo/sdk-core": "^28.22.0",
"@bitgo/statics": "^50.22.0"
"@bitgo/statics": "^50.22.0",
"@bitgo/utxo-lib": "^11.2.1",
"@dfinity/agent": "^2.2.0",
"@dfinity/candid": "^2.2.0",
"@dfinity/principal": "^2.2.0",
"crc-32": "^1.2.2",
"elliptic": "^6.6.1"
},
"devDependencies": {
"@bitgo/sdk-api": "^1.58.5",
Expand Down
38 changes: 34 additions & 4 deletions modules/sdk-coin-icp/src/icp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import {
KeyPair,
SignTransactionOptions,
SignedTransaction,
Environments,
} from '@bitgo/sdk-core';
import { KeyPair as IcpKeyPair } from './lib/keyPair';
import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
import utils from './lib/utils';

/**
* Class representing the Internet Computer (ICP) coin.
Expand Down Expand Up @@ -67,8 +70,20 @@ export class Icp extends BaseCoin {
throw new Error('Method not implemented.');
}

generateKeyPair(seed?: Buffer): KeyPair {
throw new Error('Method not implemented.');
/**
* Generate a new keypair for this coin.
* @param seed Seed from which the new keypair should be generated, otherwise a random seed is used
*/
public generateKeyPair(seed?: Buffer): KeyPair {
const keyPair = seed ? new IcpKeyPair({ seed }) : new IcpKeyPair();
const keys = keyPair.getExtendedKeys();
if (!keys.xprv) {
throw new Error('Missing prv in key generation.');
}
return {
pub: keys.xpub,
prv: keys.xprv,
};
}

isValidAddress(address: string): boolean {
Expand All @@ -79,8 +94,8 @@ export class Icp extends BaseCoin {
throw new Error('Method not implemented.');
}

isValidPub(_: string): boolean {
throw new Error('Method not implemented.');
isValidPub(key: string): boolean {
return utils.isValidPublicKey(key);
}

isValidPrv(_: string): boolean {
Expand All @@ -96,4 +111,19 @@ export class Icp extends BaseCoin {
getMPCAlgorithm(): MPCAlgorithm {
return 'ecdsa';
}

private async getAddressFromPublicKey(hexEncodedPublicKey: string) {
const isKeyValid = this.isValidPub(hexEncodedPublicKey);
if (!isKeyValid) {
throw new Error('Public Key is not in a valid Hex Encoded Format');
}
const compressedKey = utils.compressPublicKey(hexEncodedPublicKey);
const KeyPair = new IcpKeyPair({ pub: compressedKey });
return KeyPair.getAddress();
}

/** @inheritDoc **/
protected getPublicNodeUrl(): string {
return Environments[this.bitgo.getEnv()].rosettaNodeURL;
}
}
59 changes: 59 additions & 0 deletions modules/sdk-coin-icp/src/lib/keyPair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
DefaultKeys,
isPrivateKey,
isPublicKey,
isSeed,
KeyPairOptions,
Secp256k1ExtendedKeyPair,
} from '@bitgo/sdk-core';
import { bip32 } from '@bitgo/utxo-lib';
import { randomBytes } from 'crypto';
import utils from './utils';

const DEFAULT_SEED_SIZE_BYTES = 32;

/**
* ICP keys and address management.
*/
export class KeyPair extends Secp256k1ExtendedKeyPair {
/**
* Public constructor. By default, creates a key pair with a random master seed.
*
* @param {KeyPairOptions} source Either a master seed, a private key, or a public key
*/
constructor(source?: KeyPairOptions) {
super(source);
if (!source) {
const seed = randomBytes(DEFAULT_SEED_SIZE_BYTES);
this.hdNode = bip32.fromSeed(seed);
} else if (isSeed(source)) {
this.hdNode = bip32.fromSeed(source.seed);
} else if (isPrivateKey(source)) {
super.recordKeysFromPrivateKey(source.prv);
} else if (isPublicKey(source)) {
super.recordKeysFromPublicKey(source.pub);
} else {
throw new Error('Invalid key pair options');
}

if (this.hdNode) {
this.keyPair = Secp256k1ExtendedKeyPair.toKeyPair(this.hdNode);
}
}

/** @inheritdoc */
getKeys(): DefaultKeys {
return {
pub: this.getPublicKey({ compressed: true }).toString('hex'),
prv: this.getPrivateKey()?.toString('hex'),
};
}

/** @inheritdoc */
getAddress(): string {
const principal = utils.derivePrincipalFromPublicKey(this.getKeys().pub);
const subAccount = new Uint8Array(32);
const accountId = utils.fromPrincipal(principal, subAccount);
return accountId.toString();
}
}
83 changes: 82 additions & 1 deletion modules/sdk-coin-icp/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,103 @@
import { BaseUtils } from '@bitgo/sdk-core';
import elliptic from 'elliptic';
import { Principal as DfinityPrincipal } from '@dfinity/principal';
import * as agent from '@dfinity/agent';
import crypto from 'crypto';
import crc32 from 'crc-32';

const Secp256k1Curve = new elliptic.ec('secp256k1');

export class Utils implements BaseUtils {
isValidAddress(address: string): boolean {
throw new Error('Method not implemented.');
}

isValidTransactionId(txId: string): boolean {
throw new Error('Method not implemented.');
}

isValidPublicKey(key: string): boolean {
throw new Error('Method not implemented.');
const hexRegex = /^[0-9a-fA-F]+$/;
if (!hexRegex.test(key)) return false;

const length = key.length;
if (length !== 130) return false;

return true;
}

isValidPrivateKey(key: string): boolean {
throw new Error('Method not implemented.');
}

isValidSignature(signature: string): boolean {
throw new Error('Method not implemented.');
}

isValidBlockId(hash: string): boolean {
throw new Error('Method not implemented.');
}

getHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
};
}

getNetworkIdentifier(): Record<string, string> {
return {
blockchain: 'Internet Computer',
network: '00000000000000020101',
};
}

compressPublicKey(uncompressedKey: string): string {
if (!uncompressedKey.startsWith('04')) {
throw new Error('Invalid uncompressed public key format');
}
const xHex = uncompressedKey.slice(2, 66);
const yHex = uncompressedKey.slice(66);
const y = BigInt(`0x${yHex}`);
const prefix = y % 2n === 0n ? '02' : '03';
return prefix + xHex;
}

getCurveType(): string {
return 'secp256k1';
}

derivePrincipalFromPublicKey(publicKeyHex: string): DfinityPrincipal {
const publicKeyBuffer = Buffer.from(publicKeyHex, 'hex');

try {
const ellipticKey = Secp256k1Curve.keyFromPublic(publicKeyBuffer);
const uncompressedPublicKeyHex = ellipticKey.getPublic(false, 'hex');
const derEncodedKey = agent.wrapDER(Buffer.from(uncompressedPublicKeyHex, 'hex'), agent.SECP256K1_OID);
const principalId = DfinityPrincipal.selfAuthenticating(Buffer.from(derEncodedKey));
const principal = DfinityPrincipal.fromUint8Array(principalId.toUint8Array());
return principal;
} catch (error) {
throw new Error(`Failed to process the public key: ${error.message}`);
}
}

fromPrincipal(principal: DfinityPrincipal, subAccount: Uint8Array = new Uint8Array(32)): string {
const ACCOUNT_ID_PREFIX = new Uint8Array([0x0a, ...Buffer.from('account-id')]);
const principalBytes = principal.toUint8Array();
const combinedBytes = new Uint8Array(ACCOUNT_ID_PREFIX.length + principalBytes.length + subAccount.length);

combinedBytes.set(ACCOUNT_ID_PREFIX, 0);
combinedBytes.set(principalBytes, ACCOUNT_ID_PREFIX.length);
combinedBytes.set(subAccount, ACCOUNT_ID_PREFIX.length + principalBytes.length);

const sha224Hash = crypto.createHash('sha224').update(combinedBytes).digest();
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32.buf(sha224Hash) >>> 0, 0);

const accountIdBytes = Buffer.concat([checksum, sha224Hash]);
return accountIdBytes.toString('hex');
}
}

const utils = new Utils();
export default utils;
43 changes: 40 additions & 3 deletions modules/sdk-coin-icp/test/unit/icp.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import 'should';

import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { BitGoAPI } from '@bitgo/sdk-api';

import { Icp, Ticp } from '../../src/index';
import nock from 'nock';
nock.enableNetConnect();

const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' });
bitgo.safeRegister('ticp', Ticp.createInstance);

describe('Internet computer', function () {
let bitgo;
let basecoin;

describe('Icp', function () {
before(function () {
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' });
bitgo.safeRegister('icp', Icp.createInstance);
bitgo.safeRegister('ticp', Ticp.createInstance);
bitgo.initializeTestVars();
basecoin = bitgo.coin('ticp');
});

after(function () {
nock.pendingMocks().should.be.empty();
nock.cleanAll();
});

it('should return the right info', function () {
Expand All @@ -30,4 +41,30 @@ describe('Icp', function () {
ticp.getBaseFactor().should.equal(1e8);
icp.supportsTss().should.equal(true);
});

describe('Address creation', () => {
const hexEncodedPublicKey =
'047a83e378053f87b49aeae53b3ed274c8b2ffbe59d9a51e3c4d850ca8ac1684f7131b778317c0db04de661c7d08321d60c0507868af41fe3150d21b3c6c757367';
const invalidPublicKey = '02a83e378053f87b49aeae53b3ed274c8b2ffbe59d9a51e3c4d850ca8ac1684f7';
const validAccountID = '8b84c3a3529d02a9decb5b1a27e7c8d886e17e07ea0a538269697ef09c2a27b4';

it('should return true when validating a hex encoded public key', function () {
basecoin.isValidPub(hexEncodedPublicKey).should.equal(true);
});

it('should return false when validating a invalid public key', function () {
basecoin.isValidPub(invalidPublicKey).should.equal(false);
});

it('should return valid address from a valid hex encoded public key', async function () {
const accountID = await basecoin.getAddressFromPublicKey(hexEncodedPublicKey);
accountID.should.deepEqual(validAccountID);
});

it('should throw an error when invalid public key is provided', async function () {
await basecoin
.getAddressFromPublicKey(invalidPublicKey)
.should.be.rejectedWith(`Public Key is not in a valid Hex Encoded Format`);
});
});
});
3 changes: 3 additions & 0 deletions modules/sdk-core/src/bitgo/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface EnvironmentTemplate {
flrExplorerApiToken?: string;
sgbExplorerBaseUrl?: string;
sgbExplorerApiToken?: string;
rosettaNodeURL: string;
wemixExplorerBaseUrl?: string;
wemixExplorerApiToken?: string;
}
Expand Down Expand Up @@ -165,6 +166,7 @@ const mainnetBase: EnvironmentTemplate = {
etcNodeUrl: 'https://etc.blockscout.com',
coredaoExplorerBaseUrl: 'https://scan.coredao.org/',
oasExplorerBaseUrl: 'https://explorer.oasys.games',
rosettaNodeURL: 'http://localhost:8081', //TODO(WIN-4242): update when rosetta node is available
};

const testnetBase: EnvironmentTemplate = {
Expand Down Expand Up @@ -220,6 +222,7 @@ const testnetBase: EnvironmentTemplate = {
etcNodeUrl: 'https://etc-mordor.blockscout.com',
coredaoExplorerBaseUrl: 'https://scan.test.btcs.network',
oasExplorerBaseUrl: 'https://explorer.testnet.oasys.games',
rosettaNodeURL: 'http://localhost:8081', //TODO(WIN-4242): update when rosetta node is available
};

const devBase: EnvironmentTemplate = Object.assign({}, testnetBase, {
Expand Down
4 changes: 2 additions & 2 deletions modules/statics/src/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1584,7 +1584,7 @@ export const coins = CoinMap.fromCoins([
UnderlyingAsset.ICP,
BaseUnit.ICP,
ICP_FEATURES,
KeyCurve.Ed25519
KeyCurve.Secp256k1
),
account(
'ce572773-26c2-4038-a96d-26649a9a96df',
Expand All @@ -1595,7 +1595,7 @@ export const coins = CoinMap.fromCoins([
UnderlyingAsset.ICP,
BaseUnit.ICP,
ICP_FEATURES,
KeyCurve.Ed25519
KeyCurve.Secp256k1
),
erc20CompatibleAccountCoin(
'bfae821b-cf3a-4190-b1a8-a54af51d730e',
Expand Down
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1478,6 +1478,13 @@
debug "^3.1.0"
lodash.once "^4.1.1"

"@dfinity/principal@^2.2.0":
version "2.2.0"
resolved "https://registry.npmjs.org/@dfinity/principal/-/principal-2.2.0.tgz#36bd46e9e4d9d96eee8288b0c68762299e081dcf"
integrity sha512-8Yxb/6B4BWvV64HJ7X8sbDjoBaEamAQgOZ0MK0I44lZiRHomAYeUJMrw3yBg9jI1T62lijLcl401FAXBOzciiQ==
dependencies:
"@noble/hashes" "^1.3.1"

"@discoveryjs/json-ext@^0.5.0":
version "0.5.7"
resolved "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
Expand Down

0 comments on commit b480997

Please sign in to comment.