From 8d69a6269154b58f00454b667648a9440d2ad844 Mon Sep 17 00:00:00 2001 From: Aditya Akash Date: Fri, 17 Apr 2026 18:27:08 +0530 Subject: [PATCH 1/2] feat(sdk-coin-hbar): add staking support via AccountUpdateBuilder ticket: SI-361 --- commitlint.config.js | 1 + .../src/lib/accountUpdateBuilder.ts | 104 ++++++++++++++++++ modules/sdk-coin-hbar/src/lib/constants.ts | 1 + modules/sdk-coin-hbar/src/lib/iface.ts | 11 +- modules/sdk-coin-hbar/src/lib/index.ts | 1 + modules/sdk-coin-hbar/src/lib/transaction.ts | 43 ++++++++ .../src/lib/transactionBuilderFactory.ts | 10 ++ modules/statics/src/coinFeatures.ts | 1 + 8 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts diff --git a/commitlint.config.js b/commitlint.config.js index 90853d35ab..bc432bbf8f 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -58,6 +58,7 @@ module.exports = { 'PX-', 'QA-', 'RA-', + 'SI-', 'SO-', 'SC-', 'ST-', diff --git a/modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts b/modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts new file mode 100644 index 0000000000..116532848f --- /dev/null +++ b/modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts @@ -0,0 +1,104 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import Long from 'long'; +import { proto } from '@hashgraph/proto'; +import { BaseKey, BuildTransactionError, SigningError, TransactionType } from '@bitgo/sdk-core'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import { buildHederaAccountID, isValidAddress, stringifyAccountId } from './utils'; +import { DEFAULT_SIGNER_NUMBER } from './constants'; + +export class AccountUpdateBuilder extends TransactionBuilder { + private readonly _txBodyData: proto.CryptoUpdateTransactionBody; + private _accountId: string; + private _stakedNodeId?: Long; + private _declineStakingReward?: boolean; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._txBodyData = new proto.CryptoUpdateTransactionBody(); + this._txBody.cryptoUpdateAccount = this._txBodyData; + } + + /** @inheritdoc */ + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + const updateBody = tx.txBody.cryptoUpdateAccount; + if (updateBody) { + if (updateBody.accountIDToUpdate) { + this._accountId = stringifyAccountId(updateBody.accountIDToUpdate); + } + if (updateBody.stakedNodeId != null) { + this._stakedNodeId = Long.fromValue(updateBody.stakedNodeId); + } + if (updateBody.declineReward != null) { + const raw = updateBody.declineReward; + this._declineStakingReward = typeof raw === 'boolean' ? raw : (raw as { value: boolean }).value; + } + } + } + + /** @inheritdoc */ + protected signImplementation(key: BaseKey): Transaction { + if (this._multiSignerKeyPairs.length >= DEFAULT_SIGNER_NUMBER) { + throw new SigningError('A maximum of ' + DEFAULT_SIGNER_NUMBER + ' can sign the transaction.'); + } + return super.signImplementation(key); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this._txBodyData.accountIDToUpdate = buildHederaAccountID(this._accountId || this._source.address); + if (this._stakedNodeId !== undefined) { + this._txBodyData.stakedNodeId = this._stakedNodeId; + } + if (this._declineStakingReward !== undefined) { + this._txBodyData.declineReward = { value: this._declineStakingReward }; + } + this.transaction.setTransactionType(TransactionType.AccountUpdate); + return await super.buildImplementation(); + } + + /** @inheritdoc */ + validateMandatoryFields(): void { + if (this._stakedNodeId === undefined) { + throw new BuildTransactionError('Invalid transaction: missing stakedNodeId'); + } + super.validateMandatoryFields(); + } + + /** + * Set the account to update. Defaults to the source account if not set. + * + * @param {string} accountId - The account ID in format .. + * @returns {AccountUpdateBuilder} - This builder + */ + account(accountId: string): this { + if (!isValidAddress(accountId)) { + throw new BuildTransactionError('Invalid account address: ' + accountId); + } + this._accountId = accountId; + return this; + } + + /** + * Set the staked node ID. Use -1 to unstake. + * + * @param {number} nodeId - The consensus node ID to stake to, or -1 to clear staking + * @returns {AccountUpdateBuilder} - This builder + */ + stakedNodeId(nodeId: number): this { + this._stakedNodeId = Long.fromNumber(nodeId); + return this; + } + + /** + * Set whether to decline staking rewards. + * + * @param {boolean} decline - True to decline rewards, false to accept + * @returns {AccountUpdateBuilder} - This builder + */ + declineStakingReward(decline: boolean): this { + this._declineStakingReward = decline; + return this; + } +} diff --git a/modules/sdk-coin-hbar/src/lib/constants.ts b/modules/sdk-coin-hbar/src/lib/constants.ts index 64f634d1b4..947ccc49dd 100644 --- a/modules/sdk-coin-hbar/src/lib/constants.ts +++ b/modules/sdk-coin-hbar/src/lib/constants.ts @@ -5,4 +5,5 @@ export enum HederaTransactionTypes { CreateAccount = 'cryptoCreateAccount', TokenAssociateToAccount = 'tokenAssociate', TokenDissociateFromAccount = 'tokenAssociate', + AccountUpdate = 'cryptoUpdateAccount', } diff --git a/modules/sdk-coin-hbar/src/lib/iface.ts b/modules/sdk-coin-hbar/src/lib/iface.ts index bcd5ba39bd..d1b08c9b4a 100644 --- a/modules/sdk-coin-hbar/src/lib/iface.ts +++ b/modules/sdk-coin-hbar/src/lib/iface.ts @@ -38,7 +38,7 @@ export interface AddressDetails { memoId?: string; } -export type InstructionParams = Transfer | AssociateAccount; +export type InstructionParams = Transfer | AssociateAccount | AccountUpdateInstruction; export interface Transfer { type: HederaTransactionTypes.Transfer; @@ -55,3 +55,12 @@ export interface AssociateAccount { tokenNames: string[]; }; } + +export interface AccountUpdateInstruction { + type: HederaTransactionTypes.AccountUpdate; + params: { + accountId: string; + stakedNodeId?: string; + declineReward?: boolean; + }; +} diff --git a/modules/sdk-coin-hbar/src/lib/index.ts b/modules/sdk-coin-hbar/src/lib/index.ts index 1fc57fda27..aa5eb38702 100644 --- a/modules/sdk-coin-hbar/src/lib/index.ts +++ b/modules/sdk-coin-hbar/src/lib/index.ts @@ -7,5 +7,6 @@ export { TransferBuilder } from './transferBuilder'; export { CoinTransferBuilder } from './coinTransferBuilder'; export { TokenTransferBuilder } from './tokenTransferBuilder'; export { TokenAssociateBuilder } from './tokenAssociateBuilder'; +export { AccountUpdateBuilder } from './accountUpdateBuilder'; export { Recipient } from './iface'; export { Utils }; diff --git a/modules/sdk-coin-hbar/src/lib/transaction.ts b/modules/sdk-coin-hbar/src/lib/transaction.ts index 14e7630429..8f615e0960 100644 --- a/modules/sdk-coin-hbar/src/lib/transaction.ts +++ b/modules/sdk-coin-hbar/src/lib/transaction.ts @@ -76,6 +76,9 @@ export class Transaction extends BaseTransaction { case HederaTransactionTypes.TokenAssociateToAccount: this.setTransactionType(TransactionType.AssociatedTokenAccountInitialization); break; + case HederaTransactionTypes.AccountUpdate: + this.setTransactionType(TransactionType.AccountUpdate); + break; } } @@ -109,6 +112,12 @@ export class Transaction extends BaseTransaction { params: this.getAccountAssociateData(), }; break; + case HederaTransactionTypes.AccountUpdate: + result.instructionsData = { + type: HederaTransactionTypes.AccountUpdate, + params: this.getAccountUpdateData(), + }; + break; } return result; @@ -152,6 +161,27 @@ export class Transaction extends BaseTransaction { }; } + /** + * Get the account update staking data from this transaction + * + * @returns {object} The account update parameters including stakedNodeId and declineReward + */ + private getAccountUpdateData(): { accountId: string; stakedNodeId?: string; declineReward?: boolean } { + const updateBody = this._txBody.cryptoUpdateAccount!; + return { + accountId: stringifyAccountId(updateBody.accountIDToUpdate!), + ...(updateBody.stakedNodeId != null && { + stakedNodeId: Long.fromValue(updateBody.stakedNodeId).toString(), + }), + ...(updateBody.declineReward != null && { + declineReward: + typeof updateBody.declineReward === 'boolean' + ? updateBody.declineReward + : (updateBody.declineReward as { value: boolean }).value, + }), + }; + } + /** * Get the recipient account and the amount * transferred on this transaction @@ -252,6 +282,19 @@ export class Transaction extends BaseTransaction { outputs.push(tokenEntry); }); break; + + case HederaTransactionTypes.AccountUpdate: + inputs.push({ + address: txJson.from, + value: '0', + coin: this._coinConfig.name, + }); + outputs.push({ + address: instruction.params.accountId, + value: '0', + coin: this._coinConfig.name, + }); + break; } this._inputs = inputs; this._outputs = outputs; diff --git a/modules/sdk-coin-hbar/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-hbar/src/lib/transactionBuilderFactory.ts index d9ee0c2d12..9ca40540a1 100644 --- a/modules/sdk-coin-hbar/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-hbar/src/lib/transactionBuilderFactory.ts @@ -12,6 +12,7 @@ import { Transaction } from './transaction'; import { isTokenTransfer, isValidRawTransactionFormat } from './utils'; import { TokenAssociateBuilder } from './tokenAssociateBuilder'; import { TokenTransferBuilder } from './tokenTransferBuilder'; +import { AccountUpdateBuilder } from './accountUpdateBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -42,6 +43,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new TokenAssociateBuilder(this._coinConfig)); } + /** + * Returns a builder to create an account update transaction (staking operations) + */ + getAccountUpdateBuilder(tx?: Transaction): AccountUpdateBuilder { + return this.initializeBuilder(tx, new AccountUpdateBuilder(this._coinConfig)); + } + /** @inheritDoc */ from(raw: Uint8Array | string): TransactionBuilder { this.validateRawTransaction(raw); @@ -55,6 +63,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.getWalletInitializationBuilder(tx); case TransactionType.AssociatedTokenAccountInitialization: return this.getTokenAssociateBuilder(tx); + case TransactionType.AccountUpdate: + return this.getAccountUpdateBuilder(tx); default: throw new InvalidTransactionError('Invalid transaction ' + tx.txBody.data); } diff --git a/modules/statics/src/coinFeatures.ts b/modules/statics/src/coinFeatures.ts index 324930814a..417b703ec8 100644 --- a/modules/statics/src/coinFeatures.ts +++ b/modules/statics/src/coinFeatures.ts @@ -245,6 +245,7 @@ export const HBAR_FEATURES = [ CoinFeature.MULTISIG, CoinFeature.BULK_TRANSACTION, CoinFeature.ALPHANUMERIC_MEMO_ID, + CoinFeature.STAKING, ]; export const POLYGON_FEATURES = [ ...ETH_FEATURES_WITH_MMI, From f833b289e3db59737fb05bb09fa580664aba92e8 Mon Sep 17 00:00:00 2001 From: Doddanna17 Date: Fri, 17 Apr 2026 20:56:35 +0530 Subject: [PATCH 2/2] test(sdk-coin-hbar): add unit tests for AccountUpdateBuilder Ticket: SI-361 --- .../accountUpdateBuilder.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts diff --git a/modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts b/modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts new file mode 100644 index 0000000000..3511b986c4 --- /dev/null +++ b/modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts @@ -0,0 +1,179 @@ +import assert from 'assert'; +import * as should from 'should'; +import { getBuilderFactory } from '../getBuilderFactory'; +import * as testData from '../../resources/hbar'; +import { TransactionType } from '@bitgo/sdk-core'; + +describe('HBAR Account Update Builder', () => { + const factory = getBuilderFactory('thbar'); + + const NODE_ID = 3; + + const initTxBuilder = () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.stakedNodeId(NODE_ID); + return txBuilder; + }; + + describe('should build', () => { + describe('non serialized transactions', () => { + it('a stake transaction', async () => { + const builder = initTxBuilder(); + builder.validDuration(1000000); + builder.node({ nodeId: '0.0.2345' }); + builder.startTime('1596110493.372646570'); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.instructionsData.params.accountId.should.deepEqual(testData.ACCOUNT_1.accountId); + txJson.instructionsData.params.stakedNodeId.should.deepEqual(NODE_ID.toString()); + should.deepEqual(txJson.from, testData.ACCOUNT_1.accountId); + should.deepEqual(txJson.fee.toString(), testData.FEE); + tx.type.should.equal(TransactionType.AccountUpdate); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.ACCOUNT_1.accountId, + value: '0', + coin: 'thbar', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.ACCOUNT_1.accountId, + value: '0', + coin: 'thbar', + }); + }); + + it('an unstake transaction with stakedNodeId -1', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.stakedNodeId(-1); + txBuilder.validDuration(1000000); + txBuilder.node({ nodeId: '0.0.2345' }); + txBuilder.startTime('1596110493.372646570'); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + txJson.instructionsData.params.stakedNodeId.should.deepEqual('-1'); + txJson.instructionsData.params.accountId.should.deepEqual(testData.ACCOUNT_1.accountId); + tx.type.should.equal(TransactionType.AccountUpdate); + }); + + it('a stake transaction with declineReward set to true', async () => { + const builder = initTxBuilder(); + builder.declineStakingReward(true); + builder.node({ nodeId: '0.0.2345' }); + builder.startTime('1596110493.372646570'); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.instructionsData.params.declineReward.should.equal(true); + txJson.instructionsData.params.stakedNodeId.should.deepEqual(NODE_ID.toString()); + }); + + it('a stake transaction with declineReward set to false', async () => { + const builder = initTxBuilder(); + builder.declineStakingReward(false); + builder.node({ nodeId: '0.0.2345' }); + builder.startTime('1596110493.372646570'); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.instructionsData.params.declineReward.should.equal(false); + }); + + it('a signed stake transaction', async () => { + const builder = initTxBuilder(); + builder.validDuration(1000000); + builder.node({ nodeId: '0.0.2345' }); + builder.startTime('1596110493.372646570'); + builder.sign({ key: testData.ACCOUNT_1.prvKeyWithPrefix }); + const tx = await builder.build(); + should.deepEqual(tx.signature.length, 1); + tx.type.should.equal(TransactionType.AccountUpdate); + }); + + it('a stake transaction with explicit account id', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.account(testData.ACCOUNT_2.accountId); + txBuilder.stakedNodeId(NODE_ID); + txBuilder.node({ nodeId: '0.0.2345' }); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + txJson.instructionsData.params.accountId.should.deepEqual(testData.ACCOUNT_2.accountId); + }); + }); + + describe('serialized transactions', () => { + it('a signed account update transaction round-trip', async () => { + const builder = initTxBuilder(); + builder.validDuration(1000000); + builder.node({ nodeId: '0.0.2345' }); + builder.startTime('1596110493.372646570'); + builder.sign({ key: testData.ACCOUNT_1.prvKeyWithPrefix }); + const tx = await builder.build(); + const serialized = tx.toBroadcastFormat(); + + const builder2 = factory.from(serialized); + builder2.sign({ key: testData.ACCOUNT_2.privateKey }); + const tx2 = await builder2.build(); + should.deepEqual(tx2.signature.length, 2); + tx2.type.should.equal(TransactionType.AccountUpdate); + tx2.toJson().instructionsData.params.stakedNodeId.should.deepEqual(NODE_ID.toString()); + }); + + it('an unsigned account update transaction round-trip', async () => { + const builder = initTxBuilder(); + builder.validDuration(1000000); + builder.node({ nodeId: '0.0.2345' }); + builder.startTime('1596110493.372646570'); + const tx = await builder.build(); + const serialized = tx.toBroadcastFormat(); + + const builder2 = factory.from(serialized); + const tx2 = await builder2.build(); + tx2.type.should.equal(TransactionType.AccountUpdate); + tx2.toJson().instructionsData.params.accountId.should.deepEqual(testData.ACCOUNT_1.accountId); + tx2.toJson().instructionsData.params.stakedNodeId.should.deepEqual(NODE_ID.toString()); + }); + }); + }); + + describe('should fail', () => { + it('a stake transaction without stakedNodeId', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.node({ nodeId: '0.0.2345' }); + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing stakedNodeId'); + }); + + it('a stake transaction with an invalid account id', () => { + const txBuilder = factory.getAccountUpdateBuilder(); + assert.throws( + () => txBuilder.account('invalidAccountId'), + (e: any) => e.message === 'Invalid account address: invalidAccountId' + ); + }); + + it('a stake transaction with more signatures than allowed', () => { + const builder = initTxBuilder(); + builder.sign({ key: testData.ACCOUNT_1.prvKeyWithPrefix }); + builder.sign({ key: testData.ACCOUNT_2.privateKey }); + builder.sign({ key: testData.ACCOUNT_3.privateKey }); + assert.throws( + () => builder.sign({ key: '5bb72603f237c0993f7973d37fdade32c71aa94aee686aa79d260acba1882d9a' }), + (e: any) => e.message === 'A maximum of 3 can sign the transaction.' + ); + }); + + it('a stake transaction with an invalid key', () => { + const builder = initTxBuilder(); + assert.throws( + () => builder.sign({ key: '5bb72603f237c0993f7973d37fdade32c71aa94aee686aa79d260acba1882d90AA' }), + (e: any) => e.message === 'Invalid private key' + ); + }); + }); +});