diff --git a/modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts b/modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts index 7f45709277..4a8393313f 100644 --- a/modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts +++ b/modules/sdk-coin-hbar/src/lib/accountUpdateBuilder.ts @@ -11,6 +11,7 @@ export class AccountUpdateBuilder extends TransactionBuilder { private readonly _txBodyData: proto.CryptoUpdateTransactionBody; private _accountId: string; private _stakedNodeId?: Long; + private _stakedAccountId?: string; private _declineStakingReward?: boolean; constructor(_coinConfig: Readonly) { @@ -30,6 +31,9 @@ export class AccountUpdateBuilder extends TransactionBuilder { if (updateBody.stakedNodeId != null) { this._stakedNodeId = Long.fromValue(updateBody.stakedNodeId); } + if (updateBody.stakedAccountId) { + this._stakedAccountId = stringifyAccountId(updateBody.stakedAccountId); + } if (updateBody.declineReward != null) { const raw = updateBody.declineReward; this._declineStakingReward = typeof raw === 'boolean' ? raw : (raw as { value: boolean }).value; @@ -51,6 +55,9 @@ export class AccountUpdateBuilder extends TransactionBuilder { if (this._stakedNodeId !== undefined) { this._txBodyData.stakedNodeId = this._stakedNodeId; } + if (this._stakedAccountId !== undefined) { + this._txBodyData.stakedAccountId = buildHederaAccountID(this._stakedAccountId); + } if (this._declineStakingReward !== undefined) { this._txBodyData.declineReward = { value: this._declineStakingReward }; } @@ -60,8 +67,11 @@ export class AccountUpdateBuilder extends TransactionBuilder { /** @inheritdoc */ validateMandatoryFields(): void { - if (this._stakedNodeId === undefined) { - throw new BuildTransactionError('Invalid transaction: missing stakedNodeId'); + if (this._stakedNodeId === undefined && this._stakedAccountId === undefined) { + throw new BuildTransactionError('Invalid transaction: missing stakedNodeId or stakedAccountId'); + } + if (this._stakedNodeId !== undefined && this._stakedAccountId !== undefined) { + throw new BuildTransactionError('Invalid transaction: cannot set both stakedNodeId and stakedAccountId'); } super.validateMandatoryFields(); } @@ -94,6 +104,20 @@ export class AccountUpdateBuilder extends TransactionBuilder { return this; } + /** + * Set the staked account ID for indirect staking. Use "0.0.0" to unstake. + * + * @param {string} accountId - The account ID to stake to in format .., or "0.0.0" to clear + * @returns {AccountUpdateBuilder} - This builder + */ + stakedAccountId(accountId: string): this { + if (!isValidAddress(accountId)) { + throw new BuildTransactionError('Invalid stakedAccountId: ' + accountId); + } + this._stakedAccountId = accountId; + return this; + } + /** * Set whether to decline staking rewards. * diff --git a/modules/sdk-coin-hbar/src/lib/iface.ts b/modules/sdk-coin-hbar/src/lib/iface.ts index d1b08c9b4a..1bc1d2517f 100644 --- a/modules/sdk-coin-hbar/src/lib/iface.ts +++ b/modules/sdk-coin-hbar/src/lib/iface.ts @@ -61,6 +61,7 @@ export interface AccountUpdateInstruction { params: { accountId: string; stakedNodeId?: string; + stakedAccountId?: string; declineReward?: boolean; }; } diff --git a/modules/sdk-coin-hbar/src/lib/transaction.ts b/modules/sdk-coin-hbar/src/lib/transaction.ts index 8f615e0960..c190c47de7 100644 --- a/modules/sdk-coin-hbar/src/lib/transaction.ts +++ b/modules/sdk-coin-hbar/src/lib/transaction.ts @@ -166,13 +166,21 @@ export class Transaction extends BaseTransaction { * * @returns {object} The account update parameters including stakedNodeId and declineReward */ - private getAccountUpdateData(): { accountId: string; stakedNodeId?: string; declineReward?: boolean } { + private getAccountUpdateData(): { + accountId: string; + stakedNodeId?: string; + stakedAccountId?: string; + declineReward?: boolean; + } { const updateBody = this._txBody.cryptoUpdateAccount!; return { accountId: stringifyAccountId(updateBody.accountIDToUpdate!), ...(updateBody.stakedNodeId != null && { stakedNodeId: Long.fromValue(updateBody.stakedNodeId).toString(), }), + ...(updateBody.stakedAccountId && { + stakedAccountId: stringifyAccountId(updateBody.stakedAccountId), + }), ...(updateBody.declineReward != null && { declineReward: typeof updateBody.declineReward === 'boolean' diff --git a/modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts b/modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts index 89ec7ec6a1..e07e840531 100644 --- a/modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts +++ b/modules/sdk-coin-hbar/test/unit/transactionBuilder/accountUpdateBuilder.ts @@ -130,6 +130,52 @@ describe('HBAR Account Update Builder', () => { const txJson = tx.toJson(); txJson.instructionsData.params.accountId.should.deepEqual(testData.ACCOUNT_2.accountId); }); + + it('a stake-to-account transaction with stakedAccountId', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.stakedAccountId(testData.ACCOUNT_2.accountId); + 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.stakedAccountId.should.deepEqual(testData.ACCOUNT_2.accountId); + txJson.instructionsData.params.accountId.should.deepEqual(testData.ACCOUNT_1.accountId); + should.not.exist(txJson.instructionsData.params.stakedNodeId); + tx.type.should.equal(TransactionType.AccountUpdate); + }); + + it('an unstake-from-account transaction with sentinel 0.0.0', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.stakedAccountId('0.0.0'); + 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.stakedAccountId.should.deepEqual('0.0.0'); + should.not.exist(txJson.instructionsData.params.stakedNodeId); + tx.type.should.equal(TransactionType.AccountUpdate); + }); + + it('a stake-to-account transaction with declineReward', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.stakedAccountId(testData.ACCOUNT_2.accountId); + txBuilder.declineStakingReward(true); + 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.stakedAccountId.should.deepEqual(testData.ACCOUNT_2.accountId); + txJson.instructionsData.params.declineReward.should.equal(true); + }); }); describe('serialized transactions', () => { @@ -164,16 +210,54 @@ describe('HBAR Account Update Builder', () => { tx2.toJson().instructionsData.params.accountId.should.deepEqual(testData.ACCOUNT_1.accountId); tx2.toJson().instructionsData.params.stakedNodeId.should.deepEqual(NODE_ID.toString()); }); + + it('a stakedAccountId transaction round-trip', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.stakedAccountId(testData.ACCOUNT_2.accountId); + txBuilder.validDuration(1000000); + txBuilder.node({ nodeId: '0.0.2345' }); + txBuilder.startTime('1596110493.372646570'); + const tx = await txBuilder.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.stakedAccountId.should.deepEqual(testData.ACCOUNT_2.accountId); + should.not.exist(tx2.toJson().instructionsData.params.stakedNodeId); + }); }); }); describe('should fail', () => { - it('a stake transaction without stakedNodeId', async () => { + it('a stake transaction without stakedNodeId or stakedAccountId', 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'); + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing stakedNodeId or stakedAccountId'); + }); + + it('a stake transaction with both stakedNodeId and stakedAccountId', async () => { + const txBuilder = factory.getAccountUpdateBuilder(); + txBuilder.fee({ fee: testData.FEE }); + txBuilder.source({ address: testData.ACCOUNT_1.accountId }); + txBuilder.stakedNodeId(NODE_ID); + txBuilder.stakedAccountId(testData.ACCOUNT_2.accountId); + txBuilder.node({ nodeId: '0.0.2345' }); + await txBuilder + .build() + .should.be.rejectedWith('Invalid transaction: cannot set both stakedNodeId and stakedAccountId'); + }); + + it('a stake transaction with an invalid stakedAccountId', () => { + const txBuilder = factory.getAccountUpdateBuilder(); + assert.throws( + () => txBuilder.stakedAccountId('invalidAccountId'), + (e: any) => e.message === 'Invalid stakedAccountId: invalidAccountId' + ); }); it('a stake transaction with an invalid account id', () => {