Skip to content

feat: add staking flow skeleton for ICP #6376

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

Closed
wants to merge 1 commit into from
Closed
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
3 changes: 2 additions & 1 deletion modules/sdk-coin-icp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@bitgo/statics": "^54.5.0",
"@dfinity/agent": "^2.2.0",
"@dfinity/candid": "^2.2.0",
"@dfinity/nns": "^8.5.0",
"@dfinity/principal": "^2.2.0",
"@noble/curves": "1.8.1",
"bignumber.js": "^9.1.1",
Expand All @@ -62,4 +63,4 @@
"@bitgo/sdk-test": "^8.0.92"
},
"gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c"
}
}
26 changes: 26 additions & 0 deletions modules/sdk-coin-icp/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,29 @@ export enum CurveType {
SECP256K1 = 'secp256k1',
}

export enum Topic {
Unspecified = 0,
Governance = 1,
SnsAndCommunityFund = 2,
}

export enum OperationType {
TRANSACTION = 'TRANSACTION',
FEE = 'FEE',
INCREASE_DISSOLVE_DELAY = 'INCREASE_DISSOLVE_DELAY',
CLAIM_OR_REFRESH = 'CLAIM_OR_REFRESH',
ADD_HOTKEY = 'ADD_HOTKEY',
REMOVE_HOTKEY = 'REMOVE_HOTKEY',
}

export interface NeuronDetails {
neuronId: bigint;
controller: string;
hotkeys: string[];
}

export interface FollowConfig {
[topic: number]: bigint[];
}

export enum MethodName {
Expand All @@ -48,6 +68,12 @@ export interface IcpTransactionData {
memo: number | BigInt; // memo in string is not accepted by ICP chain.
transactionType: OperationType;
expiryTime: number | BigInt;
additionalData?: {
neuronId?: bigint;
additionalDelaySeconds?: number;
followConfig?: FollowConfig;
dissolveDelaySeconds?: number;
};
}

export interface IcpPublicKey {
Expand Down
183 changes: 183 additions & 0 deletions modules/sdk-coin-icp/src/lib/stakingBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { BaseKey, BuildTransactionError } from '@bitgo/sdk-core';
import { Principal } from '@dfinity/principal';
import { sha256 } from 'js-sha256';
import BigNumber from 'bignumber.js';

export class StakingValidationWarning extends Error {
constructor(message: string) {
super(message);
this.name = 'StakingValidationWarning';
}
}

import { TransactionBuilder } from './transactionBuilder';
import { Transaction } from './transaction';
import utils from './utils';
import { IcpTransactionData, OperationType, DEFAULT_MEMO } from './iface';

export interface StakingOptions {
senderPublicKey: string;
amountToStakeE8s: string;
neuronMemo?: bigint;
feeE8s?: string;
dissolveDelaySeconds?: number;
}

export class StakingBuilder extends TransactionBuilder {
private readonly GOVERNANCE_CANISTER_ID = Principal.fromText('rrkah-fqaaa-aaaaa-aaaaq-cai');
private readonly DEFAULT_FEE_E8S = '10000'; // 0.0001 ICP
private readonly MIN_DISSOLVE_DELAY_FOR_VOTING = 6 * 30 * 24 * 60 * 60; // 6 months in seconds

private amountToStakeE8s: string;
private neuronMemo: bigint;
private feeE8s: string;
private dissolveDelaySeconds?: number;

constructor(coinConfig: Readonly<CoinConfig>) {
super(coinConfig);
this._transaction = new Transaction(coinConfig);
}

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, Buffer.from(principalBytes), nonceBuf]);
return Uint8Array.from(sha256.create().update(hashInput).array());
}

/** @inheritdoc */
protected async buildImplementation(): Promise<Transaction> {
this.validateAmount();
this.validateDissolveDelay();
this.validateTransactionData();

if (!this._publicKey) {
throw new BuildTransactionError('Public key is required');
}
const controllerPrincipal = utils.derivePrincipalFromPublicKey(this._publicKey);
const subaccount = this.getNeuronSubaccount(controllerPrincipal, this.neuronMemo);
const neuronAccountId = utils.fromPrincipal(this.GOVERNANCE_CANISTER_ID, subaccount);
const receiverAddress = neuronAccountId;

// Let utils.getMetaData handle the time calculations with default behavior
const currentTime = Date.now() * 1000_000;
const { metaData } = utils.getMetaData(this.neuronMemo, currentTime, undefined);

if (!metaData.ingress_end) {
throw new BuildTransactionError('Failed to generate ingress expiry time');
}

const transactionData: IcpTransactionData = {
senderAddress: this._sender,
receiverAddress,
amount: this.amountToStakeE8s,
fee: this.feeE8s,
senderPublicKeyHex: this._publicKey,
memo: this.neuronMemo,
transactionType: OperationType.TRANSACTION,
expiryTime: metaData.ingress_end,
additionalData: {
dissolveDelaySeconds: this.dissolveDelaySeconds,
},
};

this._transaction.icpTransactionData = transactionData;
return this._transaction;
}

/** @inheritdoc */
protected signImplementation(key: BaseKey): Transaction {
// The actual signing is handled by the TSS signing process
// This implementation just returns the transaction
return this._transaction;
}

private validateAmount(): void {
if (!this.amountToStakeE8s) {
throw new BuildTransactionError('Staking amount is required');
}
utils.validateValue(new BigNumber(this.amountToStakeE8s));
}

private validateDissolveDelay(): void {
if (this.dissolveDelaySeconds !== undefined) {
if (this.dissolveDelaySeconds < 0) {
throw new BuildTransactionError('Dissolve delay cannot be negative');
}
if (this.dissolveDelaySeconds < this.MIN_DISSOLVE_DELAY_FOR_VOTING) {
throw new StakingValidationWarning(
`Dissolve delay of ${this.dissolveDelaySeconds} seconds is less than ` +
`the minimum ${this.MIN_DISSOLVE_DELAY_FOR_VOTING} seconds required for voting rights`
);
}
}
}

public amount(value: string): this {
if (!value) {
throw new BuildTransactionError('Amount value is required');
}
utils.validateValue(new BigNumber(value));
this.amountToStakeE8s = value;
return this;
}

public override memo(value: number): this {
utils.validateMemo(value);
this.neuronMemo = BigInt(value);
return this;
}

public fee(value: string): this {
if (!value) {
throw new BuildTransactionError('Fee value is required');
}
utils.validateFee(value);
this.feeE8s = value;
return this;
}

public dissolveDelay(seconds: number): this {
this.dissolveDelaySeconds = seconds;
return this;
}

public override sender(address: string, publicKey: string): this {
if (!utils.isValidAddress(address)) {
throw new BuildTransactionError('Invalid sender address');
}
if (!utils.isValidPublicKey(publicKey)) {
throw new BuildTransactionError('Invalid sender public key');
}
super.sender(address, publicKey);
this.neuronMemo = BigInt(DEFAULT_MEMO);
this.feeE8s = this.feeE8s || this.DEFAULT_FEE_E8S;
return this;
}

private validateTransactionData(): void {
if (!this._publicKey || !utils.isValidPublicKey(this._publicKey)) {
throw new BuildTransactionError('Invalid or missing public key');
}
if (this.feeE8s) {
utils.validateFee(this.feeE8s);
}
utils.validateExpireTime(this._transaction.icpTransactionData.expiryTime);
}

public getTransactionId(): string {
if (!this._transaction.icpTransactionData) {
throw new BuildTransactionError('Transaction data is required');
}
return utils.getTransactionId(
this._transaction.unsignedTransaction,
this._transaction.icpTransactionData.senderAddress,
this._transaction.icpTransactionData.receiverAddress
);
}
}
96 changes: 95 additions & 1 deletion modules/sdk-coin-icp/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { BaseAddress, BaseKey, BaseTransactionBuilder, BuildTransactionError, SigningError } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import BigNumber from 'bignumber.js';
import { DEFAULT_MEMO, IcpTransaction, IcpTransactionData, PayloadsData, Signatures } from './iface';
import { Principal } from '@dfinity/principal';
import {
DEFAULT_MEMO,
IcpTransaction,
IcpTransactionData,
PayloadsData,
Signatures,
OperationType,
MAX_INGRESS_TTL,
} from './iface';
import { SignedTransactionBuilder } from './signedTransactionBuilder';
import { Transaction } from './transaction';
import utils from './utils';
Expand All @@ -14,6 +23,11 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
protected _ingressEnd: number | BigInt;
protected _receiverId: string;
protected _amount: string;
protected static readonly GOVERNANCE_CANISTER_ID = Principal.fromText('rrkah-fqaaa-aaaaa-aaaaq-cai');
protected static readonly DEFAULT_FEE_E8S = '10000'; // 0.0001 ICP
protected _neuronId: bigint;
protected _additionalDelaySeconds: number;
protected _feeE8s: string;

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
Expand Down Expand Up @@ -185,4 +199,84 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
);
this._transaction.signedTransaction = signedTransactionBuilder.getSignTransaction();
}

/**
* Handle dissolve delay increase for a neuron
*
* @param {bigint} neuronId - The ID of the neuron
* @param {number} additionalDelaySeconds - Additional seconds to add to dissolve delay
* @param {string} senderPublicKey - The sender's public key
* @returns {Promise<Transaction>} The built transaction
*/
protected async handleDissolveDelay(
neuronId: bigint,
additionalDelaySeconds: number,
senderPublicKey: string
): Promise<Transaction> {
if (!neuronId) {
throw new BuildTransactionError('Neuron ID is required');
}
if (!additionalDelaySeconds || additionalDelaySeconds <= 0) {
throw new BuildTransactionError('Additional delay seconds must be greater than 0');
}

const controllerPrincipal = utils.derivePrincipalFromPublicKey(senderPublicKey);
const defaultSubaccount = new Uint8Array(32);
const senderAddress = utils.fromPrincipal(controllerPrincipal, defaultSubaccount);

const currentTime = Date.now() * 1000_000;
const ingressStartTime = currentTime;
const ingressEndTime = ingressStartTime + MAX_INGRESS_TTL;

const transactionData: IcpTransactionData = {
senderAddress,
receiverAddress: utils.fromPrincipal(TransactionBuilder.GOVERNANCE_CANISTER_ID, defaultSubaccount),
amount: '0', // No amount transfer needed for dissolve delay increase
fee: this._feeE8s || TransactionBuilder.DEFAULT_FEE_E8S,
senderPublicKeyHex: senderPublicKey,
memo: BigInt(neuronId.toString()),
transactionType: OperationType.INCREASE_DISSOLVE_DELAY,
expiryTime: ingressEndTime,
additionalData: {
neuronId: neuronId,
additionalDelaySeconds: additionalDelaySeconds,
},
};

this._transaction.icpTransactionData = transactionData;
return this._transaction;
}

/**
* Set the neuron ID for dissolve delay operation
*
* @param {bigint} id - The neuron ID
* @returns {this} The transaction builder instance
*/
public neuron(id: bigint): this {
this._neuronId = id;
return this;
}

/**
* Set the additional delay seconds for dissolve delay operation
*
* @param {number} seconds - The additional seconds to add
* @returns {this} The transaction builder instance
*/
public additionalDelay(seconds: number): this {
this._additionalDelaySeconds = seconds;
return this;
}

/**
* Set the fee for the transaction
*
* @param {string} value - The fee amount in e8s
* @returns {this} The transaction builder instance
*/
public fee(value: string): this {
this._feeE8s = value;
return this;
}
}
Loading
Loading