Skip to content

Commit 88064e8

Browse files
authored
Merge pull request #9013 from BitGo/rajangarg047/wcn-914-express-derive-address-endpoint
feat(express): add POST /api/v2/:coin/address/derive endpoint
2 parents 7f4bec1 + 3f286af commit 88064e8

4 files changed

Lines changed: 392 additions & 0 deletions

File tree

modules/express/src/clientRoutes.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,20 @@ export async function handleV2IsWalletAddress(
721721
return await wallet.baseCoin.isWalletAddress(req.decoded as any);
722722
}
723723

724+
/**
725+
* handle v2 deriveAddress - locally derive and return a wallet receive address from a
726+
* derivation path, using public key material only.
727+
*
728+
* Offline by design: operates purely on the request body (keychains + chain/index), with no
729+
* `wallets().get` lookup and no network access. The inverse of {@link handleV2IsWalletAddress}.
730+
* @param req
731+
*/
732+
export async function handleV2DeriveAddress(req: ExpressApiRouteRequest<'express.v2.address.derive', 'post'>) {
733+
const bitgo = req.bitgo;
734+
const coin = bitgo.coin(req.decoded.coin);
735+
return await coin.deriveAddress(req.decoded as any);
736+
}
737+
724738
/**
725739
* handle v2 approve transaction
726740
* @param req
@@ -1963,6 +1977,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
19631977
prepareBitGo(config),
19641978
typedPromiseWrapper(handleV2IsWalletAddress),
19651979
]);
1980+
router.post('express.v2.address.derive', [prepareBitGo(config), typedPromiseWrapper(handleV2DeriveAddress)]);
19661981

19671982
router.post('express.wallet.share', [prepareBitGo(config), typedPromiseWrapper(handleV2ShareWallet)]);
19681983
app.post(

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { PostWalletEnableTokens } from './v2/walletEnableTokens';
5858
import { PostWalletSweep } from './v2/walletSweep';
5959
import { PostWalletAccelerateTx } from './v2/walletAccelerateTx';
6060
import { PostIsWalletAddress } from './v2/isWalletAddress';
61+
import { PostDeriveAddress } from './v2/deriveAddress';
6162
import { GetAccountResources } from './v2/accountResources';
6263
import { GetResourceDelegations } from './v2/resourceDelegations';
6364
import { PostDelegateResources } from './v2/delegateResources';
@@ -235,6 +236,12 @@ export const ExpressV2WalletIsWalletAddressApiSpec = apiSpec({
235236
},
236237
});
237238

239+
export const ExpressV2AddressDeriveApiSpec = apiSpec({
240+
'express.v2.address.derive': {
241+
post: PostDeriveAddress,
242+
},
243+
});
244+
238245
export const ExpressV2WalletSendManyApiSpec = apiSpec({
239246
'express.wallet.sendmany': {
240247
post: PostSendMany,
@@ -399,6 +406,7 @@ export type ExpressApi = typeof ExpressPingApiSpec &
399406
typeof ExpressWalletFanoutUnspentsApiSpec &
400407
typeof ExpressV2WalletCreateAddressApiSpec &
401408
typeof ExpressV2WalletIsWalletAddressApiSpec &
409+
typeof ExpressV2AddressDeriveApiSpec &
402410
typeof ExpressKeychainLocalApiSpec &
403411
typeof ExpressKeychainChangePasswordApiSpec &
404412
typeof ExpressLightningWalletPaymentApiSpec &
@@ -444,6 +452,7 @@ export const ExpressApi: ExpressApi = {
444452
...ExpressV2WalletCreateAddressApiSpec,
445453
...ExpressV2WalletConsolidateAccountApiSpec,
446454
...ExpressV2WalletIsWalletAddressApiSpec,
455+
...ExpressV2AddressDeriveApiSpec,
447456
...ExpressKeychainLocalApiSpec,
448457
...ExpressKeychainChangePasswordApiSpec,
449458
...ExpressLightningWalletPaymentApiSpec,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 { CreateAddressFormat } from '../../schemas/address';
5+
6+
/**
7+
* Path parameters for locally deriving a wallet address
8+
*/
9+
export const DeriveAddressParams = {
10+
/** Blockchain identifier (e.g., 'btc', 'eth', 'tbtc', 'teth', 'sol') */
11+
coin: t.string,
12+
} as const;
13+
14+
/**
15+
* A keychain entry for local derivation. Public key material only — no private keys.
16+
* Modelled as a union so a keychain must carry at least one of `pub` / `commonKeychain`:
17+
* - `pub` (xpub) for BIP32 multisig coins (UTXO, legacy EVM)
18+
* - `commonKeychain` for TSS/MPC coins (SOL, EVM MPC) — identical across keychains
19+
*
20+
* (A keychain may legitimately carry both; TSS keychains commonly do.)
21+
*/
22+
export const DeriveAddressKeychainCodec = t.union([t.type({ pub: t.string }), t.type({ commonKeychain: t.string })]);
23+
24+
/**
25+
* Request body for locally deriving a wallet receive address
26+
*/
27+
export const DeriveAddressBody = {
28+
/**
29+
* Keychains for derivation (public key material only).
30+
* BIP32 multisig: the user/backup/bitgo xpub triple via `pub`.
31+
* TSS/MPC: the `commonKeychain`.
32+
*/
33+
keychains: t.array(DeriveAddressKeychainCodec),
34+
/** Derivation index for the address (caller-supplied; the endpoint is stateless) */
35+
index: t.number,
36+
/** Derivation chain code: UTXO script-type / external(0) vs internal(1) selector */
37+
chain: optional(t.number),
38+
/** Address format override (e.g. 'p2sh', 'p2wsh' for UTXO; 'cashaddr' / 'base58') */
39+
format: optional(CreateAddressFormat),
40+
/** Wallet version, to disambiguate derivation strategy (e.g. EVM forwarder vs MPC) */
41+
walletVersion: optional(t.number),
42+
/**
43+
* Seed from the user keychain's derivedFromParentWithSeed field (SMC TSS wallets);
44+
* makes the derivation path `{prefix}/{index}` instead of `m/{index}`.
45+
*/
46+
derivedFromParentWithSeed: optional(t.string),
47+
} as const;
48+
49+
/**
50+
* Response for locally deriving a wallet address
51+
*/
52+
export const DeriveAddressResponse = {
53+
/** The derived address and related derivation info */
54+
200: t.intersection([
55+
t.type({
56+
/** The derived address */
57+
address: t.string,
58+
/** The derivation index used */
59+
index: t.number,
60+
}),
61+
t.partial({
62+
/** The derivation chain code used */
63+
chain: t.number,
64+
/** Coin-specific address data (e.g. redeemScript/witnessScript for UTXO) */
65+
coinSpecific: t.UnknownRecord,
66+
/** The HD derivation path actually used */
67+
derivationPath: t.string,
68+
}),
69+
]),
70+
/** Invalid request parameters or derivation failed */
71+
400: BitgoExpressError,
72+
} as const;
73+
74+
/**
75+
* Locally derive and return a wallet receive address from a derivation path.
76+
*
77+
* Unlike `iswalletaddress` (which checks a candidate address), this *produces* the address
78+
* offline from public key material only — the xpub triple for BIP32 multisig coins, or the
79+
* commonKeychain for TSS/MPC coins. No private keys, no wallet lookup, and no network access:
80+
* the handler operates purely on the request body and can run in an air-gapped Express.
81+
*
82+
* Pairs with `iswalletaddress` for a derive→verify round-trip: derive the address here, then
83+
* verify it against the same keychains to independently confirm correctness.
84+
*
85+
* @operationId express.v2.address.derive
86+
* @tag Express
87+
*/
88+
export const PostDeriveAddress = httpRoute({
89+
path: '/api/v2/{coin}/address/derive',
90+
method: 'POST',
91+
request: httpRequest({
92+
params: DeriveAddressParams,
93+
body: DeriveAddressBody,
94+
}),
95+
response: DeriveAddressResponse,
96+
});

0 commit comments

Comments
 (0)