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' }); + }); + }); +});