Skip to content

Commit b480997

Browse files
authored
Merge pull request #5390 from BitGo/WIN-4247
feat(sdk-coin-icp): added address creation and validation logic
2 parents c259e2d + 5f28145 commit b480997

File tree

8 files changed

+234
-11
lines changed

8 files changed

+234
-11
lines changed

modules/sdk-coin-icp/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@
4141
},
4242
"dependencies": {
4343
"@bitgo/sdk-core": "^28.22.0",
44-
"@bitgo/statics": "^50.22.0"
44+
"@bitgo/statics": "^50.22.0",
45+
"@bitgo/utxo-lib": "^11.2.1",
46+
"@dfinity/agent": "^2.2.0",
47+
"@dfinity/candid": "^2.2.0",
48+
"@dfinity/principal": "^2.2.0",
49+
"crc-32": "^1.2.2",
50+
"elliptic": "^6.6.1"
4551
},
4652
"devDependencies": {
4753
"@bitgo/sdk-api": "^1.58.5",

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import {
99
KeyPair,
1010
SignTransactionOptions,
1111
SignedTransaction,
12+
Environments,
1213
} from '@bitgo/sdk-core';
14+
import { KeyPair as IcpKeyPair } from './lib/keyPair';
1315
import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
16+
import utils from './lib/utils';
1417

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

70-
generateKeyPair(seed?: Buffer): KeyPair {
71-
throw new Error('Method not implemented.');
73+
/**
74+
* Generate a new keypair for this coin.
75+
* @param seed Seed from which the new keypair should be generated, otherwise a random seed is used
76+
*/
77+
public generateKeyPair(seed?: Buffer): KeyPair {
78+
const keyPair = seed ? new IcpKeyPair({ seed }) : new IcpKeyPair();
79+
const keys = keyPair.getExtendedKeys();
80+
if (!keys.xprv) {
81+
throw new Error('Missing prv in key generation.');
82+
}
83+
return {
84+
pub: keys.xpub,
85+
prv: keys.xprv,
86+
};
7287
}
7388

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

82-
isValidPub(_: string): boolean {
83-
throw new Error('Method not implemented.');
97+
isValidPub(key: string): boolean {
98+
return utils.isValidPublicKey(key);
8499
}
85100

86101
isValidPrv(_: string): boolean {
@@ -96,4 +111,19 @@ export class Icp extends BaseCoin {
96111
getMPCAlgorithm(): MPCAlgorithm {
97112
return 'ecdsa';
98113
}
114+
115+
private async getAddressFromPublicKey(hexEncodedPublicKey: string) {
116+
const isKeyValid = this.isValidPub(hexEncodedPublicKey);
117+
if (!isKeyValid) {
118+
throw new Error('Public Key is not in a valid Hex Encoded Format');
119+
}
120+
const compressedKey = utils.compressPublicKey(hexEncodedPublicKey);
121+
const KeyPair = new IcpKeyPair({ pub: compressedKey });
122+
return KeyPair.getAddress();
123+
}
124+
125+
/** @inheritDoc **/
126+
protected getPublicNodeUrl(): string {
127+
return Environments[this.bitgo.getEnv()].rosettaNodeURL;
128+
}
99129
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
DefaultKeys,
3+
isPrivateKey,
4+
isPublicKey,
5+
isSeed,
6+
KeyPairOptions,
7+
Secp256k1ExtendedKeyPair,
8+
} from '@bitgo/sdk-core';
9+
import { bip32 } from '@bitgo/utxo-lib';
10+
import { randomBytes } from 'crypto';
11+
import utils from './utils';
12+
13+
const DEFAULT_SEED_SIZE_BYTES = 32;
14+
15+
/**
16+
* ICP keys and address management.
17+
*/
18+
export class KeyPair extends Secp256k1ExtendedKeyPair {
19+
/**
20+
* Public constructor. By default, creates a key pair with a random master seed.
21+
*
22+
* @param {KeyPairOptions} source Either a master seed, a private key, or a public key
23+
*/
24+
constructor(source?: KeyPairOptions) {
25+
super(source);
26+
if (!source) {
27+
const seed = randomBytes(DEFAULT_SEED_SIZE_BYTES);
28+
this.hdNode = bip32.fromSeed(seed);
29+
} else if (isSeed(source)) {
30+
this.hdNode = bip32.fromSeed(source.seed);
31+
} else if (isPrivateKey(source)) {
32+
super.recordKeysFromPrivateKey(source.prv);
33+
} else if (isPublicKey(source)) {
34+
super.recordKeysFromPublicKey(source.pub);
35+
} else {
36+
throw new Error('Invalid key pair options');
37+
}
38+
39+
if (this.hdNode) {
40+
this.keyPair = Secp256k1ExtendedKeyPair.toKeyPair(this.hdNode);
41+
}
42+
}
43+
44+
/** @inheritdoc */
45+
getKeys(): DefaultKeys {
46+
return {
47+
pub: this.getPublicKey({ compressed: true }).toString('hex'),
48+
prv: this.getPrivateKey()?.toString('hex'),
49+
};
50+
}
51+
52+
/** @inheritdoc */
53+
getAddress(): string {
54+
const principal = utils.derivePrincipalFromPublicKey(this.getKeys().pub);
55+
const subAccount = new Uint8Array(32);
56+
const accountId = utils.fromPrincipal(principal, subAccount);
57+
return accountId.toString();
58+
}
59+
}

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

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,103 @@
11
import { BaseUtils } from '@bitgo/sdk-core';
2+
import elliptic from 'elliptic';
3+
import { Principal as DfinityPrincipal } from '@dfinity/principal';
4+
import * as agent from '@dfinity/agent';
5+
import crypto from 'crypto';
6+
import crc32 from 'crc-32';
7+
8+
const Secp256k1Curve = new elliptic.ec('secp256k1');
29

310
export class Utils implements BaseUtils {
411
isValidAddress(address: string): boolean {
512
throw new Error('Method not implemented.');
613
}
14+
715
isValidTransactionId(txId: string): boolean {
816
throw new Error('Method not implemented.');
917
}
18+
1019
isValidPublicKey(key: string): boolean {
11-
throw new Error('Method not implemented.');
20+
const hexRegex = /^[0-9a-fA-F]+$/;
21+
if (!hexRegex.test(key)) return false;
22+
23+
const length = key.length;
24+
if (length !== 130) return false;
25+
26+
return true;
1227
}
28+
1329
isValidPrivateKey(key: string): boolean {
1430
throw new Error('Method not implemented.');
1531
}
32+
1633
isValidSignature(signature: string): boolean {
1734
throw new Error('Method not implemented.');
1835
}
36+
1937
isValidBlockId(hash: string): boolean {
2038
throw new Error('Method not implemented.');
2139
}
40+
41+
getHeaders(): Record<string, string> {
42+
return {
43+
'Content-Type': 'application/json',
44+
};
45+
}
46+
47+
getNetworkIdentifier(): Record<string, string> {
48+
return {
49+
blockchain: 'Internet Computer',
50+
network: '00000000000000020101',
51+
};
52+
}
53+
54+
compressPublicKey(uncompressedKey: string): string {
55+
if (!uncompressedKey.startsWith('04')) {
56+
throw new Error('Invalid uncompressed public key format');
57+
}
58+
const xHex = uncompressedKey.slice(2, 66);
59+
const yHex = uncompressedKey.slice(66);
60+
const y = BigInt(`0x${yHex}`);
61+
const prefix = y % 2n === 0n ? '02' : '03';
62+
return prefix + xHex;
63+
}
64+
65+
getCurveType(): string {
66+
return 'secp256k1';
67+
}
68+
69+
derivePrincipalFromPublicKey(publicKeyHex: string): DfinityPrincipal {
70+
const publicKeyBuffer = Buffer.from(publicKeyHex, 'hex');
71+
72+
try {
73+
const ellipticKey = Secp256k1Curve.keyFromPublic(publicKeyBuffer);
74+
const uncompressedPublicKeyHex = ellipticKey.getPublic(false, 'hex');
75+
const derEncodedKey = agent.wrapDER(Buffer.from(uncompressedPublicKeyHex, 'hex'), agent.SECP256K1_OID);
76+
const principalId = DfinityPrincipal.selfAuthenticating(Buffer.from(derEncodedKey));
77+
const principal = DfinityPrincipal.fromUint8Array(principalId.toUint8Array());
78+
return principal;
79+
} catch (error) {
80+
throw new Error(`Failed to process the public key: ${error.message}`);
81+
}
82+
}
83+
84+
fromPrincipal(principal: DfinityPrincipal, subAccount: Uint8Array = new Uint8Array(32)): string {
85+
const ACCOUNT_ID_PREFIX = new Uint8Array([0x0a, ...Buffer.from('account-id')]);
86+
const principalBytes = principal.toUint8Array();
87+
const combinedBytes = new Uint8Array(ACCOUNT_ID_PREFIX.length + principalBytes.length + subAccount.length);
88+
89+
combinedBytes.set(ACCOUNT_ID_PREFIX, 0);
90+
combinedBytes.set(principalBytes, ACCOUNT_ID_PREFIX.length);
91+
combinedBytes.set(subAccount, ACCOUNT_ID_PREFIX.length + principalBytes.length);
92+
93+
const sha224Hash = crypto.createHash('sha224').update(combinedBytes).digest();
94+
const checksum = Buffer.alloc(4);
95+
checksum.writeUInt32BE(crc32.buf(sha224Hash) >>> 0, 0);
96+
97+
const accountIdBytes = Buffer.concat([checksum, sha224Hash]);
98+
return accountIdBytes.toString('hex');
99+
}
22100
}
101+
102+
const utils = new Utils();
103+
export default utils;

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
1-
import 'should';
2-
31
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
42
import { BitGoAPI } from '@bitgo/sdk-api';
53

64
import { Icp, Ticp } from '../../src/index';
5+
import nock from 'nock';
6+
nock.enableNetConnect();
77

88
const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' });
9+
bitgo.safeRegister('ticp', Ticp.createInstance);
10+
11+
describe('Internet computer', function () {
12+
let bitgo;
13+
let basecoin;
914

10-
describe('Icp', function () {
1115
before(function () {
16+
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' });
1217
bitgo.safeRegister('icp', Icp.createInstance);
1318
bitgo.safeRegister('ticp', Ticp.createInstance);
1419
bitgo.initializeTestVars();
20+
basecoin = bitgo.coin('ticp');
21+
});
22+
23+
after(function () {
24+
nock.pendingMocks().should.be.empty();
25+
nock.cleanAll();
1526
});
1627

1728
it('should return the right info', function () {
@@ -30,4 +41,30 @@ describe('Icp', function () {
3041
ticp.getBaseFactor().should.equal(1e8);
3142
icp.supportsTss().should.equal(true);
3243
});
44+
45+
describe('Address creation', () => {
46+
const hexEncodedPublicKey =
47+
'047a83e378053f87b49aeae53b3ed274c8b2ffbe59d9a51e3c4d850ca8ac1684f7131b778317c0db04de661c7d08321d60c0507868af41fe3150d21b3c6c757367';
48+
const invalidPublicKey = '02a83e378053f87b49aeae53b3ed274c8b2ffbe59d9a51e3c4d850ca8ac1684f7';
49+
const validAccountID = '8b84c3a3529d02a9decb5b1a27e7c8d886e17e07ea0a538269697ef09c2a27b4';
50+
51+
it('should return true when validating a hex encoded public key', function () {
52+
basecoin.isValidPub(hexEncodedPublicKey).should.equal(true);
53+
});
54+
55+
it('should return false when validating a invalid public key', function () {
56+
basecoin.isValidPub(invalidPublicKey).should.equal(false);
57+
});
58+
59+
it('should return valid address from a valid hex encoded public key', async function () {
60+
const accountID = await basecoin.getAddressFromPublicKey(hexEncodedPublicKey);
61+
accountID.should.deepEqual(validAccountID);
62+
});
63+
64+
it('should throw an error when invalid public key is provided', async function () {
65+
await basecoin
66+
.getAddressFromPublicKey(invalidPublicKey)
67+
.should.be.rejectedWith(`Public Key is not in a valid Hex Encoded Format`);
68+
});
69+
});
3370
});

modules/sdk-core/src/bitgo/environments.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ interface EnvironmentTemplate {
6565
flrExplorerApiToken?: string;
6666
sgbExplorerBaseUrl?: string;
6767
sgbExplorerApiToken?: string;
68+
rosettaNodeURL: string;
6869
wemixExplorerBaseUrl?: string;
6970
wemixExplorerApiToken?: string;
7071
}
@@ -165,6 +166,7 @@ const mainnetBase: EnvironmentTemplate = {
165166
etcNodeUrl: 'https://etc.blockscout.com',
166167
coredaoExplorerBaseUrl: 'https://scan.coredao.org/',
167168
oasExplorerBaseUrl: 'https://explorer.oasys.games',
169+
rosettaNodeURL: 'http://localhost:8081', //TODO(WIN-4242): update when rosetta node is available
168170
};
169171

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

225228
const devBase: EnvironmentTemplate = Object.assign({}, testnetBase, {

modules/statics/src/coins.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,7 +1584,7 @@ export const coins = CoinMap.fromCoins([
15841584
UnderlyingAsset.ICP,
15851585
BaseUnit.ICP,
15861586
ICP_FEATURES,
1587-
KeyCurve.Ed25519
1587+
KeyCurve.Secp256k1
15881588
),
15891589
account(
15901590
'ce572773-26c2-4038-a96d-26649a9a96df',
@@ -1595,7 +1595,7 @@ export const coins = CoinMap.fromCoins([
15951595
UnderlyingAsset.ICP,
15961596
BaseUnit.ICP,
15971597
ICP_FEATURES,
1598-
KeyCurve.Ed25519
1598+
KeyCurve.Secp256k1
15991599
),
16001600
erc20CompatibleAccountCoin(
16011601
'bfae821b-cf3a-4190-b1a8-a54af51d730e',

yarn.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,13 @@
14781478
debug "^3.1.0"
14791479
lodash.once "^4.1.1"
14801480

1481+
"@dfinity/principal@^2.2.0":
1482+
version "2.2.0"
1483+
resolved "https://registry.npmjs.org/@dfinity/principal/-/principal-2.2.0.tgz#36bd46e9e4d9d96eee8288b0c68762299e081dcf"
1484+
integrity sha512-8Yxb/6B4BWvV64HJ7X8sbDjoBaEamAQgOZ0MK0I44lZiRHomAYeUJMrw3yBg9jI1T62lijLcl401FAXBOzciiQ==
1485+
dependencies:
1486+
"@noble/hashes" "^1.3.1"
1487+
14811488
"@discoveryjs/json-ext@^0.5.0":
14821489
version "0.5.7"
14831490
resolved "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"

0 commit comments

Comments
 (0)