Skip to content

Commit 802cc5d

Browse files
authored
feat(express): consolidateAccountV2 handler return type
2 parents 6c97938 + c503069 commit 802cc5d

File tree

5 files changed

+1126
-15
lines changed

5 files changed

+1126
-15
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -767,9 +767,11 @@ async function handleV2ConsolidateUnspents(
767767
*
768768
* @param req
769769
*/
770-
export async function handleV2ConsolidateAccount(req: express.Request) {
770+
export async function handleV2ConsolidateAccount(
771+
req: ExpressApiRouteRequest<'express.v2.wallet.consolidateaccount', 'post'>
772+
) {
771773
const bitgo = req.bitgo;
772-
const coin = bitgo.coin(req.params.coin);
774+
const coin = bitgo.coin(req.decoded.coin);
773775

774776
if (req.body.consolidateAddresses && !_.isArray(req.body.consolidateAddresses)) {
775777
throw new Error('consolidate address must be an array of addresses');
@@ -779,7 +781,7 @@ export async function handleV2ConsolidateAccount(req: express.Request) {
779781
throw new Error('invalid coin selected');
780782
}
781783

782-
const wallet = await coin.wallets().get({ id: req.params.id });
784+
const wallet = await coin.wallets().get({ id: req.decoded.id });
783785

784786
let result: any;
785787
try {
@@ -1676,12 +1678,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
16761678
);
16771679

16781680
// account-based
1679-
app.post(
1680-
'/api/v2/:coin/wallet/:id/consolidateAccount',
1681-
parseBody,
1681+
router.post('express.v2.wallet.consolidateaccount', [
16821682
prepareBitGo(config),
1683-
promiseWrapper(handleV2ConsolidateAccount)
1684-
);
1683+
typedPromiseWrapper(handleV2ConsolidateAccount),
1684+
]);
16851685

16861686
// Miscellaneous
16871687
app.post('/api/v2/:coin/canonicaladdress', parseBody, prepareBitGo(config), promiseWrapper(handleCanonicalAddress));

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload';
4444
import { PostLightningWalletPayment } from './v2/lightningPayment';
4545
import { PostLightningWalletWithdraw } from './v2/lightningWithdraw';
4646
import { PutV2PendingApproval } from './v2/pendingApproval';
47+
import { PostConsolidateAccount } from './v2/consolidateAccount';
4748

4849
// Too large types can cause the following error
4950
//
@@ -156,6 +157,12 @@ export const ExpressWalletConsolidateUnspentsApiSpec = apiSpec({
156157
},
157158
});
158159

160+
export const ExpressV2WalletConsolidateAccountApiSpec = apiSpec({
161+
'express.v2.wallet.consolidateaccount': {
162+
post: PostConsolidateAccount,
163+
},
164+
});
165+
159166
export const ExpressWalletFanoutUnspentsApiSpec = apiSpec({
160167
'express.v1.wallet.fanoutunspents': {
161168
put: PutFanoutUnspents,
@@ -292,6 +299,7 @@ export type ExpressApi = typeof ExpressPingApiSpec &
292299
typeof ExpressV1KeychainLocalApiSpec &
293300
typeof ExpressV1PendingApprovalConstructTxApiSpec &
294301
typeof ExpressWalletConsolidateUnspentsApiSpec &
302+
typeof ExpressV2WalletConsolidateAccountApiSpec &
295303
typeof ExpressWalletFanoutUnspentsApiSpec &
296304
typeof ExpressV2WalletCreateAddressApiSpec &
297305
typeof ExpressKeychainLocalApiSpec &
@@ -329,6 +337,7 @@ export const ExpressApi: ExpressApi = {
329337
...ExpressWalletConsolidateUnspentsApiSpec,
330338
...ExpressWalletFanoutUnspentsApiSpec,
331339
...ExpressV2WalletCreateAddressApiSpec,
340+
...ExpressV2WalletConsolidateAccountApiSpec,
332341
...ExpressKeychainLocalApiSpec,
333342
...ExpressKeychainChangePasswordApiSpec,
334343
...ExpressLightningWalletPaymentApiSpec,
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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 consolidate account endpoint
7+
*/
8+
export const ConsolidateAccountParams = {
9+
/** Coin identifier (e.g., 'algo', 'sol', 'xtz') */
10+
coin: t.string,
11+
/** Wallet ID */
12+
id: t.string,
13+
} as const;
14+
15+
/**
16+
* Request body for consolidating account balances
17+
* Based on BuildConsolidationTransactionOptions which extends:
18+
* - PrebuildTransactionOptions (iWallet.ts lines 90-221)
19+
* - WalletSignTransactionOptions (iWallet.ts lines 265-289)
20+
*/
21+
export const ConsolidateAccountRequestBody = {
22+
/** On-chain receive addresses to consolidate from (BuildConsolidationTransactionOptions) */
23+
consolidateAddresses: optional(t.array(t.string)),
24+
25+
/** Wallet passphrase to decrypt the user key */
26+
walletPassphrase: optional(t.string),
27+
/** Extended private key (alternative to walletPassphrase) */
28+
xprv: optional(t.string),
29+
/** One-time password for 2FA */
30+
otp: optional(t.string),
31+
32+
/** Transaction recipients */
33+
recipients: optional(
34+
t.array(
35+
t.type({
36+
address: t.string,
37+
amount: t.union([t.string, t.number]),
38+
})
39+
)
40+
),
41+
/** Estimate fees to aim for confirmation within this number of blocks */
42+
numBlocks: optional(t.number),
43+
/** Maximum fee rate limit */
44+
maxFeeRate: optional(t.number),
45+
/** Minimum number of confirmations needed */
46+
minConfirms: optional(t.number),
47+
/** If true, minConfirms also applies to change outputs */
48+
enforceMinConfirmsForChange: optional(t.boolean),
49+
/** Target number of unspents in wallet after consolidation */
50+
targetWalletUnspents: optional(t.number),
51+
/** Minimum value of balances to use (in base units) */
52+
minValue: optional(t.union([t.number, t.string])),
53+
/** Maximum value of balances to use (in base units) */
54+
maxValue: optional(t.union([t.number, t.string])),
55+
/** Sequence ID for transaction tracking */
56+
sequenceId: optional(t.string),
57+
/** Last ledger sequence (for Stellar/XRP) */
58+
lastLedgerSequence: optional(t.number),
59+
/** Ledger sequence delta (for Stellar/XRP) */
60+
ledgerSequenceDelta: optional(t.number),
61+
/** Gas price for Ethereum-like chains */
62+
gasPrice: optional(t.number),
63+
/** If true, does not split change output */
64+
noSplitChange: optional(t.boolean),
65+
/** Array of specific unspents to use in transaction */
66+
unspents: optional(t.array(t.string)),
67+
/** Receive address from which funds will be withdrawn (for ADA) */
68+
senderAddress: optional(t.string),
69+
/** Sender wallet ID when different from current wallet */
70+
senderWalletId: optional(t.string),
71+
/** Messages to attach to outputs */
72+
messages: optional(
73+
t.array(
74+
t.type({
75+
address: t.string,
76+
message: t.string,
77+
})
78+
)
79+
),
80+
/** Change address for the transaction */
81+
changeAddress: optional(t.string),
82+
/** Allow using external change address */
83+
allowExternalChangeAddress: optional(t.boolean),
84+
/** Transaction type */
85+
type: optional(t.string),
86+
/** Close remainder to this address (for Algorand) */
87+
closeRemainderTo: optional(t.string),
88+
/** Non-participation flag (for Algorand) */
89+
nonParticipation: optional(t.boolean),
90+
/** Valid from block number */
91+
validFromBlock: optional(t.number),
92+
/** Valid to block number */
93+
validToBlock: optional(t.number),
94+
/** If true, creates instant transaction */
95+
instant: optional(t.boolean),
96+
/** Transaction memo */
97+
memo: optional(t.intersection([t.type({ value: t.string }), t.partial({ type: t.string })])),
98+
/** Address type to use */
99+
addressType: optional(t.string),
100+
/** Change address type to use */
101+
changeAddressType: optional(t.string),
102+
/** If true, enables hop transaction */
103+
hop: optional(t.boolean),
104+
/** Unspent reservation details */
105+
reservation: optional(
106+
t.partial({
107+
expireTime: t.string,
108+
pendingApprovalId: t.string,
109+
})
110+
),
111+
/** If true, performs offline verification */
112+
offlineVerification: optional(t.boolean),
113+
/** Wallet contract address */
114+
walletContractAddress: optional(t.string),
115+
/** IDF signed timestamp */
116+
idfSignedTimestamp: optional(t.string),
117+
/** IDF user ID */
118+
idfUserId: optional(t.string),
119+
/** IDF version */
120+
idfVersion: optional(t.number),
121+
/** Comment to attach to the transaction */
122+
comment: optional(t.string),
123+
/** Token name for token operations */
124+
tokenName: optional(t.string),
125+
/** NFT collection ID */
126+
nftCollectionId: optional(t.string),
127+
/** NFT ID */
128+
nftId: optional(t.string),
129+
/** Tokens to enable */
130+
enableTokens: optional(t.array(t.intersection([t.type({ name: t.string }), t.partial({ address: t.string })]))),
131+
/** Nonce for account-based coins */
132+
nonce: optional(t.string),
133+
/** If true, previews the transaction without sending */
134+
preview: optional(t.boolean),
135+
/** EIP-1559 fee parameters for Ethereum */
136+
eip1559: optional(
137+
t.type({
138+
maxFeePerGas: t.string,
139+
maxPriorityFeePerGas: t.string,
140+
})
141+
),
142+
/** Gas limit for Ethereum-like chains */
143+
gasLimit: optional(t.number),
144+
/** Low fee transaction ID for RBF */
145+
lowFeeTxid: optional(t.string),
146+
/** Receive address for specific operations */
147+
receiveAddress: optional(t.string),
148+
/** If true, indicates TSS transaction */
149+
isTss: optional(t.boolean),
150+
/** Custodian transaction ID */
151+
custodianTransactionId: optional(t.string),
152+
/** API version ('lite' or 'full') */
153+
apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])),
154+
/** If false, sweep all funds including minimums */
155+
keepAlive: optional(t.boolean),
156+
/** Transaction format type */
157+
txFormat: optional(t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')])),
158+
/** Custom Solana instructions to include in the transaction */
159+
solInstructions: optional(
160+
t.array(
161+
t.type({
162+
programId: t.string,
163+
keys: t.array(
164+
t.type({
165+
pubkey: t.string,
166+
isSigner: t.boolean,
167+
isWritable: t.boolean,
168+
})
169+
),
170+
data: t.string,
171+
})
172+
)
173+
),
174+
/** Solana versioned transaction data for Address Lookup Tables */
175+
solVersionedTransactionData: optional(
176+
t.partial({
177+
versionedInstructions: t.array(
178+
t.type({
179+
programIdIndex: t.number,
180+
accountKeyIndexes: t.array(t.number),
181+
data: t.string,
182+
})
183+
),
184+
addressLookupTables: t.array(
185+
t.type({
186+
accountKey: t.string,
187+
writableIndexes: t.array(t.number),
188+
readonlyIndexes: t.array(t.number),
189+
})
190+
),
191+
staticAccountKeys: t.array(t.string),
192+
messageHeader: t.type({
193+
numRequiredSignatures: t.number,
194+
numReadonlySignedAccounts: t.number,
195+
numReadonlyUnsignedAccounts: t.number,
196+
}),
197+
recentBlockhash: t.string,
198+
})
199+
),
200+
/** Aptos custom transaction parameters for entry function calls */
201+
aptosCustomTransactionParams: optional(
202+
t.intersection([
203+
t.type({
204+
moduleName: t.string,
205+
functionName: t.string,
206+
}),
207+
t.partial({
208+
typeArguments: t.array(t.string),
209+
functionArguments: t.array(t.any),
210+
abi: t.any,
211+
}),
212+
])
213+
),
214+
/** Transaction request ID */
215+
txRequestId: optional(t.string),
216+
/** If true, marks as test transaction */
217+
isTestTransaction: optional(t.boolean),
218+
219+
/** Private key for signing (from WalletSignBaseOptions) */
220+
prv: optional(t.string),
221+
/** Array of public keys */
222+
pubs: optional(t.array(t.string)),
223+
/** Cosigner public key */
224+
cosignerPub: optional(t.string),
225+
/** If true, this is the last signature */
226+
isLastSignature: optional(t.boolean),
227+
228+
/** Transaction prebuild object (from WalletSignTransactionOptions) */
229+
txPrebuild: optional(t.any),
230+
/** Multisig type version */
231+
multisigTypeVersion: optional(t.literal('MPCv2')),
232+
/** Transaction verification parameters */
233+
verifyTxParams: optional(t.any),
234+
} as const;
235+
236+
/**
237+
* Response for consolidate account operation
238+
* Returns arrays of successful and failed consolidation transactions
239+
*/
240+
export const ConsolidateAccountResponse = t.type({
241+
/** Array of successfully sent consolidation transactions */
242+
success: t.array(t.unknown),
243+
/** Array of errors from failed consolidation transactions */
244+
failure: t.array(t.unknown),
245+
});
246+
247+
/**
248+
* Response for partial success or failure cases (202/400)
249+
* Includes both the transaction results and error metadata
250+
*/
251+
export const ConsolidateAccountErrorResponse = t.intersection([ConsolidateAccountResponse, BitgoExpressError]);
252+
253+
/**
254+
* Consolidate Account Balances
255+
*
256+
* This endpoint consolidates account balances by moving funds from receive addresses
257+
* to the root wallet address. This is useful for account-based coins where balances
258+
* are spread across multiple addresses and need to be consolidated for spending.
259+
*
260+
* Supported coins: Algorand (algo), Solana (sol), Tezos (xtz), Tron (trx), Stellar (xlm), etc.
261+
*
262+
* The API may return partial success (status 202) if some consolidations succeed but others fail.
263+
*
264+
* @operationId express.v2.wallet.consolidateaccount
265+
* @tag express
266+
*/
267+
export const PostConsolidateAccount = httpRoute({
268+
path: '/api/v2/{coin}/wallet/{id}/consolidateAccount',
269+
method: 'POST',
270+
request: httpRequest({
271+
params: ConsolidateAccountParams,
272+
body: ConsolidateAccountRequestBody,
273+
}),
274+
response: {
275+
/** Successfully consolidated accounts */
276+
200: ConsolidateAccountResponse,
277+
/** Partial success - some succeeded, others failed (includes error metadata) */
278+
202: ConsolidateAccountErrorResponse,
279+
/** All consolidations failed (includes error metadata) */
280+
400: ConsolidateAccountErrorResponse,
281+
},
282+
});

0 commit comments

Comments
 (0)