Skip to content

feat: add icp staking flow #6382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
20 changes: 19 additions & 1 deletion modules/sdk-coin-icp/src/lib/icpAgent.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
80 changes: 79 additions & 1 deletion modules/sdk-coin-icp/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
178 changes: 178 additions & 0 deletions modules/sdk-coin-icp/src/lib/staking/claimNeuronBuilder.ts
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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<Transaction> {
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,
};
}
}
Loading