Skip to content

Commit aef4fa9

Browse files
Merge pull request #9223 from BitGo/WCN-1192.vault-interfaces
feat: add sdk-core vault module interfaces and scaffolding
2 parents 4aa0484 + 7bc1790 commit aef4fa9

10 files changed

Lines changed: 556 additions & 0 deletions

File tree

modules/sdk-core/src/bitgo/enterprise/enterprise.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { BitGoBase } from '../bitgoBase';
77
import { EnterpriseData, EnterpriseFeatureFlag, IEnterprise } from '../enterprise';
88
import { getFirstPendingTransaction } from '../internal';
99
import { ListWalletOptions, Wallet } from '../wallet';
10+
import { Safes } from '../safe';
1011
import { BitGoProofSignatures, EcdsaUtils, SerializedNtildeWithVerifiers } from '../utils/tss/ecdsa';
1112
import { EcdsaTypes } from '@bitgo/sdk-lib-mpc';
1213
import { verifyEcdhSignature } from '../ecdh';
@@ -249,4 +250,12 @@ export class Enterprise implements IEnterprise {
249250
hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean {
250251
return flags.every((targetFlag) => this._enterprise.featureFlags?.includes(targetFlag));
251252
}
253+
254+
/**
255+
* Get the safes collection accessor scoped to this Enterprise
256+
* @experimental
257+
*/
258+
safes(): Safes {
259+
return new Safes(this.bitgo, this.id);
260+
}
252261
}

modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { IWallet } from '../wallet';
33
import { Buffer } from 'buffer';
44
import { BitGoProofSignatures, SerializedNtildeWithVerifiers } from '../utils/tss/ecdsa';
55
import { EcdhDerivedKeypair } from '../keychain';
6+
import { ISafes } from '../safe';
67

78
// useEnterpriseEcdsaTssChallenge is deprecated
89
export type EnterpriseFeatureFlag = 'useEnterpriseEcdsaTssChallenge';
@@ -39,4 +40,6 @@ export interface IEnterprise {
3940
bitgoNitroChallenge: SerializedNtildeWithVerifiers
4041
): Promise<void>;
4142
hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean;
43+
/** @experimental */
44+
safes(): ISafes;
4245
}

modules/sdk-core/src/bitgo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export * from './tss';
2727
export { sendSignatureShare } from './tss';
2828
export * from './types';
2929
export * from './utils';
30+
export * from './safe';
3031
export * from './wallet';
3132
export * from './webhook';
3233
export { bitcoinUtil };
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @prettier
3+
*
4+
* @experimental The safe client surface is experimental and may change (including breaking
5+
* changes) before the public release.
6+
*/
7+
// Wire/data shapes are owned by @bitgo/public-types — import them where needed rather than
8+
// re-exporting from sdk-core.
9+
import type {
10+
FreezeSafeBody,
11+
SafeData,
12+
SafePermission,
13+
SafeRootKeys,
14+
SafeShareData,
15+
SafeShareKeychain,
16+
SafeShareState,
17+
} from '@bitgo/public-types';
18+
import type { Wallet, WalletShare } from '../wallet';
19+
20+
export interface InitializeSafeOptions {
21+
label: string;
22+
}
23+
24+
// Phase 3 — the client hands back the 12 key ids it created in Phase 2:
25+
export interface FinalizeSafeOptions {
26+
rootKeys: SafeRootKeys;
27+
}
28+
29+
/**
30+
* Sharing ONE safe wallet with a non-member rides the existing wallet-share handshake (FR-13),
31+
* so the result is the existing WalletShare shape.
32+
*/
33+
export type WalletShareData = WalletShare;
34+
35+
// ---- per-safe operation options (bodies land in WCN-1203 / WCN-1204) ----
36+
37+
export interface CreateSafeWalletOptions {
38+
coin: string;
39+
label: string;
40+
type?: string;
41+
multisigTypeVersion?: string;
42+
}
43+
44+
interface AddSafeMemberBase {
45+
permissions: SafePermission[];
46+
/** required when 'spend' is included — the 4 root user keys ECDH-re-encrypted to the invitee */
47+
keychains?: SafeShareKeychain[];
48+
message?: string;
49+
/** when true, suppress the invitation email that would otherwise be sent to `email` */
50+
disableEmail?: boolean;
51+
}
52+
53+
/** Add a member by either `userId` or `email` — exactly one is required. */
54+
export type AddSafeMemberOptions =
55+
| (AddSafeMemberBase & { userId: string; email?: never })
56+
| (AddSafeMemberBase & { email: string; userId?: never });
57+
58+
export interface AddSafeWalletMemberOptions {
59+
walletId: string;
60+
/** required — sharing re-encrypts the user key, which needs hardened derivation from the passphrase */
61+
walletPassphrase: string;
62+
email?: string;
63+
permissions?: string[];
64+
message?: string;
65+
}
66+
67+
export type AcceptSafeShareAsSpenderOptions = {
68+
safeShareId: string;
69+
userPassword: string;
70+
newWalletPassphrase?: string;
71+
};
72+
export type AcceptSafeShareAsNonSpenderOptions = {
73+
safeShareId: string;
74+
};
75+
export type AcceptSafeShareOptions = AcceptSafeShareAsSpenderOptions | AcceptSafeShareAsNonSpenderOptions;
76+
77+
/**
78+
* @experimental
79+
*/
80+
export interface ISafe {
81+
id(): string;
82+
enterpriseId(): string;
83+
label(): string;
84+
status(): SafeData['status'];
85+
url(extra?: string): string;
86+
createWallet(params: CreateSafeWalletOptions): Promise<Wallet>;
87+
// whole-safe: view/admin/spend/dapp; spend opens a key share (also how a spender services a
88+
// safeShareRequests entry in UMS orgs)
89+
addMember(params: AddSafeMemberOptions): Promise<SafeData>;
90+
// share ONE safe wallet, not the whole safe
91+
addMemberToWallet(params: AddSafeWalletMemberOptions): Promise<WalletShareData>;
92+
listShares(params?: { state?: SafeShareState }): Promise<SafeShareData[]>;
93+
acceptShare(params: AcceptSafeShareOptions): Promise<SafeShareData>;
94+
freeze(params?: FreezeSafeBody): Promise<SafeData>;
95+
archive(): Promise<SafeData>;
96+
toJSON(): SafeData;
97+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @prettier
3+
*
4+
* @experimental The safe client surface is experimental and may change (including breaking
5+
* changes) before the public release.
6+
*/
7+
import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe';
8+
import { Safe } from './safe';
9+
10+
/**
11+
* Options for safe creation.
12+
*
13+
* v1 targets the HOT custody model only: the multisig root user/backup keys are generated locally
14+
* by the SDK, encrypted with `passphrase`, and registered on BitGo; the MPC roots run the standard
15+
* hot ceremonies with the same passphrase. Self-managed cold keys and custodial safes are out of
16+
* scope for v1.
17+
*/
18+
export interface CreateSafeOptions {
19+
label: string;
20+
passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies
21+
}
22+
23+
/** Handle returned by `initializeSafe`, threaded into the key ceremonies and finalize. */
24+
export interface SafeCreationHandle {
25+
safeId: string;
26+
}
27+
28+
/**
29+
* The 12 minted root key ids produced by `createSafeKeys`, as 4 ordered [user, backup, bitgo]
30+
* triplets — exactly the payload `finalizeSafe` consumes.
31+
*/
32+
export type SafeKeys = FinalizeSafeOptions;
33+
34+
export interface ListSafesOptions {
35+
cursor?: string; // opaque cursor from a previous response's nextCursor
36+
limit?: number;
37+
}
38+
39+
export interface GetSafeOptions {
40+
id: string;
41+
}
42+
43+
/**
44+
* @experimental
45+
*/
46+
export interface ISafes {
47+
/**
48+
* One-call convenience wrapper: initialize → createSafeKeys (4 safeId-tagged ceremonies) →
49+
* finalize → keycard. HOT custody only in v1.
50+
*/
51+
generateSafe(params: CreateSafeOptions): Promise<Safe>;
52+
/** Phase 1 — initialize a safe (metadata only, no key material). */
53+
initializeSafe(params: InitializeSafeOptions): Promise<Safe>;
54+
/** Phase 2 — run the 4 root key ceremonies tagged with `safeId`; returns the 12 minted key ids. */
55+
createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise<SafeKeys>;
56+
/** Phase 3 — finalize a safe with the 12 root key ids. Idempotent. */
57+
finalizeSafe(safeId: string, params: FinalizeSafeOptions): Promise<Safe>;
58+
list(params?: ListSafesOptions): Promise<{ safes: Safe[]; nextCursor?: string }>;
59+
get(params: GetSafeOptions): Promise<Safe>;
60+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './iSafe';
2+
export * from './iSafes';
3+
export * from './safe';
4+
export * from './safes';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* @prettier
3+
*
4+
* @experimental The safe client surface is experimental and may change (including breaking
5+
* changes) before the public release.
6+
*/
7+
import { FreezeSafeBody, SafeData, SafeShareData, SafeShareState } from '@bitgo/public-types';
8+
import { BitGoBase } from '../bitgoBase';
9+
import { decodeWithCodec } from '../utils/codecs';
10+
import { postWithCodec } from '../utils/postWithCodec';
11+
import { Wallet } from '../wallet';
12+
import {
13+
AcceptSafeShareOptions,
14+
AddSafeMemberOptions,
15+
AddSafeWalletMemberOptions,
16+
CreateSafeWalletOptions,
17+
ISafe,
18+
WalletShareData,
19+
} from './iSafe';
20+
21+
/**
22+
* @experimental
23+
*/
24+
export class Safe implements ISafe {
25+
private readonly bitgo: BitGoBase;
26+
public readonly _safe: SafeData;
27+
28+
constructor(bitgo: BitGoBase, safeData: SafeData) {
29+
this.bitgo = bitgo;
30+
this._safe = safeData;
31+
}
32+
33+
id(): string {
34+
return this._safe.id;
35+
}
36+
37+
enterpriseId(): string {
38+
return this._safe.enterpriseId;
39+
}
40+
41+
label(): string {
42+
return this._safe.label;
43+
}
44+
45+
status(): SafeData['status'] {
46+
return this._safe.status;
47+
}
48+
49+
/**
50+
* Enterprise-scoped v2 URL for this safe, e.g. /api/v2/enterprise/:eId/safes/:safeId
51+
* @param extra
52+
*/
53+
url(extra = ''): string {
54+
return this.bitgo.url(`/enterprise/${this.enterpriseId()}/safes/${this.id()}${extra}`, 2);
55+
}
56+
57+
/**
58+
* Mint a child wallet in this safe (server-side public derivation — no ceremony).
59+
* Body lands in WCN-1203.
60+
*/
61+
async createWallet(params: CreateSafeWalletOptions): Promise<Wallet> {
62+
throw new Error('Safe.createWallet is not yet implemented (WCN-1203)');
63+
}
64+
65+
/**
66+
* Add a member to the whole safe (view/admin/spend). Spend opens a key share.
67+
* Body lands in WCN-1204.
68+
*/
69+
async addMember(params: AddSafeMemberOptions): Promise<SafeData> {
70+
throw new Error('Safe.addMember is not yet implemented (WCN-1204)');
71+
}
72+
73+
/**
74+
* Share ONE safe wallet with a non-member via the existing wallet-share handshake (FR-13).
75+
* Body lands in WCN-1204.
76+
*/
77+
async addMemberToWallet(params: AddSafeWalletMemberOptions): Promise<WalletShareData> {
78+
throw new Error('Safe.addMemberToWallet is not yet implemented (WCN-1204)');
79+
}
80+
81+
/**
82+
* List the safe key shares visible to the caller.
83+
* Body lands in WCN-1204.
84+
*/
85+
async listShares(params: { state?: SafeShareState } = {}): Promise<SafeShareData[]> {
86+
throw new Error('Safe.listShares is not yet implemented (WCN-1204)');
87+
}
88+
89+
/**
90+
* Accept a safe key share addressed to the caller.
91+
* Body lands in WCN-1204.
92+
*/
93+
async acceptShare(params: AcceptSafeShareOptions): Promise<SafeShareData> {
94+
throw new Error('Safe.acceptShare is not yet implemented (WCN-1204)');
95+
}
96+
97+
/**
98+
* Freeze the safe — blocks withdrawals on all safe wallets. Safe stays 'active'.
99+
* @param params
100+
*/
101+
async freeze(params: FreezeSafeBody = {}): Promise<SafeData> {
102+
const response = await postWithCodec(this.bitgo, this.url('/freeze'), FreezeSafeBody, params).result();
103+
return decodeWithCodec(SafeData, response, 'SafeData');
104+
}
105+
106+
/**
107+
* Archive the safe. Requires every safe wallet to already be archived; also the abandonment
108+
* path for a stuck 'initializing' safe.
109+
*/
110+
async archive(): Promise<SafeData> {
111+
const response = await this.bitgo.post(this.url('/archive')).send().result();
112+
return decodeWithCodec(SafeData, response, 'SafeData');
113+
}
114+
115+
toJSON(): SafeData {
116+
return this._safe;
117+
}
118+
}

0 commit comments

Comments
 (0)