Skip to content

Commit 18f9f86

Browse files
authored
Merge pull request #8572 from BitGo/chalo-287-bulk-trx-resource-delegation-sdk
feat(sdk-core): bulk TRX resource delegation SDK + Express layer
2 parents aab8d0d + 0819ce1 commit 18f9f86

12 files changed

Lines changed: 1272 additions & 0 deletions

File tree

CODEOWNERS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,19 @@
121121
/modules/blake2b/ @BitGo/wallet-core @BitGo/wallet-core-india
122122
/modules/express/ @BitGo/wallet-core @BitGo/wallet-core-india
123123
/modules/express/src/lightning/ @BitGo/btc-team
124+
/modules/express/src/typedRoutes/api/v2/delegateResources.ts @BitGo/ethalt-team
125+
/modules/express/src/typedRoutes/api/v2/undelegateResources.ts @BitGo/ethalt-team
124126
/modules/express/test/unit/lightning/ @BitGo/btc-team
125127
/modules/express/test/unit/clientRoutes/lightning/ @BitGo/btc-team
128+
/modules/express/test/unit/clientRoutes/bulkResourceManagement.ts @BitGo/ethalt-team
126129
/modules/key-card/ @BitGo/wallet-core @BitGo/wallet-core-india
127130
/modules/sdk-api/ @BitGo/wallet-core @BitGo/wallet-core-india
128131
/modules/sdk-api/src/v1 @BitGo/btc-team
129132
/modules/sdk-api/test/unit/v1 @BitGo/btc-team
130133
/modules/sdk-core/ @BitGo/wallet-core @BitGo/wallet-core-india @BitGo/hsm @BitGo/coins
131134
/modules/sdk-core/src/bitgo/lightning/ @BitGo/btc-team
132135
/modules/sdk-core/test/unit/bitgo/lightning/ @BitGo/btc-team
136+
/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts @BitGo/ethalt-team
133137
/modules/sdk-lib-mpc/ @BitGo/wallet-core @BitGo/wallet-core-india @BitGo/hsm
134138
/modules/deser-lib/ @BitGo/wallet-core @BitGo/wallet-core-india @BitGo/hsm
135139
/modules/sdk-rpc-wrapper @BitGo/ethalt-team

modules/express/src/clientRoutes.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,90 @@ export async function handleV2ResourceDelegations(
10521052
});
10531053
}
10541054

1055+
/**
1056+
* Shared handler for bulk resource management (delegation / undelegation).
1057+
* Builds, signs, and sends one on-chain transaction per entry.
1058+
*/
1059+
async function handleV2ResourceManagement(
1060+
type: 'delegateResource' | 'undelegateResource',
1061+
req:
1062+
| ExpressApiRouteRequest<'express.v2.wallet.delegateresources', 'post'>
1063+
| ExpressApiRouteRequest<'express.v2.wallet.undelegateresources', 'post'>
1064+
) {
1065+
const bitgo = req.bitgo;
1066+
const coin = bitgo.coin(req.decoded.coin);
1067+
1068+
if (type === 'delegateResource') {
1069+
const decoded = (req as ExpressApiRouteRequest<'express.v2.wallet.delegateresources', 'post'>).decoded;
1070+
if (!Array.isArray(decoded.delegations) || decoded.delegations.length === 0) {
1071+
throw new Error('delegations must be a non-empty array');
1072+
}
1073+
} else {
1074+
const decoded = (req as ExpressApiRouteRequest<'express.v2.wallet.undelegateresources', 'post'>).decoded;
1075+
if (!Array.isArray(decoded.undelegations) || decoded.undelegations.length === 0) {
1076+
throw new Error('undelegations must be a non-empty array');
1077+
}
1078+
}
1079+
1080+
if (!coin.supportsResourceDelegation()) {
1081+
throw new Error(`${coin.getFamily()} does not support resource delegation`);
1082+
}
1083+
1084+
const wallet = await coin.wallets().get({ id: req.decoded.id });
1085+
1086+
let result: any;
1087+
try {
1088+
const params = wallet._wallet.multisigType === 'tss' ? createTSSSendParams(req, wallet) : createSendParams(req);
1089+
result =
1090+
type === 'delegateResource'
1091+
? await wallet.sendResourceDelegations(params)
1092+
: await wallet.sendResourceUndelegations(params);
1093+
} catch (err) {
1094+
// Surface unexpected errors as 400 rather than 500
1095+
err.status = 400;
1096+
throw err;
1097+
}
1098+
1099+
// Handle partial success / failure
1100+
if (result.failure.length > 0) {
1101+
let msg = '';
1102+
let status = 202;
1103+
1104+
if (result.success.length > 0) {
1105+
msg = `Transactions failed: ${result.failure.length} and succeeded: ${result.success.length}`;
1106+
} else {
1107+
status = 400;
1108+
msg = `All transactions failed`;
1109+
}
1110+
1111+
throw apiResponse(status, result, msg);
1112+
}
1113+
1114+
return result;
1115+
}
1116+
1117+
/**
1118+
* Handle bulk resource delegation (e.g. TRX ENERGY/BANDWIDTH delegation).
1119+
* Builds, signs, and sends one on-chain delegation transaction per entry in req.body.delegations.
1120+
* @param req
1121+
*/
1122+
export async function handleV2DelegateResources(
1123+
req: ExpressApiRouteRequest<'express.v2.wallet.delegateresources', 'post'>
1124+
) {
1125+
return handleV2ResourceManagement('delegateResource', req);
1126+
}
1127+
1128+
/**
1129+
* Handle bulk resource undelegation (e.g. TRX ENERGY/BANDWIDTH undelegation).
1130+
* Builds, signs, and sends one on-chain undelegation transaction per entry in req.body.undelegations.
1131+
* @param req
1132+
*/
1133+
export async function handleV2UndelegateResources(
1134+
req: ExpressApiRouteRequest<'express.v2.wallet.undelegateresources', 'post'>
1135+
) {
1136+
return handleV2ResourceManagement('undelegateResource', req);
1137+
}
1138+
10551139
/**
10561140
* payload meant for prebuildAndSignTransaction() in sdk-core which
10571141
* validates the payload and makes the appropriate request to WP to
@@ -1836,6 +1920,14 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
18361920
prepareBitGo(config),
18371921
typedPromiseWrapper(handleV2ResourceDelegations),
18381922
]);
1923+
router.post('express.v2.wallet.delegateresources', [
1924+
prepareBitGo(config),
1925+
typedPromiseWrapper(handleV2DelegateResources),
1926+
]);
1927+
router.post('express.v2.wallet.undelegateresources', [
1928+
prepareBitGo(config),
1929+
typedPromiseWrapper(handleV2UndelegateResources),
1930+
]);
18391931

18401932
// Miscellaneous
18411933
router.post('express.canonicaladdress', [prepareBitGo(config), typedPromiseWrapper(handleCanonicalAddress)]);

modules/express/src/typedRoutes/api/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ import { PostWalletAccelerateTx } from './v2/walletAccelerateTx';
6060
import { PostIsWalletAddress } from './v2/isWalletAddress';
6161
import { GetAccountResources } from './v2/accountResources';
6262
import { GetResourceDelegations } from './v2/resourceDelegations';
63+
import { PostDelegateResources } from './v2/delegateResources';
64+
import { PostUndelegateResources } from './v2/undelegateResources';
6365

6466
// Too large types can cause the following error
6567
//
@@ -200,6 +202,18 @@ export const ExpressV2WalletConsolidateAccountApiSpec = apiSpec({
200202
},
201203
});
202204

205+
export const ExpressV2WalletDelegateResourcesApiSpec = apiSpec({
206+
'express.v2.wallet.delegateresources': {
207+
post: PostDelegateResources,
208+
},
209+
});
210+
211+
export const ExpressV2WalletUndelegateResourcesApiSpec = apiSpec({
212+
'express.v2.wallet.undelegateresources': {
213+
post: PostUndelegateResources,
214+
},
215+
});
216+
203217
export const ExpressWalletFanoutUnspentsApiSpec = apiSpec({
204218
'express.v1.wallet.fanoutunspents': {
205219
put: PutFanoutUnspents,
@@ -405,6 +419,8 @@ export type ExpressApi = typeof ExpressPingApiSpec &
405419
typeof ExpressV2WalletAccelerateTxApiSpec &
406420
typeof ExpressV2WalletAccountResourcesApiSpec &
407421
typeof ExpressV2WalletResourceDelegationsApiSpec &
422+
typeof ExpressV2WalletDelegateResourcesApiSpec &
423+
typeof ExpressV2WalletUndelegateResourcesApiSpec &
408424
typeof ExpressWalletManagementApiSpec;
409425

410426
export const ExpressApi: ExpressApi = {
@@ -448,6 +464,8 @@ export const ExpressApi: ExpressApi = {
448464
...ExpressV2WalletAccelerateTxApiSpec,
449465
...ExpressV2WalletAccountResourcesApiSpec,
450466
...ExpressV2WalletResourceDelegationsApiSpec,
467+
...ExpressV2WalletDelegateResourcesApiSpec,
468+
...ExpressV2WalletUndelegateResourcesApiSpec,
451469
...ExpressWalletManagementApiSpec,
452470
};
453471

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import * as t from 'io-ts';
2+
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
3+
import { BitgoExpressError } from '../../schemas/error';
4+
5+
/**
6+
* Path parameters for the delegate resources endpoint
7+
*/
8+
export const DelegateResourcesParams = {
9+
/** Coin identifier (e.g., 'trx', 'ttrx') */
10+
coin: t.string,
11+
/** Wallet ID */
12+
id: t.string,
13+
} as const;
14+
15+
/**
16+
* A single resource delegation entry
17+
*/
18+
export const DelegationEntryCodec = t.type({
19+
/** On-chain address that will receive the delegated resources */
20+
receiverAddress: t.string,
21+
/** Amount of TRX (in SUN) to stake for the delegation */
22+
amount: t.string,
23+
/** Resource type to delegate (e.g. 'ENERGY', 'BANDWIDTH') */
24+
resource: t.string,
25+
});
26+
27+
/**
28+
* Request body for delegating resources to multiple receiver addresses.
29+
* Each delegation entry triggers a separate on-chain staking transaction
30+
* from the wallet's root address to the receiver address.
31+
*
32+
* Signing behaviour by wallet type:
33+
* - Hot (non-TSS) → signed locally with walletPassphrase and submitted
34+
* - Custodial non-TSS → sent for BitGo approval via initiateTransaction
35+
* - TSS (any) → build response contains txRequestId; signed by TSS service
36+
*/
37+
export const DelegateResourcesRequestBody = {
38+
/** Delegation entries — one on-chain transaction is built per entry */
39+
delegations: t.array(DelegationEntryCodec),
40+
41+
/** Wallet passphrase to decrypt the user key (hot wallets) */
42+
walletPassphrase: optional(t.string),
43+
/** Extended private key (alternative to walletPassphrase) */
44+
xprv: optional(t.string),
45+
/** One-time password for 2FA */
46+
otp: optional(t.string),
47+
48+
/** API version for TSS transaction request response ('lite' or 'full') */
49+
apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])),
50+
} as const;
51+
52+
export const DelegationFailureEntry = t.type({
53+
/** Human-readable error message */
54+
message: t.string,
55+
/** Receiver address that failed, if available */
56+
receiverAddress: optional(t.string),
57+
});
58+
59+
/**
60+
* Response for the delegate resources operation.
61+
* Returns arrays of successful and failed delegation transactions.
62+
*/
63+
export const DelegateResourcesResponse = t.type({
64+
/** Successfully sent delegation transactions */
65+
success: t.array(t.unknown),
66+
/** Errors from failed delegation transactions */
67+
failure: t.array(DelegationFailureEntry),
68+
});
69+
70+
/**
71+
* Response for partial success or failure cases (202/400).
72+
* Includes both the transaction results and error metadata.
73+
*/
74+
export const DelegateResourcesErrorResponse = t.intersection([DelegateResourcesResponse, BitgoExpressError]);
75+
76+
/**
77+
* Bulk Resource Delegation
78+
*
79+
* Delegates resources (ENERGY or BANDWIDTH) from a wallet's root address to one or more
80+
* receiver addresses. Each delegation entry produces a separate on-chain staking transaction.
81+
* This is the resource-delegation analogue of the consolidateAccount endpoint.
82+
*
83+
* Supported coins: TRON (trx, ttrx) and any future coins that support resource delegation.
84+
*
85+
* The API may return partial success (status 202) if some delegations succeed but others fail.
86+
*
87+
* @operationId express.v2.wallet.delegateresources
88+
* @tag express
89+
*/
90+
export const PostDelegateResources = httpRoute({
91+
path: '/api/v2/{coin}/wallet/{id}/delegateResources',
92+
method: 'POST',
93+
request: httpRequest({
94+
params: DelegateResourcesParams,
95+
body: DelegateResourcesRequestBody,
96+
}),
97+
response: {
98+
/** All delegations succeeded */
99+
200: DelegateResourcesResponse,
100+
/** Partial success — some delegations succeeded, others failed */
101+
202: DelegateResourcesErrorResponse,
102+
/** All delegations failed */
103+
400: DelegateResourcesErrorResponse,
104+
},
105+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as t from 'io-ts';
2+
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
3+
import { BitgoExpressError } from '../../schemas/error';
4+
import { DelegateResourcesParams, DelegationEntryCodec, DelegationFailureEntry } from './delegateResources';
5+
6+
/**
7+
* Request body for undelegating resources from multiple receiver addresses.
8+
* Each undelegation entry triggers a separate on-chain transaction that reclaims
9+
* previously delegated resources back to the wallet's root address.
10+
*
11+
* Signing behaviour by wallet type:
12+
* - Hot (non-TSS) → signed locally with walletPassphrase and submitted
13+
* - Custodial non-TSS → sent for BitGo approval via initiateTransaction
14+
* - TSS (any) → build response contains txRequestId; signed by TSS service
15+
*/
16+
export const UndelegateResourcesRequestBody = {
17+
/** Undelegation entries — one on-chain transaction is built per entry */
18+
undelegations: t.array(DelegationEntryCodec),
19+
20+
/** Wallet passphrase to decrypt the user key (hot wallets) */
21+
walletPassphrase: optional(t.string),
22+
/** Extended private key (alternative to walletPassphrase) */
23+
xprv: optional(t.string),
24+
/** One-time password for 2FA */
25+
otp: optional(t.string),
26+
27+
/** API version for TSS transaction request response ('lite' or 'full') */
28+
apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])),
29+
} as const;
30+
31+
/**
32+
* Response for the undelegate resources operation.
33+
* Returns arrays of successful and failed undelegation transactions.
34+
*/
35+
export const UndelegateResourcesResponse = t.type({
36+
/** Successfully sent undelegation transactions */
37+
success: t.array(t.unknown),
38+
/** Errors from failed undelegation transactions */
39+
failure: t.array(DelegationFailureEntry),
40+
});
41+
42+
/**
43+
* Response for partial success or failure cases (202/400).
44+
*/
45+
export const UndelegateResourcesErrorResponse = t.intersection([UndelegateResourcesResponse, BitgoExpressError]);
46+
47+
/**
48+
* Bulk Resource Undelegation
49+
*
50+
* Reclaims delegated resources (ENERGY or BANDWIDTH) back to a wallet's root address
51+
* from one or more receiver addresses. Each entry produces a separate on-chain transaction.
52+
*
53+
* Supported coins: TRON (trx, ttrx) and any future coins that support resource delegation.
54+
*
55+
* The API may return partial success (status 202) if some undelegations succeed but others fail.
56+
*
57+
* @operationId express.v2.wallet.undelegateresources
58+
* @tag express
59+
*/
60+
export const PostUndelegateResources = httpRoute({
61+
path: '/api/v2/{coin}/wallet/{id}/undelegateResources',
62+
method: 'POST',
63+
request: httpRequest({
64+
params: DelegateResourcesParams,
65+
body: UndelegateResourcesRequestBody,
66+
}),
67+
response: {
68+
/** All undelegations succeeded */
69+
200: UndelegateResourcesResponse,
70+
/** Partial success — some undelegations succeeded, others failed */
71+
202: UndelegateResourcesErrorResponse,
72+
/** All undelegations failed */
73+
400: UndelegateResourcesErrorResponse,
74+
},
75+
});

0 commit comments

Comments
 (0)