From 5f28145a5a2268b4a76599b353c5a95cd409d286 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Thu, 16 Jan 2025 11:33:15 +0530 Subject: [PATCH] feat(sdk-coin-icp): added address creation and validation logic TICKET: WIN-4247 --- modules/sdk-coin-icp/package.json | 8 ++- modules/sdk-coin-icp/src/icp.ts | 38 ++++++++-- modules/sdk-coin-icp/src/lib/keyPair.ts | 59 +++++++++++++++ modules/sdk-coin-icp/src/lib/utils.ts | 83 +++++++++++++++++++++- modules/sdk-coin-icp/test/unit/icp.ts | 43 ++++++++++- modules/sdk-core/src/bitgo/environments.ts | 3 + modules/statics/src/coins.ts | 4 +- yarn.lock | 7 ++ 8 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 modules/sdk-coin-icp/src/lib/keyPair.ts diff --git a/modules/sdk-coin-icp/package.json b/modules/sdk-coin-icp/package.json index 2d907f32b1..5603ca2890 100644 --- a/modules/sdk-coin-icp/package.json +++ b/modules/sdk-coin-icp/package.json @@ -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", diff --git a/modules/sdk-coin-icp/src/icp.ts b/modules/sdk-coin-icp/src/icp.ts index 017f909130..431994d615 100644 --- a/modules/sdk-coin-icp/src/icp.ts +++ b/modules/sdk-coin-icp/src/icp.ts @@ -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. @@ -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 { @@ -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 { @@ -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; + } } diff --git a/modules/sdk-coin-icp/src/lib/keyPair.ts b/modules/sdk-coin-icp/src/lib/keyPair.ts new file mode 100644 index 0000000000..a9c6e91f47 --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/keyPair.ts @@ -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(); + } +} diff --git a/modules/sdk-coin-icp/src/lib/utils.ts b/modules/sdk-coin-icp/src/lib/utils.ts index 2f4a177e6b..b9c165c95a 100644 --- a/modules/sdk-coin-icp/src/lib/utils.ts +++ b/modules/sdk-coin-icp/src/lib/utils.ts @@ -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 { + return { + 'Content-Type': 'application/json', + }; + } + + getNetworkIdentifier(): Record { + 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; diff --git a/modules/sdk-coin-icp/test/unit/icp.ts b/modules/sdk-coin-icp/test/unit/icp.ts index ca4b6ba6c7..3e53f7e87c 100644 --- a/modules/sdk-coin-icp/test/unit/icp.ts +++ b/modules/sdk-coin-icp/test/unit/icp.ts @@ -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 () { @@ -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`); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/environments.ts b/modules/sdk-core/src/bitgo/environments.ts index da7c82d081..f195c5fe99 100644 --- a/modules/sdk-core/src/bitgo/environments.ts +++ b/modules/sdk-core/src/bitgo/environments.ts @@ -65,6 +65,7 @@ interface EnvironmentTemplate { flrExplorerApiToken?: string; sgbExplorerBaseUrl?: string; sgbExplorerApiToken?: string; + rosettaNodeURL: string; wemixExplorerBaseUrl?: string; wemixExplorerApiToken?: string; } @@ -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 = { @@ -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, { diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index 721f334099..0fd236b9e9 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -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', @@ -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', diff --git a/yarn.lock b/yarn.lock index 0bc9322960..77c0fb8cc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"