From b35de40e5ad0b8cbf7f5a3b6cd1efb0cf40e9865 Mon Sep 17 00:00:00 2001 From: Bhavi Dhingra Date: Mon, 20 Apr 2026 14:35:23 +0530 Subject: [PATCH] feat(sdk-core): add bulk TRX resource delegation SDK methods Adds buildAccountDelegations, sendAccountDelegation, sendAccountDelegations to the Wallet class and IWallet interface, mirroring the consolidation API. Adds Express typed route schema and handler for POST /api/v2/:coin/wallet/:id/delegateResources with TSS/custodial/hot wallet branching and partial-success (202) response handling. CHALO-287 Co-Authored-By: Claude Sonnet 4.6 --- modules/express/src/clientRoutes.ts | 92 +++++ modules/express/src/typedRoutes/api/index.ts | 18 + .../typedRoutes/api/v2/delegateResources.ts | 105 +++++ .../typedRoutes/api/v2/undelegateResources.ts | 75 ++++ .../clientRoutes/bulkResourceManagement.ts | 287 ++++++++++++++ modules/sdk-coin-trx/src/trx.ts | 5 + .../sdk-core/src/bitgo/baseCoin/baseCoin.ts | 4 + .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 1 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 53 +++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 253 ++++++++++++ .../unit/bitgo/wallet/resourceManagement.ts | 375 ++++++++++++++++++ 11 files changed, 1268 insertions(+) create mode 100644 modules/express/src/typedRoutes/api/v2/delegateResources.ts create mode 100644 modules/express/src/typedRoutes/api/v2/undelegateResources.ts create mode 100644 modules/express/test/unit/clientRoutes/bulkResourceManagement.ts create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 0ae3d95021..9cb15abaa9 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1052,6 +1052,90 @@ export async function handleV2ResourceDelegations( }); } +/** + * Shared handler for bulk resource management (delegation / undelegation). + * Builds, signs, and sends one on-chain transaction per entry. + */ +async function handleV2ResourceManagement( + type: 'delegateResource' | 'undelegateResource', + req: + | ExpressApiRouteRequest<'express.v2.wallet.delegateresources', 'post'> + | ExpressApiRouteRequest<'express.v2.wallet.undelegateresources', 'post'> +) { + const bitgo = req.bitgo; + const coin = bitgo.coin(req.decoded.coin); + + if (type === 'delegateResource') { + const decoded = (req as ExpressApiRouteRequest<'express.v2.wallet.delegateresources', 'post'>).decoded; + if (!Array.isArray(decoded.delegations) || decoded.delegations.length === 0) { + throw new Error('delegations must be a non-empty array'); + } + } else { + const decoded = (req as ExpressApiRouteRequest<'express.v2.wallet.undelegateresources', 'post'>).decoded; + if (!Array.isArray(decoded.undelegations) || decoded.undelegations.length === 0) { + throw new Error('undelegations must be a non-empty array'); + } + } + + if (!coin.supportsResourceDelegation()) { + throw new Error(`${coin.getFamily()} does not support resource delegation`); + } + + const wallet = await coin.wallets().get({ id: req.decoded.id }); + + let result: any; + try { + const params = wallet._wallet.multisigType === 'tss' ? createTSSSendParams(req, wallet) : createSendParams(req); + result = + type === 'delegateResource' + ? await wallet.sendResourceDelegations(params) + : await wallet.sendResourceUndelegations(params); + } catch (err) { + // Surface unexpected errors as 400 rather than 500 + err.status = 400; + throw err; + } + + // Handle partial success / failure + if (result.failure.length > 0) { + let msg = ''; + let status = 202; + + if (result.success.length > 0) { + msg = `Transactions failed: ${result.failure.length} and succeeded: ${result.success.length}`; + } else { + status = 400; + msg = `All transactions failed`; + } + + throw apiResponse(status, result, msg); + } + + return result; +} + +/** + * Handle bulk resource delegation (e.g. TRX ENERGY/BANDWIDTH delegation). + * Builds, signs, and sends one on-chain delegation transaction per entry in req.body.delegations. + * @param req + */ +export async function handleV2DelegateResources( + req: ExpressApiRouteRequest<'express.v2.wallet.delegateresources', 'post'> +) { + return handleV2ResourceManagement('delegateResource', req); +} + +/** + * Handle bulk resource undelegation (e.g. TRX ENERGY/BANDWIDTH undelegation). + * Builds, signs, and sends one on-chain undelegation transaction per entry in req.body.undelegations. + * @param req + */ +export async function handleV2UndelegateResources( + req: ExpressApiRouteRequest<'express.v2.wallet.undelegateresources', 'post'> +) { + return handleV2ResourceManagement('undelegateResource', req); +} + /** * payload meant for prebuildAndSignTransaction() in sdk-core which * validates the payload and makes the appropriate request to WP to @@ -1834,6 +1918,14 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { prepareBitGo(config), typedPromiseWrapper(handleV2ResourceDelegations), ]); + router.post('express.v2.wallet.delegateresources', [ + prepareBitGo(config), + typedPromiseWrapper(handleV2DelegateResources), + ]); + router.post('express.v2.wallet.undelegateresources', [ + prepareBitGo(config), + typedPromiseWrapper(handleV2UndelegateResources), + ]); // Miscellaneous router.post('express.canonicaladdress', [prepareBitGo(config), typedPromiseWrapper(handleCanonicalAddress)]); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index e465e75ead..1c369d26be 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -58,6 +58,8 @@ import { PostWalletAccelerateTx } from './v2/walletAccelerateTx'; import { PostIsWalletAddress } from './v2/isWalletAddress'; import { GetAccountResources } from './v2/accountResources'; import { GetResourceDelegations } from './v2/resourceDelegations'; +import { PostDelegateResources } from './v2/delegateResources'; +import { PostUndelegateResources } from './v2/undelegateResources'; // Too large types can cause the following error // @@ -192,6 +194,18 @@ export const ExpressV2WalletConsolidateAccountApiSpec = apiSpec({ }, }); +export const ExpressV2WalletDelegateResourcesApiSpec = apiSpec({ + 'express.v2.wallet.delegateresources': { + post: PostDelegateResources, + }, +}); + +export const ExpressV2WalletUndelegateResourcesApiSpec = apiSpec({ + 'express.v2.wallet.undelegateresources': { + post: PostUndelegateResources, + }, +}); + export const ExpressWalletFanoutUnspentsApiSpec = apiSpec({ 'express.v1.wallet.fanoutunspents': { put: PutFanoutUnspents, @@ -397,6 +411,8 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressV2WalletAccelerateTxApiSpec & typeof ExpressV2WalletAccountResourcesApiSpec & typeof ExpressV2WalletResourceDelegationsApiSpec & + typeof ExpressV2WalletDelegateResourcesApiSpec & + typeof ExpressV2WalletUndelegateResourcesApiSpec & typeof ExpressWalletManagementApiSpec; export const ExpressApi: ExpressApi = { @@ -440,6 +456,8 @@ export const ExpressApi: ExpressApi = { ...ExpressV2WalletAccelerateTxApiSpec, ...ExpressV2WalletAccountResourcesApiSpec, ...ExpressV2WalletResourceDelegationsApiSpec, + ...ExpressV2WalletDelegateResourcesApiSpec, + ...ExpressV2WalletUndelegateResourcesApiSpec, ...ExpressWalletManagementApiSpec, }; diff --git a/modules/express/src/typedRoutes/api/v2/delegateResources.ts b/modules/express/src/typedRoutes/api/v2/delegateResources.ts new file mode 100644 index 0000000000..e2982f8cd2 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/delegateResources.ts @@ -0,0 +1,105 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for the delegate resources endpoint + */ +export const DelegateResourcesParams = { + /** Coin identifier (e.g., 'trx', 'ttrx') */ + coin: t.string, + /** Wallet ID */ + id: t.string, +} as const; + +/** + * A single resource delegation entry + */ +export const DelegationEntryCodec = t.type({ + /** On-chain address that will receive the delegated resources */ + receiverAddress: t.string, + /** Amount of TRX (in SUN) to stake for the delegation */ + amount: t.string, + /** Resource type to delegate (e.g. 'ENERGY', 'BANDWIDTH') */ + resource: t.string, +}); + +/** + * Request body for delegating resources to multiple receiver addresses. + * Each delegation entry triggers a separate on-chain staking transaction + * from the wallet's root address to the receiver address. + * + * Signing behaviour by wallet type: + * - Hot (non-TSS) → signed locally with walletPassphrase and submitted + * - Custodial non-TSS → sent for BitGo approval via initiateTransaction + * - TSS (any) → build response contains txRequestId; signed by TSS service + */ +export const DelegateResourcesRequestBody = { + /** Delegation entries — one on-chain transaction is built per entry */ + delegations: t.array(DelegationEntryCodec), + + /** Wallet passphrase to decrypt the user key (hot wallets) */ + walletPassphrase: optional(t.string), + /** Extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + /** One-time password for 2FA */ + otp: optional(t.string), + + /** API version for TSS transaction request response ('lite' or 'full') */ + apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])), +} as const; + +export const DelegationFailureEntry = t.type({ + /** Human-readable error message */ + message: t.string, + /** Receiver address that failed, if available */ + receiverAddress: optional(t.string), +}); + +/** + * Response for the delegate resources operation. + * Returns arrays of successful and failed delegation transactions. + */ +export const DelegateResourcesResponse = t.type({ + /** Successfully sent delegation transactions */ + success: t.array(t.unknown), + /** Errors from failed delegation transactions */ + failure: t.array(DelegationFailureEntry), +}); + +/** + * Response for partial success or failure cases (202/400). + * Includes both the transaction results and error metadata. + */ +export const DelegateResourcesErrorResponse = t.intersection([DelegateResourcesResponse, BitgoExpressError]); + +/** + * Bulk Resource Delegation + * + * Delegates resources (ENERGY or BANDWIDTH) from a wallet's root address to one or more + * receiver addresses. Each delegation entry produces a separate on-chain staking transaction. + * This is the resource-delegation analogue of the consolidateAccount endpoint. + * + * Supported coins: TRON (trx, ttrx) and any future coins that support resource delegation. + * + * The API may return partial success (status 202) if some delegations succeed but others fail. + * + * @operationId express.v2.wallet.delegateresources + * @tag express + */ +export const PostDelegateResources = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/delegateResources', + method: 'POST', + request: httpRequest({ + params: DelegateResourcesParams, + body: DelegateResourcesRequestBody, + }), + response: { + /** All delegations succeeded */ + 200: DelegateResourcesResponse, + /** Partial success — some delegations succeeded, others failed */ + 202: DelegateResourcesErrorResponse, + /** All delegations failed */ + 400: DelegateResourcesErrorResponse, + }, +}); diff --git a/modules/express/src/typedRoutes/api/v2/undelegateResources.ts b/modules/express/src/typedRoutes/api/v2/undelegateResources.ts new file mode 100644 index 0000000000..c557b4c606 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/undelegateResources.ts @@ -0,0 +1,75 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { DelegateResourcesParams, DelegationEntryCodec, DelegationFailureEntry } from './delegateResources'; + +/** + * Request body for undelegating resources from multiple receiver addresses. + * Each undelegation entry triggers a separate on-chain transaction that reclaims + * previously delegated resources back to the wallet's root address. + * + * Signing behaviour by wallet type: + * - Hot (non-TSS) → signed locally with walletPassphrase and submitted + * - Custodial non-TSS → sent for BitGo approval via initiateTransaction + * - TSS (any) → build response contains txRequestId; signed by TSS service + */ +export const UndelegateResourcesRequestBody = { + /** Undelegation entries — one on-chain transaction is built per entry */ + undelegations: t.array(DelegationEntryCodec), + + /** Wallet passphrase to decrypt the user key (hot wallets) */ + walletPassphrase: optional(t.string), + /** Extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + /** One-time password for 2FA */ + otp: optional(t.string), + + /** API version for TSS transaction request response ('lite' or 'full') */ + apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])), +} as const; + +/** + * Response for the undelegate resources operation. + * Returns arrays of successful and failed undelegation transactions. + */ +export const UndelegateResourcesResponse = t.type({ + /** Successfully sent undelegation transactions */ + success: t.array(t.unknown), + /** Errors from failed undelegation transactions */ + failure: t.array(DelegationFailureEntry), +}); + +/** + * Response for partial success or failure cases (202/400). + */ +export const UndelegateResourcesErrorResponse = t.intersection([UndelegateResourcesResponse, BitgoExpressError]); + +/** + * Bulk Resource Undelegation + * + * Reclaims delegated resources (ENERGY or BANDWIDTH) back to a wallet's root address + * from one or more receiver addresses. Each entry produces a separate on-chain transaction. + * + * Supported coins: TRON (trx, ttrx) and any future coins that support resource delegation. + * + * The API may return partial success (status 202) if some undelegations succeed but others fail. + * + * @operationId express.v2.wallet.undelegateresources + * @tag express + */ +export const PostUndelegateResources = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/undelegateResources', + method: 'POST', + request: httpRequest({ + params: DelegateResourcesParams, + body: UndelegateResourcesRequestBody, + }), + response: { + /** All undelegations succeeded */ + 200: UndelegateResourcesResponse, + /** Partial success — some undelegations succeeded, others failed */ + 202: UndelegateResourcesErrorResponse, + /** All undelegations failed */ + 400: UndelegateResourcesErrorResponse, + }, +}); diff --git a/modules/express/test/unit/clientRoutes/bulkResourceManagement.ts b/modules/express/test/unit/clientRoutes/bulkResourceManagement.ts new file mode 100644 index 0000000000..250e0062e4 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/bulkResourceManagement.ts @@ -0,0 +1,287 @@ +import * as sinon from 'sinon'; + +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; + +import * as express from 'express'; + +import { handleV2DelegateResources, handleV2UndelegateResources } from '../../../src/clientRoutes'; + +import { BitGo } from 'bitgo'; + +describe('Bulk resource management handlers', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.verifyAndRestore(); + }); + + const delegations = [{ receiverAddress: 'TRecv1', amount: '1000', resource: 'ENERGY' }]; + const undelegations = [{ receiverAddress: 'TRecv1', amount: '1000', resource: 'ENERGY' }]; + + function createDelegationMocks(result: unknown, opts: { supportsDelegation?: boolean; multisigType?: string } = {}) { + const { supportsDelegation = true, multisigType = 'hot' } = opts; + const sendStub = sandbox.stub().resolves(result); + const walletStub = { + _wallet: { multisigType }, + sendResourceDelegations: sendStub, + sendResourceUndelegations: sandbox.stub(), + }; + const coinStub = { + supportsResourceDelegation: () => supportsDelegation, + getFamily: () => 'trx', + wallets: () => ({ get: () => Promise.resolve(walletStub) }), + }; + return { + bitgoStub: sinon.createStubInstance(BitGo as any, { coin: coinStub }), + sendStub, + }; + } + + function createUndelegationMocks( + result: unknown, + opts: { supportsDelegation?: boolean; multisigType?: string } = {} + ) { + const { supportsDelegation = true, multisigType = 'hot' } = opts; + const sendStub = sandbox.stub().resolves(result); + const walletStub = { + _wallet: { multisigType }, + sendResourceDelegations: sandbox.stub(), + sendResourceUndelegations: sendStub, + }; + const coinStub = { + supportsResourceDelegation: () => supportsDelegation, + getFamily: () => 'trx', + wallets: () => ({ get: () => Promise.resolve(walletStub) }), + }; + return { + bitgoStub: sinon.createStubInstance(BitGo as any, { coin: coinStub }), + sendStub, + }; + } + + // --------------------------------------------------------------------------- + // handleV2DelegateResources + // --------------------------------------------------------------------------- + describe('handleV2DelegateResources', () => { + it('should throw if delegations is not an array', async () => { + const { bitgoStub } = createDelegationMocks({}); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations: 'not-an-array' }, + body: {}, + }; + + await handleV2DelegateResources(req as any).should.be.rejectedWith('delegations must be a non-empty array'); + }); + + it('should throw if delegations is an empty array', async () => { + const { bitgoStub } = createDelegationMocks({}); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations: [] }, + body: {}, + }; + + await handleV2DelegateResources(req as express.Request & typeof req).should.be.rejectedWith( + 'delegations must be a non-empty array' + ); + }); + + it('should throw if coin does not support resource delegation', async () => { + const { bitgoStub } = createDelegationMocks({}, { supportsDelegation: false }); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations }, + body: { delegations }, + }; + + await handleV2DelegateResources(req as express.Request & typeof req).should.be.rejectedWith( + 'trx does not support resource delegation' + ); + }); + + it('should return result when all transactions succeed (200)', async () => { + const result = { success: [{ txid: 'tx-1' }], failure: [] }; + const { bitgoStub, sendStub } = createDelegationMocks(result); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations }, + body: { delegations }, + }; + + await handleV2DelegateResources(req as express.Request & typeof req).should.be.resolvedWith(result); + sendStub.should.be.calledOnceWith(req.body); + }); + + it('should throw 202 when some transactions fail (partial success)', async () => { + const result = { success: [{ txid: 'tx-1' }], failure: [{ message: 'err', receiverAddress: 'TRecv2' }] }; + const { bitgoStub } = createDelegationMocks(result); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations }, + body: { delegations }, + }; + + await handleV2DelegateResources(req as express.Request & typeof req).should.be.rejectedWith({ + status: 202, + result, + }); + }); + + it('should throw 400 when all transactions fail', async () => { + const result = { success: [], failure: [{ message: 'err', receiverAddress: 'TRecv1' }] }; + const { bitgoStub } = createDelegationMocks(result); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations }, + body: { delegations }, + }; + + await handleV2DelegateResources(req as express.Request & typeof req).should.be.rejectedWith({ + status: 400, + result, + }); + }); + + it('should use wallet-level multisigType (not coin.supportsTss) for TSS detection', async () => { + // The coin stub deliberately does NOT expose supportsTss() — only the wallet._wallet.multisigType + // matters for the TSS routing decision in handleV2ResourceManagement. + const result = { success: [{ txid: 'tx-1' }], failure: [] }; + const sendStub = sandbox.stub().resolves(result); + const walletStub = { + _wallet: { multisigType: 'tss' }, + sendResourceDelegations: sendStub, + sendResourceUndelegations: sandbox.stub(), + }; + const coinStub = { + supportsResourceDelegation: () => true, + getFamily: () => 'trx', + wallets: () => ({ get: () => Promise.resolve(walletStub) }), + }; + const bitgoStub = sinon.createStubInstance(BitGo as any, { coin: coinStub }); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations }, + body: { delegations }, + params: { coin: 'ttrx' }, + }; + + await handleV2DelegateResources(req as express.Request & typeof req).should.be.resolvedWith(result); + sendStub.should.be.calledOnce(); + }); + + it('should surface unexpected send errors as 400', async () => { + const sendError = new Error('unexpected failure'); + const sendStub = sandbox.stub().rejects(sendError); + const walletStub = { + _wallet: { multisigType: 'hot' }, + sendResourceDelegations: sendStub, + sendResourceUndelegations: sandbox.stub(), + }; + const coinStub = { + supportsResourceDelegation: () => true, + getFamily: () => 'trx', + wallets: () => ({ get: () => Promise.resolve(walletStub) }), + }; + const bitgoStub = sinon.createStubInstance(BitGo as any, { coin: coinStub }); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', delegations }, + body: { delegations }, + }; + + const err: any = await handleV2DelegateResources(req as express.Request & typeof req).should.be.rejected(); + err.status.should.equal(400); + }); + }); + + // --------------------------------------------------------------------------- + // handleV2UndelegateResources + // --------------------------------------------------------------------------- + describe('handleV2UndelegateResources', () => { + it('should throw if undelegations is not an array', async () => { + const { bitgoStub } = createUndelegationMocks({}); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', undelegations: 'not-an-array' }, + body: {}, + }; + + await handleV2UndelegateResources(req as any).should.be.rejectedWith('undelegations must be a non-empty array'); + }); + + it('should throw if undelegations is an empty array', async () => { + const { bitgoStub } = createUndelegationMocks({}); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', undelegations: [] }, + body: {}, + }; + + await handleV2UndelegateResources(req as express.Request & typeof req).should.be.rejectedWith( + 'undelegations must be a non-empty array' + ); + }); + + it('should throw if coin does not support resource delegation', async () => { + const { bitgoStub } = createUndelegationMocks({}, { supportsDelegation: false }); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', undelegations }, + body: { undelegations }, + }; + + await handleV2UndelegateResources(req as express.Request & typeof req).should.be.rejectedWith( + 'trx does not support resource delegation' + ); + }); + + it('should return result when all transactions succeed (200)', async () => { + const result = { success: [{ txid: 'undel-tx-1' }], failure: [] }; + const { bitgoStub, sendStub } = createUndelegationMocks(result); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', undelegations }, + body: { undelegations }, + }; + + await handleV2UndelegateResources(req as express.Request & typeof req).should.be.resolvedWith(result); + sendStub.should.be.calledOnceWith(req.body); + }); + + it('should throw 202 when some transactions fail (partial success)', async () => { + const result = { + success: [{ txid: 'undel-tx-1' }], + failure: [{ message: 'lock period active', receiverAddress: 'TRecv2' }], + }; + const { bitgoStub } = createUndelegationMocks(result); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', undelegations }, + body: { undelegations }, + }; + + await handleV2UndelegateResources(req as express.Request & typeof req).should.be.rejectedWith({ + status: 202, + result, + }); + }); + + it('should throw 400 when all transactions fail', async () => { + const result = { success: [], failure: [{ message: 'lock period active', receiverAddress: 'TRecv1' }] }; + const { bitgoStub } = createUndelegationMocks(result); + const req = { + bitgo: bitgoStub, + decoded: { coin: 'ttrx', id: 'wallet-id', undelegations }, + body: { undelegations }, + }; + + await handleV2UndelegateResources(req as express.Request & typeof req).should.be.rejectedWith({ + status: 400, + result, + }); + }); + }); +}); diff --git a/modules/sdk-coin-trx/src/trx.ts b/modules/sdk-coin-trx/src/trx.ts index 08c9b5f371..3c19182542 100644 --- a/modules/sdk-coin-trx/src/trx.ts +++ b/modules/sdk-coin-trx/src/trx.ts @@ -225,6 +225,11 @@ export class Trx extends BaseCoin { return true; } + /** @inheritDoc */ + supportsResourceDelegation(): boolean { + return true; + } + /** * Checks if this is a valid base58 * @param address diff --git a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts index 737024125c..6b393129ee 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts @@ -173,6 +173,10 @@ export abstract class BaseCoin implements IBaseCoin { return false; } + supportsResourceDelegation(): boolean { + return false; + } + /** * Gets config for how token enablements work for this coin * @returns diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 145972207d..a9ce31b312 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -580,6 +580,7 @@ export interface IBaseCoin { sweepWithSendMany(): boolean; transactionDataAllowed(): boolean; allowsAccountConsolidations(): boolean; + supportsResourceDelegation(): boolean; getTokenEnablementConfig(): TokenEnablementConfig; supportsTss(): boolean; supportsMultisig(): boolean; diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index ac0eea4ac9..31636d92dd 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -1,6 +1,7 @@ import { IRequestTracer } from '../../api'; import { IBaseCoin, + ITransactionRecipient, Message, SignedMessage, SignedTransaction, @@ -63,6 +64,39 @@ export interface BuildConsolidationTransactionOptions extends PrebuildTransactio consolidateAddresses?: string[]; } +export interface ResourceDelegationEntry { + receiverAddress: string; + amount: string; + /** Resource type to delegate (e.g. 'ENERGY', 'BANDWIDTH'). */ + resource: string; +} + +export interface BuildResourceDelegationTransactionOptions + extends PrebuildTransactionOptions, + WalletSignTransactionOptions { + delegations: ResourceDelegationEntry[]; +} + +export interface BuildResourceUndelegationTransactionOptions + extends PrebuildTransactionOptions, + WalletSignTransactionOptions { + // receiverAddress denotes the account to undelegate FROM + undelegations: ResourceDelegationEntry[]; +} + +export interface ResourceManagementSendResult { + txid?: string; + status?: string; + tx?: string; + txRequestId?: string; + pendingApprovalId?: string; +} + +export interface BuildResourceManagementTransactionResult { + prebuilds: PrebuildTransactionResult[]; + buildFailures: { message: string; receiverAddress?: string }[]; +} + export interface BuildTokenEnablementOptions extends PrebuildTransactionOptions { enableTokens: TokenEnablement[]; } @@ -160,6 +194,7 @@ export interface PrebuildTransactionOptions { isTss?: boolean; custodianTransactionId?: string; apiVersion?: ApiVersion; + stakingParams?: unknown; /** * If set to false, sweep all funds including the required minimums for address(es). E.g. Polkadot (DOT) requires 1 DOT minimum. */ @@ -251,6 +286,8 @@ export interface PrebuildTransactionResult extends TransactionPrebuild { pendingApprovalId?: string; reqId?: IRequestTracer; payload?: string; + stakingParams?: unknown; + recipients?: ITransactionRecipient[]; } export interface CustomSigningFunction { @@ -1097,6 +1134,22 @@ export interface IWallet { buildAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; sendAccountConsolidation(params?: PrebuildAndSignTransactionOptions): Promise; sendAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; + buildResourceDelegations( + params: BuildResourceDelegationTransactionOptions + ): Promise; + sendResourceDelegation(params: PrebuildAndSignTransactionOptions): Promise; + sendResourceDelegations(params: BuildResourceDelegationTransactionOptions): Promise<{ + success: ResourceManagementSendResult[]; + failure: { message: string; receiverAddress?: string }[]; + }>; + buildResourceUndelegations( + params: BuildResourceUndelegationTransactionOptions + ): Promise; + sendResourceUndelegation(params: PrebuildAndSignTransactionOptions): Promise; + sendResourceUndelegations(params: BuildResourceUndelegationTransactionOptions): Promise<{ + success: ResourceManagementSendResult[]; + failure: { message: string; receiverAddress?: string }[]; + }>; buildTokenEnablements(params?: BuildTokenEnablementOptions): Promise; sendTokenEnablement(params?: PrebuildAndSignTransactionOptions): Promise; sendTokenEnablements(params?: BuildTokenEnablementOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 4a06e41c39..d05427cff5 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -64,6 +64,10 @@ import { AddressesByBalanceOptions, AddressesOptions, BuildConsolidationTransactionOptions, + BuildResourceDelegationTransactionOptions, + BuildResourceManagementTransactionResult, + BuildResourceUndelegationTransactionOptions, + ResourceManagementSendResult, BuildTokenEnablementOptions, BulkCreateShareOption, BulkWalletShareKeychain, @@ -3458,6 +3462,255 @@ export class Wallet implements IWallet { } } + /** + * Shared build logic for resource delegation and undelegation. + * POSTs to the given endpoint and post-processes each prebuild. + */ + private async buildResourceManagementTransactions( + type: 'delegateResource' | 'undelegateResource', + params: BuildResourceDelegationTransactionOptions | BuildResourceUndelegationTransactionOptions + ): Promise { + if (!this.baseCoin.supportsResourceDelegation()) { + throw new Error(`${this.baseCoin.getFullName()} does not support resource delegation.`); + } + + if (type === 'delegateResource') { + const delegationParams = params as BuildResourceDelegationTransactionOptions; + if (!delegationParams.delegations || delegationParams.delegations.length === 0) { + throw new Error('delegations must be a non-empty array.'); + } + } else { + const undelegationParams = params as BuildResourceUndelegationTransactionOptions; + if (!undelegationParams.undelegations || undelegationParams.undelegations.length === 0) { + throw new Error('undelegations must be a non-empty array.'); + } + } + + const endpoint = type === 'delegateResource' ? '/delegateResources/build' : '/undelegateResources/build'; + const whitelistedParams = + type === 'delegateResource' + ? _.pick(params as BuildResourceDelegationTransactionOptions, ['delegations', 'apiVersion']) + : _.pick(params as BuildResourceUndelegationTransactionOptions, ['undelegations', 'apiVersion']); + debug('prebuilding resource management transactions (%s): %O', endpoint, whitelistedParams); + + if (params.reqId) { + this.bitgo.setRequestTracer(params.reqId); + } + + const buildResponse = (await this.bitgo + .post(this.baseCoin.url('/wallet/' + this.id() + endpoint)) + .send(whitelistedParams) + .result()) as { transactions: any[]; errors: any[] }; + + if (!Array.isArray(buildResponse.transactions)) { + throw new Error(`Unexpected response from ${endpoint}: missing transactions array`); + } + + const buildFailures: { message: string; receiverAddress?: string }[] = []; + if (buildResponse.errors?.length > 0) { + debug('build errors from %s: %O', endpoint, buildResponse.errors); + for (const err of buildResponse.errors) { + buildFailures.push({ + message: err.error ?? err.message ?? String(err), + receiverAddress: err.receiverAddress, + }); + } + } + + const prebuilds: PrebuildTransactionResult[] = []; + for (const rawTx of buildResponse.transactions) { + let prebuild: PrebuildTransactionResult = (await this.baseCoin.postProcessPrebuild( + Object.assign(rawTx, { wallet: this, buildParams: params }) + )) as PrebuildTransactionResult; + + delete prebuild.wallet; + delete prebuild.buildParams; + + prebuild = _.extend({}, prebuild, { walletId: this.id() }); + debug('final resource management transaction prebuild: %O', prebuild); + prebuilds.push(prebuild); + } + return { prebuilds, buildFailures }; + } + + /** + * Shared signing logic for a single resource management transaction (delegation or undelegation). + * Signing flow by wallet type (mirrors sendAccountConsolidation): + * - TSS (hot or custodial) → sendManyTxRequests (requires txRequestId on prebuildTx) + * - Custodial non-TSS → initiateTransaction (BitGo auto-signs with its key) + * - Hot non-TSS → prebuildAndSignTransaction + submitTransaction + */ + private async sendResourceManagementTransaction( + type: 'delegateResource' | 'undelegateResource', + params: PrebuildAndSignTransactionOptions + ): Promise { + // Custodial non-TSS: BitGo holds the key, send for BitGo approval and signing. + // The /tx/initiate API builds and signs server-side. Promote stakingParams and + // recipients from the prebuild to the top level so they survive the TxSendBody + // whitelist applied inside initiateTransaction. + if (this._wallet.type === 'custodial' && this._wallet.multisigType !== 'tss') { + params.type = type; + if (typeof params.prebuildTx === 'object' && params.prebuildTx) { + if (params.prebuildTx.stakingParams) { + params.stakingParams = params.prebuildTx.stakingParams; + } + if (params.prebuildTx.recipients) { + params.recipients = params.prebuildTx.recipients; + } + } + return this.initiateTransaction(params as TxSendBody, params.reqId); + } + + if (typeof params.prebuildTx === 'string' || params.prebuildTx === undefined) { + throw new Error('Invalid prebuild for resource management transaction.'); + } + + // TSS path (hot or custodial): signing service holds the key shares + if (this._wallet.multisigType === 'tss') { + if (!params.prebuildTx.txRequestId) { + throw new Error('Resource management request missing txRequestId for TSS wallet.'); + } + return await this.sendManyTxRequests(params); + } + + // Hot non-TSS: user holds the key, sign locally and submit + const signedPrebuild = (await this.prebuildAndSignTransaction(params)) as any; + delete signedPrebuild.wallet; + // Relay stakingParams from the prebuild so send.ts can populate it on the transfer document + if (typeof params.prebuildTx === 'object' && params.prebuildTx.stakingParams) { + signedPrebuild.stakingParams = params.prebuildTx.stakingParams; + } + return await this.submitTransaction(signedPrebuild, params.reqId); + } + + /** + * Shared build → sign → send loop for resource delegation and undelegation. + * Validates the wallet passphrase upfront, then sends each transaction individually. + * Returns { success, failure } so partial success is handled gracefully. + */ + private async sendResourceManagementTransactions( + type: 'delegateResource' | 'undelegateResource', + params: BuildResourceDelegationTransactionOptions | BuildResourceUndelegationTransactionOptions + ): Promise<{ success: ResourceManagementSendResult[]; failure: { message: string; receiverAddress?: string }[] }> { + if (!this.baseCoin.supportsResourceDelegation()) { + throw new Error(`${this.baseCoin.getFullName()} does not support resource delegation.`); + } + + const apiVersion = + params.apiVersion ?? + (this.tssUtils && this.tssUtils.supportedTxRequestVersions().includes('full') ? 'full' : undefined); + + // Validate passphrase upfront to fail fast before building N transactions + await this.getKeychainsAndValidatePassphrase({ + reqId: params.reqId, + walletPassphrase: params.walletPassphrase, + customSigningFunction: params.customSigningFunction, + }); + + const { prebuilds: unsignedBuilds, buildFailures } = await this.buildResourceManagementTransactions(type, { + ...params, + apiVersion, + }); + const successfulTxs: ResourceManagementSendResult[] = []; + const failedTxs: { message: string; receiverAddress?: string }[] = [...buildFailures]; + + const entries = + type === 'delegateResource' + ? (params as BuildResourceDelegationTransactionOptions).delegations + : (params as BuildResourceUndelegationTransactionOptions).undelegations; + + for (let i = 0; i < unsignedBuilds.length; i++) { + const unsignedBuild = unsignedBuilds[i]; + const receiverAddress = entries[i]?.receiverAddress; + const unsignedBuildWithOptions: PrebuildAndSignTransactionOptions = { + ...params, + apiVersion, + prebuildTx: unsignedBuild, + }; + try { + const sendTx = await this.sendResourceManagementTransaction(type, unsignedBuildWithOptions); + successfulTxs.push(sendTx); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + failedTxs.push({ message, receiverAddress }); + } + } + + return { success: successfulTxs, failure: failedTxs }; + } + + /** + * Builds a set of resource delegation transactions for the given delegation entries. + * Each entry delegates resources (e.g. ENERGY, BANDWIDTH) from this wallet's root address + * to a receiver address. Modelled after buildAccountConsolidations. + * + * @param params.delegations - Array of { receiverAddress, amount, resource } entries + * @returns Unsigned prebuild transaction results, one per delegation entry + */ + async buildResourceDelegations( + params: BuildResourceDelegationTransactionOptions + ): Promise { + return this.buildResourceManagementTransactions('delegateResource', params); + } + + /** + * Signs and sends a single resource delegation transaction. + * @param params.prebuildTx - A single prebuild result from buildResourceDelegations + */ + async sendResourceDelegation(params: PrebuildAndSignTransactionOptions = {}): Promise { + if (!this.baseCoin.supportsResourceDelegation()) { + throw new Error(`${this.baseCoin.getFullName()} does not support resource delegation.`); + } + return this.sendResourceManagementTransaction('delegateResource', params); + } + + /** + * Builds, signs, and sends all resource delegation transactions in a single call. + * @param params.delegations - Array of { receiverAddress, amount, resource } entries + */ + async sendResourceDelegations( + params: BuildResourceDelegationTransactionOptions + ): Promise<{ success: ResourceManagementSendResult[]; failure: { message: string; receiverAddress?: string }[] }> { + return this.sendResourceManagementTransactions('delegateResource', params); + } + + /** + * Builds a set of resource undelegation transactions for the given entries. + * Each entry reclaims delegated resources from this wallet's root address back from + * a receiver address. Modelled after buildResourceDelegations. + * + * @param params.undelegations - Array of { receiverAddress, amount, resource } entries + * @returns Unsigned prebuild transaction results, one per undelegation entry + */ + async buildResourceUndelegations( + params: BuildResourceUndelegationTransactionOptions + ): Promise { + return this.buildResourceManagementTransactions('undelegateResource', params); + } + + /** + * Signs and sends a single resource undelegation transaction. + * @param params.prebuildTx - A single prebuild result from buildResourceUndelegations + */ + async sendResourceUndelegation( + params: PrebuildAndSignTransactionOptions = {} + ): Promise { + if (!this.baseCoin.supportsResourceDelegation()) { + throw new Error(`${this.baseCoin.getFullName()} does not support resource delegation.`); + } + return this.sendResourceManagementTransaction('undelegateResource', params); + } + + /** + * Builds, signs, and sends all resource undelegation transactions in a single call. + * @param params.undelegations - Array of { receiverAddress, amount, resource } entries + */ + async sendResourceUndelegations( + params: BuildResourceUndelegationTransactionOptions + ): Promise<{ success: ResourceManagementSendResult[]; failure: { message: string; receiverAddress?: string }[] }> { + return this.sendResourceManagementTransactions('undelegateResource', params); + } + /** * Builds a set of transactions that enables the specified tokens * @param params - diff --git a/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts b/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts new file mode 100644 index 0000000000..01b2418ac3 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts @@ -0,0 +1,375 @@ +import * as assert from 'assert'; +import sinon from 'sinon'; +import 'should'; +import 'should-sinon'; +import { Wallet } from '../../../../src'; + +describe('Wallet - resource management', function () { + let wallet: Wallet; + let mockBitGo: any; + let mockBaseCoin: any; + let mockWalletData: any; + + const delegations = [ + { receiverAddress: 'TRecv1', amount: '1000', resource: 'ENERGY' }, + { receiverAddress: 'TRecv2', amount: '2000', resource: 'BANDWIDTH' }, + ]; + + const undelegations = [{ receiverAddress: 'TRecv1', amount: '1000', resource: 'ENERGY' }]; + + function stubPost(response: any) { + const resultStub = sinon.stub().resolves(response); + const sendStub = sinon.stub().returns({ result: resultStub }); + mockBitGo.post.returns({ send: sendStub }); + return { sendStub }; + } + + beforeEach(function () { + mockBitGo = { + post: sinon.stub(), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + url: sinon.stub().callsFake((path: string) => `/ttrx${path}`), + supportsResourceDelegation: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + getMPCAlgorithm: sinon.stub().returns('ecdsa'), + getFullName: sinon.stub().returns('Tron'), + postProcessPrebuild: sinon.stub().callsFake((prebuild: any) => Promise.resolve(prebuild)), + }; + + mockWalletData = { + id: 'test-wallet-id', + keys: ['user-key', 'backup-key', 'bitgo-key'], + type: 'hot', + multisigType: 'hot', + }; + + wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData); + }); + + afterEach(function () { + sinon.restore(); + }); + + // --------------------------------------------------------------------------- + // buildResourceDelegations + // --------------------------------------------------------------------------- + describe('buildResourceDelegations', function () { + it('should throw if coin does not support resource delegation', async function () { + mockBaseCoin.supportsResourceDelegation.returns(false); + await (wallet.buildResourceDelegations({ delegations }) as any).should.be.rejectedWith( + 'Tron does not support resource delegation.' + ); + }); + + it('should throw if delegations array is empty', async function () { + await (wallet.buildResourceDelegations({ delegations: [] }) as any).should.be.rejectedWith( + 'delegations must be a non-empty array.' + ); + }); + + it('should POST to /delegateResources/build and return prebuilds with walletId', async function () { + stubPost({ transactions: [{ txHex: 'aaa' }, { txHex: 'bbb' }], errors: [] }); + + const result = await wallet.buildResourceDelegations({ delegations }); + + result.prebuilds.should.have.length(2); + result.buildFailures.should.have.length(0); + result.prebuilds[0].walletId.should.equal('test-wallet-id'); + result.prebuilds[1].walletId.should.equal('test-wallet-id'); + sinon.assert.calledOnce(mockBitGo.post); + (mockBitGo.post.firstCall.args[0] as string).should.containEql('/delegateResources/build'); + }); + + it('should return buildFailures when API returns errors', async function () { + stubPost({ + transactions: [{ txHex: 'aaa' }], + errors: [{ error: 'insufficient balance', receiverAddress: 'TRecv2' }], + }); + + const result = await wallet.buildResourceDelegations({ delegations }); + + result.prebuilds.should.have.length(1); + result.buildFailures.should.have.length(1); + result.buildFailures[0].should.deepEqual({ message: 'insufficient balance', receiverAddress: 'TRecv2' }); + }); + + it('should throw if API response is missing transactions array', async function () { + stubPost({ errors: [] }); + + await (wallet.buildResourceDelegations({ delegations }) as any).should.be.rejectedWith( + 'Unexpected response from /delegateResources/build: missing transactions array' + ); + }); + + it('should only send whitelisted params (delegations, apiVersion) to the API', async function () { + const { sendStub } = stubPost({ transactions: [{ txHex: 'aaa' }], errors: [] }); + + await wallet.buildResourceDelegations({ delegations, walletPassphrase: 'secret' }); + + const bodyArg = sendStub.firstCall.args[0]; + bodyArg.should.have.property('delegations'); + bodyArg.should.not.have.property('walletPassphrase'); + }); + }); + + // --------------------------------------------------------------------------- + // buildResourceUndelegations + // --------------------------------------------------------------------------- + describe('buildResourceUndelegations', function () { + it('should throw if undelegations array is empty', async function () { + await (wallet.buildResourceUndelegations({ undelegations: [] }) as any).should.be.rejectedWith( + 'undelegations must be a non-empty array.' + ); + }); + + it('should POST to /undelegateResources/build and return prebuilds', async function () { + stubPost({ transactions: [{ txHex: 'ccc' }], errors: [] }); + + const result = await wallet.buildResourceUndelegations({ undelegations }); + + result.prebuilds.should.have.length(1); + result.buildFailures.should.have.length(0); + sinon.assert.calledOnce(mockBitGo.post); + (mockBitGo.post.firstCall.args[0] as string).should.containEql('/undelegateResources/build'); + }); + + it('should return buildFailures when API returns errors for undelegations', async function () { + stubPost({ + transactions: [], + errors: [{ error: 'lock period active', receiverAddress: 'TRecv1' }], + }); + + const result = await wallet.buildResourceUndelegations({ undelegations }); + + result.prebuilds.should.have.length(0); + result.buildFailures.should.have.length(1); + result.buildFailures[0].should.deepEqual({ message: 'lock period active', receiverAddress: 'TRecv1' }); + }); + }); + + // --------------------------------------------------------------------------- + // sendResourceDelegation (single) + // --------------------------------------------------------------------------- + describe('sendResourceDelegation', function () { + it('should throw if coin does not support resource delegation', async function () { + mockBaseCoin.supportsResourceDelegation.returns(false); + await (wallet.sendResourceDelegation({ prebuildTx: { txHex: 'aaa' } as any }) as any).should.be.rejectedWith( + 'Tron does not support resource delegation.' + ); + }); + + it('should throw if prebuildTx is undefined for a non-custodial wallet', async function () { + await (wallet.sendResourceDelegation({}) as any).should.be.rejectedWith( + 'Invalid prebuild for resource management transaction.' + ); + }); + + it('should call sendManyTxRequests for a TSS wallet', async function () { + mockWalletData.multisigType = 'tss'; + wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData); + const sendManyStub = sinon.stub(wallet as any, 'sendManyTxRequests').resolves({ txid: 'tss-tx-1' }); + + const result = await wallet.sendResourceDelegation({ prebuildTx: { txRequestId: 'req-1' } as any }); + + result.should.deepEqual({ txid: 'tss-tx-1' }); + sendManyStub.should.be.calledOnce(); + }); + + it('should call prebuildAndSignTransaction + submitTransaction for a hot wallet', async function () { + const signedPrebuild = { txHex: 'signed-aaa', wallet }; + sinon.stub(wallet, 'prebuildAndSignTransaction').resolves(signedPrebuild as any); + const submitStub = sinon.stub(wallet, 'submitTransaction').resolves({ txid: 'hot-tx-1' } as any); + + const result = await wallet.sendResourceDelegation({ prebuildTx: { txHex: 'aaa' } as any }); + + result.should.deepEqual({ txid: 'hot-tx-1' }); + submitStub.should.be.calledOnce(); + }); + + it('should call initiateTransaction for a custodial non-TSS wallet with stakingParams and recipients promoted from prebuildTx', async function () { + mockWalletData.type = 'custodial'; + mockWalletData.multisigType = 'multisig'; + wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData); + const initiateStub = sinon.stub(wallet as any, 'initiateTransaction').resolves({ txid: 'custodial-tx-1' }); + + const stakingParams = { + actionType: 'delegateResource', + owner_address: 'TOwner', + receiver_address: 'TReceiver', + receiverAddress: 'TReceiver', + amount: '1000000000', + resource: 'ENERGY', + }; + const recipients = [{ address: 'TReceiver', amount: '0' }]; + + const result = await wallet.sendResourceDelegation({ + prebuildTx: { txHex: 'aaa', stakingParams, recipients } as any, + }); + + result.should.deepEqual({ txid: 'custodial-tx-1' }); + initiateStub.should.be.calledOnce(); + const calledWith = initiateStub.firstCall.args[0]; + calledWith.should.containEql({ type: 'delegateResource', stakingParams, recipients }); + }); + }); + + // --------------------------------------------------------------------------- + // sendResourceDelegations (bulk) + // --------------------------------------------------------------------------- + describe('sendResourceDelegations', function () { + beforeEach(function () { + sinon.stub(wallet as any, 'getKeychainsAndValidatePassphrase').resolves(); + }); + + it('should throw if coin does not support resource delegation', async function () { + mockBaseCoin.supportsResourceDelegation.returns(false); + await (wallet.sendResourceDelegations({ delegations }) as any).should.be.rejectedWith( + 'Tron does not support resource delegation.' + ); + }); + + it('should propagate build failures with receiverAddress into the failure array', async function () { + sinon.stub(wallet as any, 'buildResourceManagementTransactions').resolves({ + prebuilds: [], + buildFailures: [{ message: 'build error', receiverAddress: 'TRecv2' }], + }); + + const result = await wallet.sendResourceDelegations({ delegations }); + + result.success.should.have.length(0); + result.failure.should.have.length(1); + result.failure[0].should.deepEqual({ message: 'build error', receiverAddress: 'TRecv2' }); + }); + + it('should return all successes when no failures', async function () { + sinon.stub(wallet as any, 'buildResourceManagementTransactions').resolves({ + prebuilds: [{ txHex: 'aaa' }, { txHex: 'bbb' }], + buildFailures: [], + }); + sinon.stub(wallet as any, 'sendResourceManagementTransaction').resolves({ txid: 'tx-ok' }); + + const result = await wallet.sendResourceDelegations({ delegations }); + + result.success.should.have.length(2); + result.failure.should.have.length(0); + }); + + it('should capture send failures with the correct receiverAddress from input entries', async function () { + sinon.stub(wallet as any, 'buildResourceManagementTransactions').resolves({ + prebuilds: [{ txHex: 'aaa' }, { txHex: 'bbb' }], + buildFailures: [], + }); + const sendStub = sinon.stub(wallet as any, 'sendResourceManagementTransaction'); + sendStub.onFirstCall().resolves({ txid: 'tx-1' }); + sendStub.onSecondCall().rejects(new Error('send failed')); + + const result = await wallet.sendResourceDelegations({ delegations }); + + result.success.should.have.length(1); + result.failure.should.have.length(1); + result.failure[0].message.should.equal('send failed'); + result.failure[0].should.have.property('receiverAddress', 'TRecv2'); + }); + + it('should combine build failures and send failures in the failure array', async function () { + sinon.stub(wallet as any, 'buildResourceManagementTransactions').resolves({ + prebuilds: [{ txHex: 'aaa' }], + buildFailures: [{ message: 'build error', receiverAddress: 'TRecv2' }], + }); + sinon.stub(wallet as any, 'sendResourceManagementTransaction').rejects(new Error('send failed')); + + const result = await wallet.sendResourceDelegations({ delegations }); + + result.success.should.have.length(0); + result.failure.should.have.length(2); + assert.ok(result.failure.find((f) => f.receiverAddress === 'TRecv2' && f.message === 'build error')); + assert.ok(result.failure.find((f) => f.receiverAddress === 'TRecv1' && f.message === 'send failed')); + }); + }); + + // --------------------------------------------------------------------------- + // sendResourceUndelegation (single) + // --------------------------------------------------------------------------- + describe('sendResourceUndelegation', function () { + it('should throw if coin does not support resource delegation', async function () { + mockBaseCoin.supportsResourceDelegation.returns(false); + await (wallet.sendResourceUndelegation({ prebuildTx: { txHex: 'aaa' } as any }) as any).should.be.rejectedWith( + 'Tron does not support resource delegation.' + ); + }); + + it('should call sendManyTxRequests for a TSS wallet', async function () { + mockWalletData.multisigType = 'tss'; + wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData); + const sendManyStub = sinon.stub(wallet as any, 'sendManyTxRequests').resolves({ txid: 'tss-undel-1' }); + + const result = await wallet.sendResourceUndelegation({ prebuildTx: { txRequestId: 'req-2' } as any }); + + result.should.deepEqual({ txid: 'tss-undel-1' }); + sendManyStub.should.be.calledOnce(); + }); + + it('should call prebuildAndSignTransaction + submitTransaction for a hot wallet', async function () { + const signedPrebuild = { txHex: 'signed-ccc', wallet }; + sinon.stub(wallet, 'prebuildAndSignTransaction').resolves(signedPrebuild as any); + const submitStub = sinon.stub(wallet, 'submitTransaction').resolves({ txid: 'hot-undel-1' } as any); + + const result = await wallet.sendResourceUndelegation({ prebuildTx: { txHex: 'ccc' } as any }); + + result.should.deepEqual({ txid: 'hot-undel-1' }); + submitStub.should.be.calledOnce(); + }); + }); + + // --------------------------------------------------------------------------- + // sendResourceUndelegations (bulk) + // --------------------------------------------------------------------------- + describe('sendResourceUndelegations', function () { + beforeEach(function () { + sinon.stub(wallet as any, 'getKeychainsAndValidatePassphrase').resolves(); + }); + + it('should return all successes for undelegations', async function () { + sinon.stub(wallet as any, 'buildResourceManagementTransactions').resolves({ + prebuilds: [{ txHex: 'ccc' }], + buildFailures: [], + }); + sinon.stub(wallet as any, 'sendResourceManagementTransaction').resolves({ txid: 'undel-tx-1' }); + + const result = await wallet.sendResourceUndelegations({ undelegations }); + + result.success.should.have.length(1); + result.failure.should.have.length(0); + }); + + it('should capture send failures with correct receiverAddress for undelegations', async function () { + sinon.stub(wallet as any, 'buildResourceManagementTransactions').resolves({ + prebuilds: [{ txHex: 'ccc' }], + buildFailures: [], + }); + sinon.stub(wallet as any, 'sendResourceManagementTransaction').rejects(new Error('undel send failed')); + + const result = await wallet.sendResourceUndelegations({ undelegations }); + + result.failure.should.have.length(1); + result.failure[0].message.should.equal('undel send failed'); + result.failure[0].should.have.property('receiverAddress', 'TRecv1'); + }); + + it('should propagate undelegation build failures into the failure array', async function () { + sinon.stub(wallet as any, 'buildResourceManagementTransactions').resolves({ + prebuilds: [], + buildFailures: [{ message: 'lock period active', receiverAddress: 'TRecv1' }], + }); + + const result = await wallet.sendResourceUndelegations({ undelegations }); + + result.success.should.have.length(0); + result.failure.should.have.length(1); + result.failure[0].should.deepEqual({ message: 'lock period active', receiverAddress: 'TRecv1' }); + }); + }); +});