diff --git a/modules/sdk-coin-icp/src/lib/icpAgent.ts b/modules/sdk-coin-icp/src/lib/icpAgent.ts index e5af5e051d..37cdfbd73d 100644 --- a/modules/sdk-coin-icp/src/lib/icpAgent.ts +++ b/modules/sdk-coin-icp/src/lib/icpAgent.ts @@ -1,7 +1,14 @@ import { Principal } from '@dfinity/principal'; import { HttpAgent, replica, AgentCanister } from 'ic0'; import utils from './utils'; -import { ACCOUNT_BALANCE_CALL, LEDGER_CANISTER_ID, ICRC1_FEE_KEY, METADATA_CALL, DEFAULT_SUBACCOUNT } from './iface'; +import { + ACCOUNT_BALANCE_CALL, + LEDGER_CANISTER_ID, + GOVERNANCE_CANISTER_ID, + ICRC1_FEE_KEY, + METADATA_CALL, + DEFAULT_SUBACCOUNT, +} from './iface'; import BigNumber from 'bignumber.js'; export class IcpAgent { @@ -38,6 +45,17 @@ export class IcpAgent { return ic(Principal.fromUint8Array(LEDGER_CANISTER_ID).toText()); } + /** + * Retrieves an instance of the governance canister agent. + * + * @returns {AgentCanister} An agent interface for interacting with the governance canister. + */ + private getGovernanceCanister(): AgentCanister { + const agent = this.createAgent(); + const ic = replica(agent, { local: true }); + return ic(Principal.fromUint8Array(GOVERNANCE_CANISTER_ID).toText()); + } + /** * Fetches the account balance for a given principal ID. * @param principalId - The principal ID of the account. diff --git a/modules/sdk-coin-icp/src/lib/iface.ts b/modules/sdk-coin-icp/src/lib/iface.ts index decca1dcfd..27c3f8a851 100644 --- a/modules/sdk-coin-icp/src/lib/iface.ts +++ b/modules/sdk-coin-icp/src/lib/iface.ts @@ -2,10 +2,12 @@ import { TransactionExplanation as BaseTransactionExplanation, TransactionType as BitGoTransactionType, } from '@bitgo/sdk-core'; +import { Principal } from '@dfinity/principal'; 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" +export const LEDGER_CANISTER_ID = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 2, 1, 1]); // ryjl3-tyaaa-aaaaa-aaaba-cai +export const GOVERNANCE_CANISTER_ID = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); // rrkah-fqaaa-aaaaa-aaaaq-cai export const ROOT_PATH = 'm/0'; export const ACCOUNT_BALANCE_CALL = 'icrc1_balance_of'; export const PUBLIC_NODE_REQUEST_ENDPOINT = '/api/v3/canister/'; @@ -39,6 +41,25 @@ export enum Network { ID = '00000000000000020101', // ICP does not have different network IDs for mainnet and testnet } +export enum Topic { + Unspecified = 0, + Governance = 1, + SnsAndCommunityFund = 2, + NetworkEconomics = 3, + Node = 4, + ParticipantManagement = 5, + SubnetManagement = 6, + NetworkCanisterManagement = 7, + KYC = 8, + NodeProviderRewards = 9, +} + +export enum Vote { + Unspecified = 0, + Yes = 1, + No = 2, +} + export interface IcpTransactionData { senderAddress: string; receiverAddress: string; @@ -194,6 +215,37 @@ export interface RecoveryTransaction { tx: string; } +export enum HotkeyPermission { + VOTE = 'VOTE', + FOLLOW = 'FOLLOW', + DISSOLVE = 'DISSOLVE', + CONFIGURE = 'CONFIGURE', +} + +export interface HotkeyStatus { + principal: Principal; + permissions: HotkeyPermission[]; + addedAt: number; + lastUsed?: number; +} + +export interface FullNeuron { + controller: Principal; + hotKeys: Principal[]; + hotkeyDetails?: HotkeyStatus[]; + dissolveDelaySeconds: number; + votingPower: bigint; + state: string; +} + +export interface NeuronInfo { + neuronId: bigint; + fullNeuron?: FullNeuron; + dissolveDelaySeconds: number; + votingPower: bigint; + state: string; +} + export interface UnsignedSweepRecoveryTransaction { txHex: string; coin: string; @@ -211,3 +263,29 @@ export interface TransactionHexParams { transactionHex: string; signableHex?: string; } + +export interface ProposalInfo { + proposalId: bigint; + title: string; + topic: Topic; + status: string; + summary: string; + proposer: Principal; + created: number; +} + +export interface ClaimNeuronParams { + controller: Principal; + memo: bigint; +} + +export interface SetFolloweesParams { + neuronId: bigint; + topic: Topic; + followees: bigint[]; +} + +export interface DissolveDelayParams { + neuronId: bigint; + dissolveDelaySeconds: number; +} diff --git a/modules/sdk-coin-icp/src/lib/staking/claimNeuronBuilder.ts b/modules/sdk-coin-icp/src/lib/staking/claimNeuronBuilder.ts new file mode 100644 index 0000000000..81f001526f --- /dev/null +++ b/modules/sdk-coin-icp/src/lib/staking/claimNeuronBuilder.ts @@ -0,0 +1,178 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionBuilder } from '../transactionBuilder'; +import { BaseKey, BaseTransaction, BuildTransactionError } from '@bitgo/sdk-core'; +import { Principal } from '@dfinity/principal'; +import { createHash } from 'crypto'; +import utils, { Utils } from '../utils'; +import { Transaction } from '../transaction'; +import { UnsignedTransactionBuilder } from '../unsignedTransactionBuilder'; +import { + GOVERNANCE_CANISTER_ID, + IcpTransaction, + IcpTransactionData, + OperationType, + CurveType, + IcpPublicKey, + IcpOperation, + ClaimNeuronParams, +} from '../iface'; + +export class ClaimNeuronBuilder extends TransactionBuilder { + private _neuronMemo: bigint; + private utils: Utils; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._neuronMemo = 0n; + this.utils = new Utils(); + } + + /** + * Set the neuron memo for staking + * + * @param {bigint} memo - The memo to use for neuron identification + * @returns {this} The builder instance + */ + public neuronMemo(memo: bigint): this { + this.utils.validateMemo(memo); + this._neuronMemo = memo; + return this; + } + + /** + * Generate the neuron subaccount based on controller principal and memo + * + * @param {Principal} controllerPrincipal - The principal ID of the controller + * @param {bigint} memo - The memo value + * @returns {Uint8Array} The generated subaccount + */ + private getNeuronSubaccount(controllerPrincipal: Principal, memo: bigint): Uint8Array { + const nonceBuf = Buffer.alloc(8); + nonceBuf.writeBigUInt64BE(memo); + const domainSeparator = Buffer.from([0x0c]); + const context = Buffer.from('neuron-stake', 'utf8'); + const principalBytes = controllerPrincipal.toUint8Array(); + + const hashInput = Buffer.concat([domainSeparator, context, principalBytes, nonceBuf]); + + return new Uint8Array(createHash('sha256').update(hashInput).digest()); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender || !this._publicKey) { + throw new BuildTransactionError('Sender address and public key are required'); + } + if (!this._amount) { + throw new BuildTransactionError('Staking amount is required'); + } + + // Get controller principal from public key + const controllerPrincipal = this.utils.derivePrincipalFromPublicKey(this._publicKey); + + // Generate neuron subaccount + const subaccount = this.getNeuronSubaccount(controllerPrincipal, this._neuronMemo); + + // Set receiver as governance canister with neuron subaccount + const governancePrincipal = Principal.fromUint8Array(GOVERNANCE_CANISTER_ID); + this._receiverId = this.utils.fromPrincipal(governancePrincipal, subaccount); + + const publicKey: IcpPublicKey = { + hex_bytes: this._publicKey, + curve_type: CurveType.SECP256K1, + }; + + const senderOperation: IcpOperation = { + type: OperationType.TRANSACTION, + account: { address: this._sender }, + amount: { + value: `-${this._amount}`, + currency: { + symbol: this._coinConfig.family, + decimals: this._coinConfig.decimalPlaces, + }, + }, + }; + + const receiverOperation: IcpOperation = { + type: OperationType.TRANSACTION, + account: { address: this._receiverId }, + amount: { + value: this._amount, + currency: { + symbol: this._coinConfig.family, + decimals: this._coinConfig.decimalPlaces, + }, + }, + }; + + const feeOperation: IcpOperation = { + type: OperationType.FEE, + account: { address: this._sender }, + amount: { + value: this.utils.feeData(), + currency: { + symbol: this._coinConfig.family, + decimals: this._coinConfig.decimalPlaces, + }, + }, + }; + + const createdTimestamp = this._transaction.createdTimestamp; + const { metaData, ingressEndTime } = this.utils.getMetaData( + Number(this._neuronMemo), + createdTimestamp, + this._ingressEnd + ); + + const icpTransaction: IcpTransaction = { + public_keys: [publicKey], + operations: [senderOperation, receiverOperation, feeOperation], + metadata: metaData, + }; + + const icpTransactionData: IcpTransactionData = { + senderAddress: this._sender, + receiverAddress: this._receiverId, + amount: this._amount, + fee: this.utils.feeData(), + senderPublicKeyHex: this._publicKey, + transactionType: OperationType.TRANSACTION, + expiryTime: ingressEndTime, + memo: Number(this._neuronMemo), + }; + + this._transaction.icpTransactionData = icpTransactionData; + this._transaction.icpTransaction = icpTransaction; + + const unsignedTransactionBuilder = new UnsignedTransactionBuilder(this._transaction.icpTransaction); + this._transaction.payloadsData = await unsignedTransactionBuilder.getUnsignedTransaction(); + return this._transaction; + } + + /** @inheritdoc */ + protected signImplementation(key: BaseKey): BaseTransaction { + const signatures = utils.getSignatures(this._transaction.payloadsData, this._publicKey, key.key); + this._transaction.addSignature(signatures); + return this._transaction; + } + + /** + * Get the parameters needed for claiming the neuron after the staking transaction is confirmed. + * This allows the consumer to handle the network calls themselves. + * + * @returns {ClaimNeuronParams} Parameters needed for claiming the neuron + * @throws {BuildTransactionError} If required fields are missing + */ + public getClaimNeuronParams(): ClaimNeuronParams { + if (!this._publicKey) { + throw new BuildTransactionError('Public key is required'); + } + + const controllerPrincipal = this.utils.derivePrincipalFromPublicKey(this._publicKey); + return { + controller: controllerPrincipal, + memo: this._neuronMemo, + }; + } +} diff --git a/modules/sdk-coin-icp/test/unit/claimNeuronBuilder.ts b/modules/sdk-coin-icp/test/unit/claimNeuronBuilder.ts new file mode 100644 index 0000000000..6037a78fab --- /dev/null +++ b/modules/sdk-coin-icp/test/unit/claimNeuronBuilder.ts @@ -0,0 +1,185 @@ +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { ClaimNeuronBuilder } from '../../src/lib/staking/claimNeuronBuilder'; +import { coins } from '@bitgo/statics'; +import { Transaction } from '../../src/lib/transaction'; +import { BuildTransactionError } from '@bitgo/sdk-core'; +import { Principal } from '@dfinity/principal'; +import should from 'should'; +import { createHash } from 'crypto'; +import { GOVERNANCE_CANISTER_ID } from '../../src/lib/iface'; + +describe('ICP Claim Neuron Builder', () => { + let bitgo: TestBitGoAPI; + let builder: ClaimNeuronBuilder; + const coinName = 'ticp'; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('ticp', coins.get('ticp') as any); + }); + + beforeEach(() => { + builder = new ClaimNeuronBuilder(coins.get(coinName)); + }); + + it('should initialize with default values', () => { + should.exist(builder); + (builder as any)._neuronMemo.toString().should.equal('0'); + }); + + it('should set neuron memo', () => { + const memo = 12345n; + builder.neuronMemo(memo); + (builder as any)._neuronMemo.should.equal(memo); + }); + + it('should throw error for invalid neuron memo', () => { + (() => builder.neuronMemo(-1n)).should.throw(BuildTransactionError); + }); + + describe('getClaimNeuronParams', () => { + const publicKey = '03ab8d1d860207f559c630290e60a0afe31afacfcd8c900c07b40f1d3b11c954a1'; + + it('should throw error if public key is missing', () => { + (() => builder.getClaimNeuronParams()).should.throw(new BuildTransactionError('Public key is required')); + }); + + it('should return valid claim neuron params', () => { + builder.sender('e8b8a75748496b0a2c5c21f1576f7ca6283809115a1b454039ac6e68ff61b80f', publicKey).neuronMemo(123n); + const params = builder.getClaimNeuronParams(); + should.exist(params); + params.memo.should.equal(123n); + should.exist(params.controller); + params.controller.should.be.instanceof(Principal); + }); + }); + + describe('neuron subaccount generation', () => { + const publicKey = '03ab8d1d860207f559c630290e60a0afe31afacfcd8c900c07b40f1d3b11c954a1'; + const memo = 123n; + + it('should generate correct neuron subaccount', async () => { + builder + .sender('e8b8a75748496b0a2c5c21f1576f7ca6283809115a1b454039ac6e68ff61b80f', publicKey) + .amount('1000000000') + .neuronMemo(memo); + const tx = (await builder.build()) as Transaction; + + // Verify the subaccount generation matches the expected algorithm + const controllerPrincipal = (builder as any).utils.derivePrincipalFromPublicKey(publicKey); + const nonceBuf = Buffer.alloc(8); + nonceBuf.writeBigUInt64BE(memo); + const domainSeparator = Buffer.from([0x0c]); + const context = Buffer.from('neuron-stake', 'utf8'); + const principalBytes = controllerPrincipal.toUint8Array(); + const hashInput = Buffer.concat([domainSeparator, context, principalBytes, nonceBuf]); + const expectedSubaccount = new Uint8Array(createHash('sha256').update(hashInput).digest()); + + // Get utils and account ID prefix + const utils = (builder as any).utils; + const accountIdPrefix = utils.getAccountIdPrefix(); + + // Debug logging for subaccount generation + console.log('Account ID prefix:', accountIdPrefix.toString('hex')); + console.log('Controller principal bytes:', Buffer.from(principalBytes).toString('hex')); + console.log('Expected subaccount:', Buffer.from(expectedSubaccount).toString('hex')); + console.log('Hash input:', Buffer.from(hashInput).toString('hex')); + + // Generate the expected full address using the governance principal + const governancePrincipal = Principal.fromUint8Array(GOVERNANCE_CANISTER_ID); + const governancePrincipalBytes = governancePrincipal.toUint8Array(); + + // Debug logging for address components + console.log('Governance principal bytes:', Buffer.from(governancePrincipalBytes).toString('hex')); + + // Generate expected address using the same method as the implementation + const expectedAddress = utils.getAccountIdFromPrincipalBytes( + accountIdPrefix, + Buffer.from(governancePrincipalBytes), + expectedSubaccount + ); + console.log('Expected address:', expectedAddress); + + const icpTx = tx as Transaction; + const receiverAddress = icpTx.icpTransactionData.receiverAddress; + should.exist(receiverAddress); + console.log('Actual receiver address:', receiverAddress); + console.log( + 'Transaction data:', + JSON.stringify( + { + icpTransactionData: icpTx.icpTransactionData, + icpTransaction: icpTx.icpTransaction, + }, + null, + 2 + ) + ); + + receiverAddress.should.equal(expectedAddress); + + // Verify the transaction operations are set correctly + should.exist(icpTx.icpTransaction); + should.exist(icpTx.icpTransaction.operations); + icpTx.icpTransaction.operations.should.have.length(3); // sender, receiver, fee operations + + // Verify the operations in detail + const [senderOp, receiverOp, feeOp] = icpTx.icpTransaction.operations; + console.log( + 'Operations:', + JSON.stringify( + { + sender: senderOp, + receiver: receiverOp, + fee: feeOp, + }, + null, + 2 + ) + ); + }); + }); + + describe('build', () => { + const sender = 'e8b8a75748496b0a2c5c21f1576f7ca6283809115a1b454039ac6e68ff61b80f'; + const amount = '1000000000'; + const publicKey = '03ab8d1d860207f559c630290e60a0afe31afacfcd8c900c07b40f1d3b11c954a1'; + + it('should throw error if sender is missing', async () => { + builder.amount(amount); + await builder + .build() + .should.be.rejectedWith(new BuildTransactionError('Sender address and public key are required')); + }); + + it('should throw error if public key is missing', async () => { + builder.amount(amount); + await builder + .build() + .should.be.rejectedWith(new BuildTransactionError('Sender address and public key are required')); + }); + + it('should throw error if amount is missing', async () => { + builder.sender(sender, publicKey); + await builder.build().should.be.rejectedWith(new BuildTransactionError('Staking amount is required')); + }); + + it('should build a valid transaction and claim neuron params', async () => { + builder.sender(sender, publicKey).amount(amount).neuronMemo(123n); + + const tx = await builder.build(); + const claimParams = builder.getClaimNeuronParams(); + should.exist(claimParams); + claimParams.memo.should.equal(123n); + should.exist(claimParams.controller); + should.exist(tx); + tx.should.be.instanceof(Transaction); + + const txData = tx.toJson(); + txData.sender.should.equal(sender); + txData.senderPublicKey.should.equal(publicKey); + txData.memo.should.equal(123); + }); + }); +});