Skip to content

Commit 8b0b018

Browse files
feat(express): migrate shareWallet to typed routes
2 parents 40dae3b + f5c9898 commit 8b0b018

File tree

7 files changed

+544
-8
lines changed

7 files changed

+544
-8
lines changed

examples/ts/share-wallet.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ bitgo.register(coin, Tbtc.createInstance);
2222
const walletId = '';
2323

2424
// TODO: set BitGo account email of wallet share recipient
25-
const recipient = null;
25+
const recipient = "recipient_email";
2626

2727
// TODO: set share permissions as a comma-separated list
2828
// Valid permissions to choose from are: view, spend, manage, admin
2929
const perms = 'view';
3030

3131
// TODO: provide the passphrase for the wallet being shared
32-
const passphrase = null;
32+
const passphrase = "passhrase";
3333

3434
async function main() {
3535
const wallet = await bitgo.coin(coin).wallets().get({ id: walletId });

modules/express/src/clientRoutes.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -687,11 +687,11 @@ export function handleV2CreateLocalKeyChain(req: ExpressApiRouteRequest<'express
687687
* handle wallet share
688688
* @param req
689689
*/
690-
async function handleV2ShareWallet(req: express.Request) {
690+
export async function handleV2ShareWallet(req: ExpressApiRouteRequest<'express.v2.wallet.share', 'post'>) {
691691
const bitgo = req.bitgo;
692-
const coin = bitgo.coin(req.params.coin);
693-
const wallet = await coin.wallets().get({ id: req.params.id });
694-
return wallet.shareWallet(req.body);
692+
const coin = bitgo.coin(req.decoded.coin);
693+
const wallet = await coin.wallets().get({ id: req.decoded.id });
694+
return wallet.shareWallet(req.decoded);
695695
}
696696

697697
/**
@@ -1619,8 +1619,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
16191619

16201620
router.post('express.v2.wallet.createAddress', [prepareBitGo(config), typedPromiseWrapper(handleV2CreateAddress)]);
16211621

1622-
// share wallet
1623-
app.post('/api/v2/:coin/wallet/:id/share', parseBody, prepareBitGo(config), promiseWrapper(handleV2ShareWallet));
1622+
router.post('express.v2.wallet.share', [prepareBitGo(config), typedPromiseWrapper(handleV2ShareWallet)]);
16241623
app.post(
16251624
'/api/v2/:coin/walletshare/:id/acceptshare',
16261625
parseBody,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { PostWalletRecoverToken } from './v2/walletRecoverToken';
2929
import { PostCoinSignTx } from './v2/coinSignTx';
3030
import { PostWalletSignTx } from './v2/walletSignTx';
3131
import { PostWalletTxSignTSS } from './v2/walletTxSignTSS';
32+
import { PostShareWallet } from './v2/shareWallet';
3233

3334
export const ExpressApi = apiSpec({
3435
'express.ping': {
@@ -112,6 +113,9 @@ export const ExpressApi = apiSpec({
112113
'express.v2.wallet.signtxtss': {
113114
post: PostWalletTxSignTSS,
114115
},
116+
'express.v2.wallet.share': {
117+
post: PostShareWallet,
118+
},
115119
});
116120

117121
export type ExpressApi = typeof ExpressApi;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 { ShareState, ShareWalletKeychain } from '../../schemas/wallet';
5+
6+
/**
7+
* Path parameters for sharing a wallet
8+
*/
9+
export const ShareWalletParams = {
10+
/** Coin ticker / chain identifier */
11+
coin: t.string,
12+
/** Wallet ID */
13+
id: t.string,
14+
} as const;
15+
16+
/**
17+
* Request body for sharing a wallet
18+
*/
19+
export const ShareWalletBody = {
20+
/** Recipient email address */
21+
email: t.string,
22+
/** Permissions string, e.g., "view,spend" */
23+
permissions: t.string,
24+
/** Wallet passphrase used to derive shared key when needed */
25+
walletPassphrase: optional(t.string),
26+
/** Optional message to include with the share */
27+
message: optional(t.string),
28+
/** If true, allows sharing without a keychain */
29+
reshare: optional(t.boolean),
30+
/** If true, skips sharing the wallet keychain with the recipient */
31+
skipKeychain: optional(t.boolean),
32+
/** If true, suppresses email notification to the recipient */
33+
disableEmail: optional(t.boolean),
34+
} as const;
35+
36+
/**
37+
* Response for sharing a wallet
38+
*/
39+
export const ShareWalletResponse200 = t.intersection([
40+
t.type({
41+
/** Wallet share id */
42+
id: t.string,
43+
/** Coin of the wallet */
44+
coin: t.string,
45+
/** Wallet id */
46+
wallet: t.string,
47+
/** Id of the sharer */
48+
fromUser: t.string,
49+
/** Id of the recipient */
50+
toUser: t.string,
51+
/** Comma-separated list of privileges for wallet */
52+
permissions: t.string,
53+
}),
54+
t.partial({
55+
/** Wallet label */
56+
walletLabel: t.string,
57+
/** User-readable message */
58+
message: t.string,
59+
/** Share state */
60+
state: ShareState,
61+
/** Enterprise id, if applicable */
62+
enterprise: t.string,
63+
/** Pending approval id, if one was generated */
64+
pendingApprovalId: t.string,
65+
/** Included if shared with spend permission */
66+
keychain: ShareWalletKeychain,
67+
}),
68+
]);
69+
70+
export const ShareWalletResponse = {
71+
200: ShareWalletResponse200,
72+
400: BitgoExpressError,
73+
} as const;
74+
75+
/**
76+
* Share this wallet with another BitGo user.
77+
*
78+
* @operationId express.v2.wallet.share
79+
*/
80+
export const PostShareWallet = httpRoute({
81+
path: '/api/v2/{coin}/wallet/{id}/share',
82+
method: 'POST',
83+
request: httpRequest({
84+
params: ShareWalletParams,
85+
body: ShareWalletBody,
86+
}),
87+
response: ShareWalletResponse,
88+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as t from 'io-ts';
2+
3+
export const ShareState = t.union([
4+
t.literal('pendingapproval'),
5+
t.literal('active'),
6+
t.literal('accepted'),
7+
t.literal('canceled'),
8+
t.literal('rejected'),
9+
]);
10+
11+
export const ShareWalletKeychain = t.partial({
12+
pub: t.string,
13+
encryptedPrv: t.string,
14+
fromPubKey: t.string,
15+
toPubKey: t.string,
16+
path: t.string,
17+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as sinon from 'sinon';
2+
import 'should-http';
3+
import 'should-sinon';
4+
import '../../lib/asserts';
5+
import nock from 'nock';
6+
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
7+
import { BitGo } from 'bitgo';
8+
import { BaseCoin, Wallets, Wallet, decodeOrElse, common } from '@bitgo/sdk-core';
9+
import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api';
10+
import { handleV2ShareWallet } from '../../../src/clientRoutes';
11+
import { ShareWalletResponse } from '../../../src/typedRoutes/api/v2/shareWallet';
12+
13+
describe('Share Wallet (typed handler)', () => {
14+
let bitgo: TestBitGoAPI;
15+
16+
before(async function () {
17+
if (!nock.isActive()) {
18+
nock.activate();
19+
}
20+
bitgo = TestBitGo.decorate(BitGo, { env: 'test' });
21+
bitgo.initializeTestVars();
22+
nock.disableNetConnect();
23+
nock.enableNetConnect('127.0.0.1');
24+
});
25+
26+
after(() => {
27+
if (nock.isActive()) {
28+
nock.restore();
29+
}
30+
});
31+
32+
it('should call shareWallet (no stub), mock BitGo HTTP, and return typed response', async () => {
33+
const coin = 'tbtc';
34+
const walletId = '59cd72485007a239fb00282ed480da1f';
35+
const email = '[email protected]';
36+
const permissions = 'view';
37+
const message = 'hello';
38+
39+
const baseCoin = bitgo.coin(coin);
40+
const walletData = {
41+
id: walletId,
42+
coin: coin,
43+
keys: ['k1', 'k2', 'k3'],
44+
coinSpecific: {},
45+
multisigType: 'onchain',
46+
type: 'hot',
47+
};
48+
const realWallet = new Wallet(bitgo, baseCoin, walletData);
49+
const coinStub = sinon.createStubInstance(BaseCoin, {
50+
wallets: sinon.stub<[], Wallets>().returns({
51+
get: sinon.stub<[any], Promise<Wallet>>().resolves(realWallet),
52+
} as any),
53+
});
54+
55+
const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) });
56+
57+
const bgUrl = common.Environments[bitgo.getEnv()].uri;
58+
const getSharingKeyNock = nock(bgUrl).post('/api/v1/user/sharingkey', { email }).reply(200, { userId: 'u1' });
59+
const shareResponse = {
60+
id: walletId,
61+
coin,
62+
wallet: walletId,
63+
fromUser: 'u0',
64+
toUser: 'u1',
65+
permissions,
66+
message,
67+
state: 'active',
68+
};
69+
const createShareNock = nock(bgUrl)
70+
.post(`/api/v2/${coin}/wallet/${walletId}/share`, (body) => {
71+
body.user.should.equal('u1');
72+
body.permissions.should.equal(permissions);
73+
body.skipKeychain.should.equal(true);
74+
if (message) body.message.should.equal(message);
75+
return true;
76+
})
77+
.reply(200, shareResponse);
78+
79+
const req = {
80+
bitgo: stubBitgo,
81+
decoded: {
82+
coin,
83+
id: walletId,
84+
email,
85+
permissions,
86+
message,
87+
},
88+
} as unknown as ExpressApiRouteRequest<'express.v2.wallet.share', 'post'>;
89+
90+
const res = await handleV2ShareWallet(req);
91+
decodeOrElse('ShareWalletResponse200', ShareWalletResponse[200], res, (errors) => {
92+
throw new Error(`Response did not match expected codec: ${errors}`);
93+
});
94+
95+
getSharingKeyNock.isDone().should.be.true();
96+
createShareNock.isDone().should.be.true();
97+
});
98+
});

0 commit comments

Comments
 (0)