From 3e92d3907a7aae395da01e5623973fddd8dc96aa Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Tue, 7 Jul 2026 12:40:32 -0400 Subject: [PATCH 1/4] feat: add sdk-core vault module interfaces and scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new sdk-core/src/bitgo/vault module mirroring the EnterpriseData/ Enterprise and WalletData/Wallet conventions: iVault/iVaults interfaces (per API Changes v1 §3), the Vault and Vaults classes, and the barrel. Vault implements getters, url(), toJSON(), and freeze/archive REST plumbing; createWallet, member, and share methods are stubbed for WCN-1203/WCN-1204. Vaults lifecycle methods (createVault, initialize, finalize, list, get) are stubbed pending Phase 2/3 (WCN-1175/1177). Wires an Enterprise.vaults() accessor and the bitgo barrel export. WCN-1192 TICKET: WCN-1192 --- .../src/bitgo/enterprise/enterprise.ts | 8 + .../src/bitgo/enterprise/iEnterprise.ts | 2 + modules/sdk-core/src/bitgo/index.ts | 1 + modules/sdk-core/src/bitgo/vault/iVault.ts | 154 ++++++++++++++++++ modules/sdk-core/src/bitgo/vault/iVaults.ts | 36 ++++ modules/sdk-core/src/bitgo/vault/index.ts | 4 + modules/sdk-core/src/bitgo/vault/vault.ts | 123 ++++++++++++++ modules/sdk-core/src/bitgo/vault/vaults.ts | 73 +++++++++ .../sdk-core/test/unit/bitgo/vault/vault.ts | 131 +++++++++++++++ .../sdk-core/test/unit/bitgo/vault/vaults.ts | 63 +++++++ 10 files changed, 595 insertions(+) create mode 100644 modules/sdk-core/src/bitgo/vault/iVault.ts create mode 100644 modules/sdk-core/src/bitgo/vault/iVaults.ts create mode 100644 modules/sdk-core/src/bitgo/vault/index.ts create mode 100644 modules/sdk-core/src/bitgo/vault/vault.ts create mode 100644 modules/sdk-core/src/bitgo/vault/vaults.ts create mode 100644 modules/sdk-core/test/unit/bitgo/vault/vault.ts create mode 100644 modules/sdk-core/test/unit/bitgo/vault/vaults.ts diff --git a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts index 0a2a8ced4f..790924b6ab 100644 --- a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts +++ b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts @@ -7,6 +7,7 @@ import { BitGoBase } from '../bitgoBase'; import { EnterpriseData, EnterpriseFeatureFlag, IEnterprise } from '../enterprise'; import { getFirstPendingTransaction } from '../internal'; import { ListWalletOptions, Wallet } from '../wallet'; +import { Vaults } from '../vault'; import { BitGoProofSignatures, EcdsaUtils, SerializedNtildeWithVerifiers } from '../utils/tss/ecdsa'; import { EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { verifyEcdhSignature } from '../ecdh'; @@ -249,4 +250,11 @@ export class Enterprise implements IEnterprise { hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean { return flags.every((targetFlag) => this._enterprise.featureFlags?.includes(targetFlag)); } + + /** + * Get the vaults collection accessor scoped to this Enterprise + */ + vaults(): Vaults { + return new Vaults(this.bitgo, this.baseCoin, this.id); + } } diff --git a/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts b/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts index 60e96f2df9..516abd747c 100644 --- a/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts +++ b/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts @@ -3,6 +3,7 @@ import { IWallet } from '../wallet'; import { Buffer } from 'buffer'; import { BitGoProofSignatures, SerializedNtildeWithVerifiers } from '../utils/tss/ecdsa'; import { EcdhDerivedKeypair } from '../keychain'; +import { IVaults } from '../vault'; // useEnterpriseEcdsaTssChallenge is deprecated export type EnterpriseFeatureFlag = 'useEnterpriseEcdsaTssChallenge'; @@ -39,4 +40,5 @@ export interface IEnterprise { bitgoNitroChallenge: SerializedNtildeWithVerifiers ): Promise; hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean; + vaults(): IVaults; } diff --git a/modules/sdk-core/src/bitgo/index.ts b/modules/sdk-core/src/bitgo/index.ts index 610c75d97d..965df3d260 100644 --- a/modules/sdk-core/src/bitgo/index.ts +++ b/modules/sdk-core/src/bitgo/index.ts @@ -27,6 +27,7 @@ export * from './tss'; export { sendSignatureShare } from './tss'; export * from './types'; export * from './utils'; +export * from './vault'; export * from './wallet'; export * from './webhook'; export { bitcoinUtil }; diff --git a/modules/sdk-core/src/bitgo/vault/iVault.ts b/modules/sdk-core/src/bitgo/vault/iVault.ts new file mode 100644 index 0000000000..96001fa28e --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/iVault.ts @@ -0,0 +1,154 @@ +/** + * @prettier + */ +import type { FreezeOptions, Wallet, WalletShare } from '../wallet'; + +/** + * The four static root-key slots of a vault, keyed by (curve, scheme): + * - secp256k1Multisig ① — UTXO/XRP/XTZ/TRX/EOS + * - ecdsaMpc ② — EVM/Cosmos (DKLS) + * - eddsaMpc ③ — SOL/SUI/NEAR/TON/APT/DOT + * - ed25519Multisig ④ — ALGO/XLM/HBAR + */ +export type RootKeyType = 'secp256k1Multisig' | 'ecdsaMpc' | 'eddsaMpc' | 'ed25519Multisig'; + +export type VaultPermission = 'view' | 'spend' | 'admin' | 'dapp'; + +/** + * The 12 static root key ids, by (curve, scheme). Each entry is an ordered + * [userKeyId, backupKeyId, bitgoKeyId] triplet — same shape and order as wallet.keys[]. + */ +export type VaultRootKeys = Record; + +export interface VaultMembershipData { + userId: string; // new-model naming convention + permissions: VaultPermission[]; + needsRecovery?: boolean; +} + +/** + * A pending UMS spend grant awaiting a key share — mirror of the wallet's walletShareRequests[]. + * Returned to spenders/admins and serviced via addMember. + */ +export interface VaultShareRequest { + userId: string; + permissions: VaultPermission[]; + createdAt: string; +} + +export interface VaultData { + id: string; + enterpriseId: string; + label: string; + // freeze is NOT a status — a frozen vault stays 'active' with the freeze field set (wallet precedent) + status: 'initializing' | 'active' | 'archived'; + creator: string; + users: VaultMembershipData[]; + vaultShareRequests?: VaultShareRequest[]; + freeze?: { time?: string; expires?: string; reason?: string }; + rootKeys?: VaultRootKeys; // ordered, like wallet.keys + archivedAt?: string; + createdAt: string; +} + +export interface InitializeVaultOptions { + label: string; // Phase 1 carries no key material — key generation happens in Phase 2 via the existing keychain APIs +} + +// Phase 3 — the client hands back the 12 key ids it created in Phase 2: +export interface FinalizeVaultOptions { + rootKeys: VaultRootKeys; +} + +/** Vault key-share states — identical to WalletShare states, no new states. */ +export type VaultShareState = 'pendingapproval' | 'active' | 'accepted' | 'canceled' | 'rejected'; + +/** + * One of the 4 root USER keyshares carried on a VaultShare, ECDH-re-encrypted to the recipient. + * Body semantics land in the Part VI SDK ticket (WCN-1204). + */ +export interface VaultShareKeychain { + rootKeyType: RootKeyType; + rootKeyId: string; + encryptedPrv: string; + publicIdentifier: string; + fromPubKey: string; + toPubKey: string; + path: string; +} + +export interface VaultShareData { + id: string; + enterpriseId: string; + vaultId: string; + vaultLabel?: string; + fromUser: string; + toUser: string; + permissions: VaultPermission[]; + state: VaultShareState; + message?: string; + pendingApprovalId?: string; + isUMSInitiated?: boolean; + keychains?: VaultShareKeychain[]; + createdAt: string; + updatedAt?: string; +} + +/** + * Sharing ONE vault wallet with a non-member rides the existing wallet-share handshake (FR-13), + * so the result is the existing WalletShare shape. + */ +export type WalletShareData = WalletShare; + +// ---- per-vault operation options (bodies land in WCN-1203 / WCN-1204) ---- + +export interface CreateVaultWalletOptions { + coin: string; + label: string; + type?: string; + multisigType?: string; + multisigTypeVersion?: string; +} + +export interface AddVaultMemberOptions { + userId?: string; + email?: string; + permissions: VaultPermission[]; + // required when 'spend' is included — the 4 root user keys ECDH-re-encrypted to the invitee + keychains?: VaultShareKeychain[]; + message?: string; + disableEmail?: boolean; +} + +export interface AddVaultWalletMemberOptions { + walletId: string; + email?: string; + permissions?: string[]; + message?: string; +} + +export interface AcceptVaultShareOptions { + vaultShareId: string; + userPassword?: string; + newWalletPassphrase?: string; + overrideEncryptedPrv?: string; +} + +export interface IVault { + id(): string; + enterpriseId(): string; + label(): string; + status(): VaultData['status']; + url(extra?: string): string; + createWallet(params: CreateVaultWalletOptions): Promise; + // whole-vault: view/admin/spend; spend opens a key share (also how a spender services a + // vaultShareRequests entry in UMS orgs) + addMember(params: AddVaultMemberOptions): Promise; + // share ONE vault wallet (FR-13), not the whole vault + addMemberToWallet(params: AddVaultWalletMemberOptions): Promise; + listShares(params?: { state?: VaultShareState }): Promise; + acceptShare(params: AcceptVaultShareOptions): Promise; + freeze(params?: FreezeOptions): Promise; + archive(): Promise; + toJSON(): VaultData; +} diff --git a/modules/sdk-core/src/bitgo/vault/iVaults.ts b/modules/sdk-core/src/bitgo/vault/iVaults.ts new file mode 100644 index 0000000000..5f6c31d21b --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/iVaults.ts @@ -0,0 +1,36 @@ +/** + * @prettier + */ +import { InitializeVaultOptions, FinalizeVaultOptions } from './iVault'; +import { Vault } from './vault'; + +/** + * Options for the createVault orchestrator. + * + * v1 targets the HOT custody model only: the multisig root user/backup keys are generated locally + * by the SDK, encrypted with `passphrase`, and registered on BitGo; the MPC roots run the standard + * hot ceremonies with the same passphrase. Self-managed cold keys and custodial vaults are out of + * scope for v1. + */ +export interface CreateVaultOptions { + label: string; + passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies +} + +export interface ListVaultsOptions { + cursor?: string; // opaque cursor from a previous response's nextCursor + limit?: number; +} + +export interface GetVaultOptions { + id: string; +} + +export interface IVaults { + // orchestrates: initialize → 4 root key flows (vaultId-tagged) → finalize → keycard + createVault(params: CreateVaultOptions): Promise; + initializeVault(params: InitializeVaultOptions): Promise; + finalizeVault(vaultId: string, params: FinalizeVaultOptions): Promise; + list(params?: ListVaultsOptions): Promise<{ vaults: Vault[]; nextCursor?: string }>; + get(params: GetVaultOptions): Promise; +} diff --git a/modules/sdk-core/src/bitgo/vault/index.ts b/modules/sdk-core/src/bitgo/vault/index.ts new file mode 100644 index 0000000000..60f23f7252 --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/index.ts @@ -0,0 +1,4 @@ +export * from './iVault'; +export * from './iVaults'; +export * from './vault'; +export * from './vaults'; diff --git a/modules/sdk-core/src/bitgo/vault/vault.ts b/modules/sdk-core/src/bitgo/vault/vault.ts new file mode 100644 index 0000000000..4c2b6327ca --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/vault.ts @@ -0,0 +1,123 @@ +/** + * @prettier + */ +import * as _ from 'lodash'; +import { IBaseCoin } from '../baseCoin'; +import { BitGoBase } from '../bitgoBase'; +import { FreezeOptions, Wallet } from '../wallet'; +import { + AcceptVaultShareOptions, + AddVaultMemberOptions, + AddVaultWalletMemberOptions, + CreateVaultWalletOptions, + IVault, + VaultData, + VaultShareData, + VaultShareState, + WalletShareData, +} from './iVault'; + +export class Vault implements IVault { + private readonly bitgo: BitGoBase; + private readonly baseCoin: IBaseCoin; + public readonly _vault: VaultData; + + constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, vaultData: VaultData) { + this.bitgo = bitgo; + this.baseCoin = baseCoin; + if (!_.isObject(vaultData)) { + throw new Error('vaultData has to be an object'); + } + if (!_.isString(vaultData.id)) { + throw new Error('vault id has to be a string'); + } + if (!_.isString(vaultData.enterpriseId)) { + throw new Error('vault enterpriseId has to be a string'); + } + this._vault = vaultData; + } + + id(): string { + return this._vault.id; + } + + enterpriseId(): string { + return this._vault.enterpriseId; + } + + label(): string { + return this._vault.label; + } + + status(): VaultData['status'] { + return this._vault.status; + } + + /** + * Enterprise-scoped v2 URL for this vault, e.g. /api/v2/enterprise/:eId/vaults/:vId + * @param extra + */ + url(extra = ''): string { + return this.bitgo.url(`/enterprise/${this.enterpriseId()}/vaults/${this.id()}${extra}`, 2); + } + + /** + * Mint a child wallet in this vault (server-side public derivation — no ceremony). + * Body lands in WCN-1203. + */ + async createWallet(params: CreateVaultWalletOptions): Promise { + throw new Error('Vault.createWallet is not yet implemented (WCN-1203)'); + } + + /** + * Add a member to the whole vault (view/admin/spend). Spend opens a key share. + * Body lands in WCN-1204. + */ + async addMember(params: AddVaultMemberOptions): Promise { + throw new Error('Vault.addMember is not yet implemented (WCN-1204)'); + } + + /** + * Share ONE vault wallet with a non-member via the existing wallet-share handshake (FR-13). + * Body lands in WCN-1204. + */ + async addMemberToWallet(params: AddVaultWalletMemberOptions): Promise { + throw new Error('Vault.addMemberToWallet is not yet implemented (WCN-1204)'); + } + + /** + * List the vault key shares visible to the caller. + * Body lands in WCN-1204. + */ + async listShares(params: { state?: VaultShareState } = {}): Promise { + throw new Error('Vault.listShares is not yet implemented (WCN-1204)'); + } + + /** + * Accept a vault key share addressed to the caller. + * Body lands in WCN-1204. + */ + async acceptShare(params: AcceptVaultShareOptions): Promise { + throw new Error('Vault.acceptShare is not yet implemented (WCN-1204)'); + } + + /** + * Freeze the vault — blocks withdrawals on all vault wallets. Vault stays 'active'. + * @param params + */ + async freeze(params: FreezeOptions = {}): Promise { + return (await this.bitgo.post(this.url('/freeze')).send(params).result()) as VaultData; + } + + /** + * Archive the vault. Requires every vault wallet to already be archived; also the abandonment + * path for a stuck 'initializing' vault. + */ + async archive(): Promise { + return (await this.bitgo.post(this.url('/archive')).send().result()) as VaultData; + } + + toJSON(): VaultData { + return this._vault; + } +} diff --git a/modules/sdk-core/src/bitgo/vault/vaults.ts b/modules/sdk-core/src/bitgo/vault/vaults.ts new file mode 100644 index 0000000000..788c9cf88d --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/vaults.ts @@ -0,0 +1,73 @@ +/** + * @prettier + */ +import { IBaseCoin } from '../baseCoin'; +import { BitGoBase } from '../bitgoBase'; +import { FinalizeVaultOptions, InitializeVaultOptions } from './iVault'; +import { CreateVaultOptions, GetVaultOptions, IVaults, ListVaultsOptions } from './iVaults'; +import { Vault } from './vault'; + +/** + * Collection accessor for a single enterprise's vaults, mirroring Wallets / Enterprises. + * Vault routes are enterprise-scoped (/api/v2/enterprise/:eId/vaults), so the accessor is + * constructed with the enterprise id (see Enterprise.vaults()). + */ +export class Vaults implements IVaults { + private readonly bitgo: BitGoBase; + private readonly baseCoin: IBaseCoin; + private readonly enterpriseId: string; + + constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, enterpriseId: string) { + this.bitgo = bitgo; + this.baseCoin = baseCoin; + this.enterpriseId = enterpriseId; + } + + /** + * Enterprise-scoped v2 URL for the vaults collection, e.g. /api/v2/enterprise/:eId/vaults + * @param extra + */ + private url(extra = ''): string { + return this.bitgo.url(`/enterprise/${this.enterpriseId}/vaults${extra}`, 2); + } + + /** + * One-call orchestrator: initialize → 4 root key ceremonies (vaultId-tagged) → finalize → keycard. + * HOT custody only in v1. Implemented in WCN-1192 Phase 2. + */ + async createVault(params: CreateVaultOptions): Promise { + throw new Error('Vaults.createVault is not yet implemented (WCN-1192 Phase 2)'); + } + + /** + * Phase 1 — initialize a vault (metadata only, no key material). + * Implemented in WCN-1192 Phase 2 (blocked on WCN-1175). + */ + async initializeVault(params: InitializeVaultOptions): Promise { + throw new Error('Vaults.initializeVault is not yet implemented (WCN-1192 Phase 2)'); + } + + /** + * Phase 3 — finalize a vault with the 12 root key ids as 4 ordered [user, backup, bitgo] triplets. + * Idempotent. Implemented in WCN-1192 Phase 2 (blocked on WCN-1175). + */ + async finalizeVault(vaultId: string, params: FinalizeVaultOptions): Promise { + throw new Error('Vaults.finalizeVault is not yet implemented (WCN-1192 Phase 2)'); + } + + /** + * List the enterprise's vaults (cursor pagination). + * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). + */ + async list(params: ListVaultsOptions = {}): Promise<{ vaults: Vault[]; nextCursor?: string }> { + throw new Error('Vaults.list is not yet implemented (WCN-1192 Phase 3)'); + } + + /** + * Fetch a single vault by id. + * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). + */ + async get(params: GetVaultOptions): Promise { + throw new Error('Vaults.get is not yet implemented (WCN-1192 Phase 3)'); + } +} diff --git a/modules/sdk-core/test/unit/bitgo/vault/vault.ts b/modules/sdk-core/test/unit/bitgo/vault/vault.ts new file mode 100644 index 0000000000..6e4eec998f --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/vault/vault.ts @@ -0,0 +1,131 @@ +import * as sinon from 'sinon'; +import 'should'; +import { Vault, VaultData } from '../../../../src'; + +describe('Vault', function () { + let vault: Vault; + let mockBitGo: any; + let mockBaseCoin: any; + let vaultData: VaultData; + + beforeEach(function () { + mockBitGo = { + url: sinon.stub().callsFake((path: string) => path), + post: sinon.stub(), + }; + mockBaseCoin = { + url: sinon.stub().callsFake((path: string) => path), + }; + vaultData = { + id: 'test-vault-id', + enterpriseId: 'test-enterprise-id', + label: 'my vault', + status: 'active', + creator: 'creator-id', + users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], + createdAt: '2026-07-07T00:00:00.000Z', + }; + vault = new Vault(mockBitGo, mockBaseCoin, vaultData); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('constructor', function () { + it('throws when vaultData is not an object', function () { + (() => new Vault(mockBitGo, mockBaseCoin, undefined as unknown as VaultData)).should.throw( + 'vaultData has to be an object' + ); + }); + + it('throws when id is not a string', function () { + (() => new Vault(mockBitGo, mockBaseCoin, { ...vaultData, id: 123 as unknown as string })).should.throw( + 'vault id has to be a string' + ); + }); + + it('throws when enterpriseId is not a string', function () { + (() => + new Vault(mockBitGo, mockBaseCoin, { + ...vaultData, + enterpriseId: undefined as unknown as string, + })).should.throw('vault enterpriseId has to be a string'); + }); + }); + + describe('getters', function () { + it('exposes id/enterpriseId/label/status', function () { + vault.id().should.equal('test-vault-id'); + vault.enterpriseId().should.equal('test-enterprise-id'); + vault.label().should.equal('my vault'); + vault.status().should.equal('active'); + }); + + it('toJSON returns the underlying data', function () { + vault.toJSON().should.equal(vaultData); + }); + }); + + describe('url', function () { + it('builds the enterprise-scoped v2 path', function () { + vault.url().should.equal('/enterprise/test-enterprise-id/vaults/test-vault-id'); + vault.url('/freeze').should.equal('/enterprise/test-enterprise-id/vaults/test-vault-id/freeze'); + mockBitGo.url.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id', 2).should.be.true(); + }); + }); + + describe('freeze / archive REST plumbing', function () { + function stubPost(response: unknown) { + const resultStub = sinon.stub().resolves(response); + const sendStub = sinon.stub().returns({ result: resultStub }); + mockBitGo.post.returns({ send: sendStub }); + return { sendStub }; + } + + it('archive sends an empty body', async function () { + const { sendStub } = stubPost({ ...vaultData, status: 'archived' as const }); + await vault.archive(); + sendStub.calledWithExactly().should.be.true(); + }); + + it('freeze posts to the freeze route and returns VaultData', async function () { + const frozen = { ...vaultData, freeze: { reason: 'incident' } }; + const { sendStub } = stubPost(frozen); + const result = await vault.freeze({ duration: 3600 }); + mockBitGo.post.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id/freeze').should.be.true(); + sendStub.calledWith({ duration: 3600 }).should.be.true(); + result.should.equal(frozen); + }); + + it('archive posts to the archive route and returns VaultData', async function () { + const archived = { ...vaultData, status: 'archived' as const }; + stubPost(archived); + const result = await vault.archive(); + mockBitGo.post.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id/archive').should.be.true(); + result.should.equal(archived); + }); + }); + + describe('member/share methods are stubbed (WCN-1203 / WCN-1204)', function () { + it('createWallet throws not-implemented (WCN-1203)', async function () { + await vault.createWallet({ coin: 'tbtc', label: 'w' }).should.be.rejectedWith(/WCN-1203/); + }); + + it('addMember throws not-implemented (WCN-1204)', async function () { + await vault.addMember({ permissions: ['view'] }).should.be.rejectedWith(/WCN-1204/); + }); + + it('addMemberToWallet throws not-implemented (WCN-1204)', async function () { + await vault.addMemberToWallet({ walletId: 'w' }).should.be.rejectedWith(/WCN-1204/); + }); + + it('listShares throws not-implemented (WCN-1204)', async function () { + await vault.listShares().should.be.rejectedWith(/WCN-1204/); + }); + + it('acceptShare throws not-implemented (WCN-1204)', async function () { + await vault.acceptShare({ vaultShareId: 's' }).should.be.rejectedWith(/WCN-1204/); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/vault/vaults.ts b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts new file mode 100644 index 0000000000..f6baa199bb --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts @@ -0,0 +1,63 @@ +import * as sinon from 'sinon'; +import 'should'; +import { Enterprise, Vaults } from '../../../../src'; + +describe('Vaults', function () { + let vaults: Vaults; + let mockBitGo: any; + let mockBaseCoin: any; + + beforeEach(function () { + mockBitGo = { + url: sinon.stub().callsFake((path: string) => path), + }; + mockBaseCoin = { + url: sinon.stub().callsFake((path: string) => path), + }; + vaults = new Vaults(mockBitGo, mockBaseCoin, 'test-enterprise-id'); + }); + + afterEach(function () { + sinon.restore(); + }); + + // Phase 2 (createVault/initialize/finalize) and Phase 3 (list/get) are not yet implemented — + // they are gated on WCN-1175 / WCN-1177. Assert the interface is wired and stubbed for now. + describe('unimplemented lifecycle methods', function () { + it('createVault throws (Phase 2)', async function () { + await vaults.createVault({ label: 'v', passphrase: 'p' }).should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + + it('initializeVault throws (Phase 2)', async function () { + await vaults.initializeVault({ label: 'v' }).should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + + it('finalizeVault throws (Phase 2)', async function () { + await vaults + .finalizeVault('vid', { + rootKeys: { + secp256k1Multisig: ['u', 'b', 'g'], + ecdsaMpc: ['u', 'b', 'g'], + eddsaMpc: ['u', 'b', 'g'], + ed25519Multisig: ['u', 'b', 'g'], + }, + }) + .should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + + it('list throws (Phase 3)', async function () { + await vaults.list().should.be.rejectedWith(/not yet implemented .*Phase 3/); + }); + + it('get throws (Phase 3)', async function () { + await vaults.get({ id: 'vid' }).should.be.rejectedWith(/not yet implemented .*Phase 3/); + }); + }); + + describe('Enterprise.vaults() accessor', function () { + it('returns a Vaults instance scoped to the enterprise', function () { + const enterprise = new Enterprise(mockBitGo, mockBaseCoin, { id: 'ent-id', name: 'ent' }); + enterprise.vaults().should.be.instanceof(Vaults); + }); + }); +}); From 06f259799134f81b1ea52077b3df6af89a91d969 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Thu, 9 Jul 2026 12:25:00 -0400 Subject: [PATCH 2/4] chore: add Pr review comments WCN-1192 TICKET: WCN-1192 --- .../src/bitgo/enterprise/enterprise.ts | 3 +- .../src/bitgo/enterprise/iEnterprise.ts | 1 + modules/sdk-core/src/bitgo/vault/codecs.ts | 178 ++++++++++++++++++ modules/sdk-core/src/bitgo/vault/iVault.ts | 128 ++++--------- modules/sdk-core/src/bitgo/vault/iVaults.ts | 32 +++- modules/sdk-core/src/bitgo/vault/vault.ts | 20 +- modules/sdk-core/src/bitgo/vault/vaults.ts | 35 +++- .../sdk-core/test/unit/bitgo/vault/vault.ts | 51 ++--- .../sdk-core/test/unit/bitgo/vault/vaults.ts | 25 ++- 9 files changed, 327 insertions(+), 146 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/vault/codecs.ts diff --git a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts index 790924b6ab..1b1b0cb0c5 100644 --- a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts +++ b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts @@ -253,8 +253,9 @@ export class Enterprise implements IEnterprise { /** * Get the vaults collection accessor scoped to this Enterprise + * @experimental */ vaults(): Vaults { - return new Vaults(this.bitgo, this.baseCoin, this.id); + return new Vaults(this.bitgo, this.id); } } diff --git a/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts b/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts index 516abd747c..614eb22b82 100644 --- a/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts +++ b/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts @@ -40,5 +40,6 @@ export interface IEnterprise { bitgoNitroChallenge: SerializedNtildeWithVerifiers ): Promise; hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean; + /** @experimental */ vaults(): IVaults; } diff --git a/modules/sdk-core/src/bitgo/vault/codecs.ts b/modules/sdk-core/src/bitgo/vault/codecs.ts new file mode 100644 index 0000000000..5dc774d30f --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/codecs.ts @@ -0,0 +1,178 @@ +/** + * @prettier + * + * io-ts codecs for the vault REST surface. These are the single source of truth for the + * vault data shapes: the TypeScript interfaces in `iVault.ts` are derived from them via + * `t.TypeOf`, request bodies are encoded with `postWithCodec`, and responses are decoded + * (and validated) with `decodeWithCodec` — so there are no `as VaultData` casts. + * + * Timestamps use `DateFromISOString`: the wire representation stays an ISO-8601 string while + * the decoded object exposes a real `Date`. + */ +import * as t from 'io-ts'; +import { DateFromISOString } from 'io-ts-types'; + +/** + * The four static root-key slots of a vault, keyed by (curve, scheme): + * - secp256k1Multisig ① — ex. UTXO/XRP/XTZ/TRX/EOS + * - ecdsaMpc ② — ex. EVM/Cosmos (DKLS) + * - eddsaMpc ③ — ex. SOL/SUI/NEAR/TON/APT/DOT + * - ed25519Multisig ④ — ex. ALGO/XLM/HBAR + */ +export const RootKeyType = t.keyof( + { + secp256k1Multisig: null, + ecdsaMpc: null, + eddsaMpc: null, + ed25519Multisig: null, + }, + 'RootKeyType' +); + +export const VaultPermission = t.keyof( + { + view: null, + spend: null, + admin: null, + dapp: null, + }, + 'VaultPermission' +); + +/** An ordered [userKeyId, backupKeyId, bitgoKeyId] triplet — same shape/order as wallet.keys[]. */ +export const RootKeyTriplet = t.tuple([t.string, t.string, t.string], 'RootKeyTriplet'); + +/** The 12 static root key ids, by (curve, scheme). */ +export const VaultRootKeys = t.type( + { + secp256k1Multisig: RootKeyTriplet, + ecdsaMpc: RootKeyTriplet, + eddsaMpc: RootKeyTriplet, + ed25519Multisig: RootKeyTriplet, + }, + 'VaultRootKeys' +); + +export const VaultMembershipData = t.intersection( + [ + t.type({ + userId: t.string, + permissions: t.array(VaultPermission), + }), + t.partial({ + needsRecovery: t.boolean, + }), + ], + 'VaultMembershipData' +); + +/** A pending UMS spend grant awaiting a key share — mirror of the wallet's walletShareRequests[]. */ +export const VaultShareRequest = t.type( + { + userId: t.string, + permissions: t.array(VaultPermission), + createdAt: DateFromISOString, + }, + 'VaultShareRequest' +); + +export const VaultFreeze = t.partial( + { + time: DateFromISOString, + expires: DateFromISOString, + reason: t.string, + }, + 'VaultFreeze' +); + +export const VaultStatus = t.keyof( + { + initializing: null, + active: null, + archived: null, + }, + 'VaultStatus' +); + +export const VaultData = t.intersection( + [ + t.type({ + id: t.string, + enterpriseId: t.string, + label: t.string, + // freeze is NOT a status — a frozen vault stays 'active' with the freeze field set (wallet precedent) + status: VaultStatus, + creator: t.string, + users: t.array(VaultMembershipData), + createdAt: DateFromISOString, + }), + t.partial({ + vaultShareRequests: t.array(VaultShareRequest), + freeze: VaultFreeze, + rootKeys: VaultRootKeys, + archivedAt: DateFromISOString, + }), + ], + 'VaultData' +); + +/** Vault key-share states — identical to WalletShare states, no new states. */ +export const VaultShareState = t.keyof( + { + pendingapproval: null, + active: null, + accepted: null, + canceled: null, + rejected: null, + }, + 'VaultShareState' +); + +/** One of the 4 root USER keyshares carried on a VaultShare, ECDH-re-encrypted to the recipient. */ +export const VaultShareKeychain = t.type( + { + rootKeyType: RootKeyType, + rootKeyId: t.string, + encryptedPrv: t.string, + publicIdentifier: t.string, + fromPubKey: t.string, + toPubKey: t.string, + path: t.string, + }, + 'VaultShareKeychain' +); + +export const VaultShareData = t.intersection( + [ + t.type({ + id: t.string, + enterpriseId: t.string, + vaultId: t.string, + fromUser: t.string, + toUser: t.string, + permissions: t.array(VaultPermission), + state: VaultShareState, + createdAt: DateFromISOString, + }), + t.partial({ + vaultLabel: t.string, + message: t.string, + pendingApprovalId: t.string, + isUMSInitiated: t.boolean, + keychains: t.array(VaultShareKeychain), + updatedAt: DateFromISOString, + }), + ], + 'VaultShareData' +); + +// ---- request bodies ---- + +/** POST /enterprise/:eId/vaults — Phase 1 carries no key material. */ +export const InitializeVaultBody = t.type({ label: t.string }, 'InitializeVaultBody'); + +/** POST /enterprise/:eId/vaults/:vId/finalize — the 12 key ids as 4 ordered triplets. */ +export const FinalizeVaultBody = t.type({ rootKeys: VaultRootKeys }, 'FinalizeVaultBody'); + +/** POST/DELETE /enterprise/:eId/vaults/:vId/freeze */ +export const FreezeVaultBody = t.partial({ duration: t.number }, 'FreezeVaultBody'); diff --git a/modules/sdk-core/src/bitgo/vault/iVault.ts b/modules/sdk-core/src/bitgo/vault/iVault.ts index 96001fa28e..9d6573617a 100644 --- a/modules/sdk-core/src/bitgo/vault/iVault.ts +++ b/modules/sdk-core/src/bitgo/vault/iVault.ts @@ -1,58 +1,27 @@ /** * @prettier + * + * @experimental The vault client surface is experimental and may change (including breaking + * changes) before the public release. */ +import * as t from 'io-ts'; import type { FreezeOptions, Wallet, WalletShare } from '../wallet'; +import * as VaultCodecs from './codecs'; -/** - * The four static root-key slots of a vault, keyed by (curve, scheme): - * - secp256k1Multisig ① — UTXO/XRP/XTZ/TRX/EOS - * - ecdsaMpc ② — EVM/Cosmos (DKLS) - * - eddsaMpc ③ — SOL/SUI/NEAR/TON/APT/DOT - * - ed25519Multisig ④ — ALGO/XLM/HBAR - */ -export type RootKeyType = 'secp256k1Multisig' | 'ecdsaMpc' | 'eddsaMpc' | 'ed25519Multisig'; - -export type VaultPermission = 'view' | 'spend' | 'admin' | 'dapp'; +// ---- data shapes (derived from the io-ts codecs in ./codecs — single source of truth) ---- -/** - * The 12 static root key ids, by (curve, scheme). Each entry is an ordered - * [userKeyId, backupKeyId, bitgoKeyId] triplet — same shape and order as wallet.keys[]. - */ -export type VaultRootKeys = Record; - -export interface VaultMembershipData { - userId: string; // new-model naming convention - permissions: VaultPermission[]; - needsRecovery?: boolean; -} - -/** - * A pending UMS spend grant awaiting a key share — mirror of the wallet's walletShareRequests[]. - * Returned to spenders/admins and serviced via addMember. - */ -export interface VaultShareRequest { - userId: string; - permissions: VaultPermission[]; - createdAt: string; -} - -export interface VaultData { - id: string; - enterpriseId: string; - label: string; - // freeze is NOT a status — a frozen vault stays 'active' with the freeze field set (wallet precedent) - status: 'initializing' | 'active' | 'archived'; - creator: string; - users: VaultMembershipData[]; - vaultShareRequests?: VaultShareRequest[]; - freeze?: { time?: string; expires?: string; reason?: string }; - rootKeys?: VaultRootKeys; // ordered, like wallet.keys - archivedAt?: string; - createdAt: string; -} +export type RootKeyType = t.TypeOf; +export type VaultPermission = t.TypeOf; +export type VaultRootKeys = t.TypeOf; +export type VaultMembershipData = t.TypeOf; +export type VaultShareRequest = t.TypeOf; +export type VaultData = t.TypeOf; +export type VaultShareState = t.TypeOf; +export type VaultShareKeychain = t.TypeOf; +export type VaultShareData = t.TypeOf; export interface InitializeVaultOptions { - label: string; // Phase 1 carries no key material — key generation happens in Phase 2 via the existing keychain APIs + label: string; } // Phase 3 — the client hands back the 12 key ids it created in Phase 2: @@ -60,40 +29,6 @@ export interface FinalizeVaultOptions { rootKeys: VaultRootKeys; } -/** Vault key-share states — identical to WalletShare states, no new states. */ -export type VaultShareState = 'pendingapproval' | 'active' | 'accepted' | 'canceled' | 'rejected'; - -/** - * One of the 4 root USER keyshares carried on a VaultShare, ECDH-re-encrypted to the recipient. - * Body semantics land in the Part VI SDK ticket (WCN-1204). - */ -export interface VaultShareKeychain { - rootKeyType: RootKeyType; - rootKeyId: string; - encryptedPrv: string; - publicIdentifier: string; - fromPubKey: string; - toPubKey: string; - path: string; -} - -export interface VaultShareData { - id: string; - enterpriseId: string; - vaultId: string; - vaultLabel?: string; - fromUser: string; - toUser: string; - permissions: VaultPermission[]; - state: VaultShareState; - message?: string; - pendingApprovalId?: string; - isUMSInitiated?: boolean; - keychains?: VaultShareKeychain[]; - createdAt: string; - updatedAt?: string; -} - /** * Sharing ONE vault wallet with a non-member rides the existing wallet-share handshake (FR-13), * so the result is the existing WalletShare shape. @@ -106,34 +41,45 @@ export interface CreateVaultWalletOptions { coin: string; label: string; type?: string; - multisigType?: string; multisigTypeVersion?: string; } -export interface AddVaultMemberOptions { - userId?: string; - email?: string; +interface AddVaultMemberBase { permissions: VaultPermission[]; - // required when 'spend' is included — the 4 root user keys ECDH-re-encrypted to the invitee + /** required when 'spend' is included — the 4 root user keys ECDH-re-encrypted to the invitee */ keychains?: VaultShareKeychain[]; message?: string; + /** when true, suppress the invitation email that would otherwise be sent to `email` */ disableEmail?: boolean; } +/** Add a member by either `userId` or `email` — exactly one is required. */ +export type AddVaultMemberOptions = + | (AddVaultMemberBase & { userId: string; email?: never }) + | (AddVaultMemberBase & { email: string; userId?: never }); + export interface AddVaultWalletMemberOptions { walletId: string; + /** required — sharing re-encrypts the user key, which needs hardened derivation from the passphrase */ + walletPassphrase: string; email?: string; permissions?: string[]; message?: string; } -export interface AcceptVaultShareOptions { +export type AcceptVaultShareAsSpenderOptions = { vaultShareId: string; - userPassword?: string; + userPassword: string; newWalletPassphrase?: string; - overrideEncryptedPrv?: string; -} +}; +export type AcceptVaultShareAsNonSpenderOptions = { + vaultShareId: string; +}; +export type AcceptVaultShareOptions = AcceptVaultShareAsSpenderOptions | AcceptVaultShareAsNonSpenderOptions; +/** + * @experimental + */ export interface IVault { id(): string; enterpriseId(): string; @@ -144,7 +90,7 @@ export interface IVault { // whole-vault: view/admin/spend; spend opens a key share (also how a spender services a // vaultShareRequests entry in UMS orgs) addMember(params: AddVaultMemberOptions): Promise; - // share ONE vault wallet (FR-13), not the whole vault + // share ONE vault wallet, not the whole vault addMemberToWallet(params: AddVaultWalletMemberOptions): Promise; listShares(params?: { state?: VaultShareState }): Promise; acceptShare(params: AcceptVaultShareOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/vault/iVaults.ts b/modules/sdk-core/src/bitgo/vault/iVaults.ts index 5f6c31d21b..5be7a790b8 100644 --- a/modules/sdk-core/src/bitgo/vault/iVaults.ts +++ b/modules/sdk-core/src/bitgo/vault/iVaults.ts @@ -1,11 +1,14 @@ /** * @prettier + * + * @experimental The vault client surface is experimental and may change (including breaking + * changes) before the public release. */ -import { InitializeVaultOptions, FinalizeVaultOptions } from './iVault'; +import { FinalizeVaultOptions, InitializeVaultOptions } from './iVault'; import { Vault } from './vault'; /** - * Options for the createVault orchestrator. + * Options for vault creation. * * v1 targets the HOT custody model only: the multisig root user/backup keys are generated locally * by the SDK, encrypted with `passphrase`, and registered on BitGo; the MPC roots run the standard @@ -17,6 +20,17 @@ export interface CreateVaultOptions { passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies } +/** Handle returned by `initializeVault`, threaded into the key ceremonies and finalize. */ +export interface VaultCreationHandle { + vaultId: string; +} + +/** + * The 12 minted root key ids produced by `createVaultKeys`, as 4 ordered [user, backup, bitgo] + * triplets — exactly the payload `finalizeVault` consumes. + */ +export type VaultKeys = FinalizeVaultOptions; + export interface ListVaultsOptions { cursor?: string; // opaque cursor from a previous response's nextCursor limit?: number; @@ -26,10 +40,20 @@ export interface GetVaultOptions { id: string; } +/** + * @experimental + */ export interface IVaults { - // orchestrates: initialize → 4 root key flows (vaultId-tagged) → finalize → keycard - createVault(params: CreateVaultOptions): Promise; + /** + * One-call convenience wrapper: initialize → createVaultKeys (4 vaultId-tagged ceremonies) → + * finalize → keycard. HOT custody only in v1. + */ + generateVault(params: CreateVaultOptions): Promise; + /** Phase 1 — initialize a vault (metadata only, no key material). */ initializeVault(params: InitializeVaultOptions): Promise; + /** Phase 2 — run the 4 root key ceremonies tagged with `vaultId`; returns the 12 minted key ids. */ + createVaultKeys(params: CreateVaultOptions & VaultCreationHandle): Promise; + /** Phase 3 — finalize a vault with the 12 root key ids. Idempotent. */ finalizeVault(vaultId: string, params: FinalizeVaultOptions): Promise; list(params?: ListVaultsOptions): Promise<{ vaults: Vault[]; nextCursor?: string }>; get(params: GetVaultOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/vault/vault.ts b/modules/sdk-core/src/bitgo/vault/vault.ts index 4c2b6327ca..4d6da4c797 100644 --- a/modules/sdk-core/src/bitgo/vault/vault.ts +++ b/modules/sdk-core/src/bitgo/vault/vault.ts @@ -1,10 +1,15 @@ /** * @prettier + * + * @experimental The vault client surface is experimental and may change (including breaking + * changes) before the public release. */ import * as _ from 'lodash'; -import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; +import { decodeWithCodec } from '../utils/codecs'; +import { postWithCodec } from '../utils/postWithCodec'; import { FreezeOptions, Wallet } from '../wallet'; +import * as VaultCodecs from './codecs'; import { AcceptVaultShareOptions, AddVaultMemberOptions, @@ -17,14 +22,15 @@ import { WalletShareData, } from './iVault'; +/** + * @experimental + */ export class Vault implements IVault { private readonly bitgo: BitGoBase; - private readonly baseCoin: IBaseCoin; public readonly _vault: VaultData; - constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, vaultData: VaultData) { + constructor(bitgo: BitGoBase, vaultData: VaultData) { this.bitgo = bitgo; - this.baseCoin = baseCoin; if (!_.isObject(vaultData)) { throw new Error('vaultData has to be an object'); } @@ -106,7 +112,8 @@ export class Vault implements IVault { * @param params */ async freeze(params: FreezeOptions = {}): Promise { - return (await this.bitgo.post(this.url('/freeze')).send(params).result()) as VaultData; + const response = await postWithCodec(this.bitgo, this.url('/freeze'), VaultCodecs.FreezeVaultBody, params).result(); + return decodeWithCodec(VaultCodecs.VaultData, response, 'VaultData'); } /** @@ -114,7 +121,8 @@ export class Vault implements IVault { * path for a stuck 'initializing' vault. */ async archive(): Promise { - return (await this.bitgo.post(this.url('/archive')).send().result()) as VaultData; + const response = await this.bitgo.post(this.url('/archive')).send().result(); + return decodeWithCodec(VaultCodecs.VaultData, response, 'VaultData'); } toJSON(): VaultData { diff --git a/modules/sdk-core/src/bitgo/vault/vaults.ts b/modules/sdk-core/src/bitgo/vault/vaults.ts index 788c9cf88d..51c325b2f0 100644 --- a/modules/sdk-core/src/bitgo/vault/vaults.ts +++ b/modules/sdk-core/src/bitgo/vault/vaults.ts @@ -1,25 +1,34 @@ /** * @prettier + * + * @experimental The vault client surface is experimental and may change (including breaking + * changes) before the public release. */ -import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { FinalizeVaultOptions, InitializeVaultOptions } from './iVault'; -import { CreateVaultOptions, GetVaultOptions, IVaults, ListVaultsOptions } from './iVaults'; +import { + CreateVaultOptions, + GetVaultOptions, + IVaults, + ListVaultsOptions, + VaultCreationHandle, + VaultKeys, +} from './iVaults'; import { Vault } from './vault'; /** * Collection accessor for a single enterprise's vaults, mirroring Wallets / Enterprises. * Vault routes are enterprise-scoped (/api/v2/enterprise/:eId/vaults), so the accessor is * constructed with the enterprise id (see Enterprise.vaults()). + * + * @experimental */ export class Vaults implements IVaults { private readonly bitgo: BitGoBase; - private readonly baseCoin: IBaseCoin; private readonly enterpriseId: string; - constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, enterpriseId: string) { + constructor(bitgo: BitGoBase, enterpriseId: string) { this.bitgo = bitgo; - this.baseCoin = baseCoin; this.enterpriseId = enterpriseId; } @@ -32,11 +41,11 @@ export class Vaults implements IVaults { } /** - * One-call orchestrator: initialize → 4 root key ceremonies (vaultId-tagged) → finalize → keycard. - * HOT custody only in v1. Implemented in WCN-1192 Phase 2. + * One-call convenience wrapper: initialize → createVaultKeys (4 vaultId-tagged ceremonies) → + * finalize → keycard. HOT custody only. Implemented in WCN-1192 Phase 2. */ - async createVault(params: CreateVaultOptions): Promise { - throw new Error('Vaults.createVault is not yet implemented (WCN-1192 Phase 2)'); + async generateVault(params: CreateVaultOptions): Promise { + throw new Error('Vaults.generateVault is not yet implemented (WCN-1192 Phase 2)'); } /** @@ -47,6 +56,14 @@ export class Vaults implements IVaults { throw new Error('Vaults.initializeVault is not yet implemented (WCN-1192 Phase 2)'); } + /** + * Phase 2 — run the 4 root key ceremonies tagged with `vaultId`; returns the 12 minted key ids. + * Implemented in WCN-1192 Phase 2 (blocked on WCN-1176). + */ + async createVaultKeys(params: CreateVaultOptions & VaultCreationHandle): Promise { + throw new Error('Vaults.createVaultKeys is not yet implemented (WCN-1192 Phase 2)'); + } + /** * Phase 3 — finalize a vault with the 12 root key ids as 4 ordered [user, backup, bitgo] triplets. * Idempotent. Implemented in WCN-1192 Phase 2 (blocked on WCN-1175). diff --git a/modules/sdk-core/test/unit/bitgo/vault/vault.ts b/modules/sdk-core/test/unit/bitgo/vault/vault.ts index 6e4eec998f..8f78cc85dc 100644 --- a/modules/sdk-core/test/unit/bitgo/vault/vault.ts +++ b/modules/sdk-core/test/unit/bitgo/vault/vault.ts @@ -5,18 +5,25 @@ import { Vault, VaultData } from '../../../../src'; describe('Vault', function () { let vault: Vault; let mockBitGo: any; - let mockBaseCoin: any; let vaultData: VaultData; + // wire-shaped VaultData (timestamps as ISO strings) for mocked REST responses that get decoded + let vaultDataWire: any; beforeEach(function () { mockBitGo = { url: sinon.stub().callsFake((path: string) => path), post: sinon.stub(), }; - mockBaseCoin = { - url: sinon.stub().callsFake((path: string) => path), - }; vaultData = { + id: 'test-vault-id', + enterpriseId: 'test-enterprise-id', + label: 'my vault', + status: 'active', + creator: 'creator-id', + users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], + createdAt: new Date('2026-07-07T00:00:00.000Z'), + }; + vaultDataWire = { id: 'test-vault-id', enterpriseId: 'test-enterprise-id', label: 'my vault', @@ -25,7 +32,7 @@ describe('Vault', function () { users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], createdAt: '2026-07-07T00:00:00.000Z', }; - vault = new Vault(mockBitGo, mockBaseCoin, vaultData); + vault = new Vault(mockBitGo, vaultData); }); afterEach(function () { @@ -34,20 +41,18 @@ describe('Vault', function () { describe('constructor', function () { it('throws when vaultData is not an object', function () { - (() => new Vault(mockBitGo, mockBaseCoin, undefined as unknown as VaultData)).should.throw( - 'vaultData has to be an object' - ); + (() => new Vault(mockBitGo, undefined as unknown as VaultData)).should.throw('vaultData has to be an object'); }); it('throws when id is not a string', function () { - (() => new Vault(mockBitGo, mockBaseCoin, { ...vaultData, id: 123 as unknown as string })).should.throw( + (() => new Vault(mockBitGo, { ...vaultData, id: 123 as unknown as string })).should.throw( 'vault id has to be a string' ); }); it('throws when enterpriseId is not a string', function () { (() => - new Vault(mockBitGo, mockBaseCoin, { + new Vault(mockBitGo, { ...vaultData, enterpriseId: undefined as unknown as string, })).should.throw('vault enterpriseId has to be a string'); @@ -83,27 +88,23 @@ describe('Vault', function () { return { sendStub }; } - it('archive sends an empty body', async function () { - const { sendStub } = stubPost({ ...vaultData, status: 'archived' as const }); - await vault.archive(); - sendStub.calledWithExactly().should.be.true(); - }); - - it('freeze posts to the freeze route and returns VaultData', async function () { - const frozen = { ...vaultData, freeze: { reason: 'incident' } }; + it('freeze posts the encoded body to the freeze route and decodes VaultData', async function () { + const frozen = { ...vaultDataWire, freeze: { reason: 'incident' } }; const { sendStub } = stubPost(frozen); const result = await vault.freeze({ duration: 3600 }); mockBitGo.post.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id/freeze').should.be.true(); sendStub.calledWith({ duration: 3600 }).should.be.true(); - result.should.equal(frozen); + result.freeze!.reason!.should.equal('incident'); + result.createdAt.should.be.instanceof(Date); }); - it('archive posts to the archive route and returns VaultData', async function () { - const archived = { ...vaultData, status: 'archived' as const }; - stubPost(archived); + it('archive posts an empty body to the archive route and decodes VaultData', async function () { + const archived = { ...vaultDataWire, status: 'archived' as const }; + const { sendStub } = stubPost(archived); const result = await vault.archive(); mockBitGo.post.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id/archive').should.be.true(); - result.should.equal(archived); + sendStub.calledWithExactly().should.be.true(); + result.status.should.equal('archived'); }); }); @@ -113,11 +114,11 @@ describe('Vault', function () { }); it('addMember throws not-implemented (WCN-1204)', async function () { - await vault.addMember({ permissions: ['view'] }).should.be.rejectedWith(/WCN-1204/); + await vault.addMember({ userId: 'u', permissions: ['view'] }).should.be.rejectedWith(/WCN-1204/); }); it('addMemberToWallet throws not-implemented (WCN-1204)', async function () { - await vault.addMemberToWallet({ walletId: 'w' }).should.be.rejectedWith(/WCN-1204/); + await vault.addMemberToWallet({ walletId: 'w', walletPassphrase: 'p' }).should.be.rejectedWith(/WCN-1204/); }); it('listShares throws not-implemented (WCN-1204)', async function () { diff --git a/modules/sdk-core/test/unit/bitgo/vault/vaults.ts b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts index f6baa199bb..49a78114a5 100644 --- a/modules/sdk-core/test/unit/bitgo/vault/vaults.ts +++ b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts @@ -5,33 +5,38 @@ import { Enterprise, Vaults } from '../../../../src'; describe('Vaults', function () { let vaults: Vaults; let mockBitGo: any; - let mockBaseCoin: any; beforeEach(function () { mockBitGo = { url: sinon.stub().callsFake((path: string) => path), }; - mockBaseCoin = { - url: sinon.stub().callsFake((path: string) => path), - }; - vaults = new Vaults(mockBitGo, mockBaseCoin, 'test-enterprise-id'); + vaults = new Vaults(mockBitGo, 'test-enterprise-id'); }); afterEach(function () { sinon.restore(); }); - // Phase 2 (createVault/initialize/finalize) and Phase 3 (list/get) are not yet implemented — - // they are gated on WCN-1175 / WCN-1177. Assert the interface is wired and stubbed for now. + // Phase 2 (generateVault/initialize/createVaultKeys/finalize) and Phase 3 (list/get) are not yet + // implemented — they are gated on WCN-1175 / WCN-1176 / WCN-1177. Assert the interface is wired + // and stubbed for now. describe('unimplemented lifecycle methods', function () { - it('createVault throws (Phase 2)', async function () { - await vaults.createVault({ label: 'v', passphrase: 'p' }).should.be.rejectedWith(/not yet implemented .*Phase 2/); + it('generateVault throws (Phase 2)', async function () { + await vaults + .generateVault({ label: 'v', passphrase: 'p' }) + .should.be.rejectedWith(/not yet implemented .*Phase 2/); }); it('initializeVault throws (Phase 2)', async function () { await vaults.initializeVault({ label: 'v' }).should.be.rejectedWith(/not yet implemented .*Phase 2/); }); + it('createVaultKeys throws (Phase 2)', async function () { + await vaults + .createVaultKeys({ label: 'v', passphrase: 'p', vaultId: 'vid' }) + .should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + it('finalizeVault throws (Phase 2)', async function () { await vaults .finalizeVault('vid', { @@ -56,7 +61,7 @@ describe('Vaults', function () { describe('Enterprise.vaults() accessor', function () { it('returns a Vaults instance scoped to the enterprise', function () { - const enterprise = new Enterprise(mockBitGo, mockBaseCoin, { id: 'ent-id', name: 'ent' }); + const enterprise = new Enterprise(mockBitGo, {} as any, { id: 'ent-id', name: 'ent' }); enterprise.vaults().should.be.instanceof(Vaults); }); }); From 7da4e23d96e01190976ff56f18d356ca100f45a8 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Thu, 9 Jul 2026 14:31:54 -0400 Subject: [PATCH 3/4] feat: nest vault keys under walletType WCN-1192 TICKET: WCN-1192 --- modules/sdk-core/src/bitgo/vault/codecs.ts | 30 ++++++++++++++++--- modules/sdk-core/src/bitgo/vault/iVault.ts | 4 ++- .../sdk-core/test/unit/bitgo/vault/vaults.ts | 10 ++++--- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/modules/sdk-core/src/bitgo/vault/codecs.ts b/modules/sdk-core/src/bitgo/vault/codecs.ts index 5dc774d30f..c4484f9ed7 100644 --- a/modules/sdk-core/src/bitgo/vault/codecs.ts +++ b/modules/sdk-core/src/bitgo/vault/codecs.ts @@ -42,14 +42,37 @@ export const VaultPermission = t.keyof( /** An ordered [userKeyId, backupKeyId, bitgoKeyId] triplet — same shape/order as wallet.keys[]. */ export const RootKeyTriplet = t.tuple([t.string, t.string, t.string], 'RootKeyTriplet'); -/** The 12 static root key ids, by (curve, scheme). */ -export const VaultRootKeys = t.type( +/** The 12 root key ids for a single custody model, keyed by (curve, scheme). */ +export const RootKeysByType = t.type( { secp256k1Multisig: RootKeyTriplet, ecdsaMpc: RootKeyTriplet, eddsaMpc: RootKeyTriplet, ed25519Multisig: RootKeyTriplet, }, + 'RootKeysByType' +); + +/** Custody models a vault's roots can be created under. v1 implements `hot` only. */ +export const VaultCustodyType = t.keyof( + { + hot: null, + cold: null, + custodial: null, + }, + 'VaultCustodyType' +); + +/** + * A vault's root keys grouped by custody model — each model holds its own set of 4 (curve, scheme) + * root triplets. Only `hot` is populated in v1; `cold`/`custodial` are reserved for later phases. + */ +export const VaultRootKeys = t.partial( + { + hot: RootKeysByType, + cold: RootKeysByType, + custodial: RootKeysByType, + }, 'VaultRootKeys' ); @@ -100,7 +123,6 @@ export const VaultData = t.intersection( id: t.string, enterpriseId: t.string, label: t.string, - // freeze is NOT a status — a frozen vault stays 'active' with the freeze field set (wallet precedent) status: VaultStatus, creator: t.string, users: t.array(VaultMembershipData), @@ -174,5 +196,5 @@ export const InitializeVaultBody = t.type({ label: t.string }, 'InitializeVaultB /** POST /enterprise/:eId/vaults/:vId/finalize — the 12 key ids as 4 ordered triplets. */ export const FinalizeVaultBody = t.type({ rootKeys: VaultRootKeys }, 'FinalizeVaultBody'); -/** POST/DELETE /enterprise/:eId/vaults/:vId/freeze */ +/** POST /enterprise/:eId/vaults/:vId/freeze */ export const FreezeVaultBody = t.partial({ duration: t.number }, 'FreezeVaultBody'); diff --git a/modules/sdk-core/src/bitgo/vault/iVault.ts b/modules/sdk-core/src/bitgo/vault/iVault.ts index 9d6573617a..49d769921f 100644 --- a/modules/sdk-core/src/bitgo/vault/iVault.ts +++ b/modules/sdk-core/src/bitgo/vault/iVault.ts @@ -12,6 +12,8 @@ import * as VaultCodecs from './codecs'; export type RootKeyType = t.TypeOf; export type VaultPermission = t.TypeOf; +export type VaultCustodyType = t.TypeOf; +export type RootKeysByType = t.TypeOf; export type VaultRootKeys = t.TypeOf; export type VaultMembershipData = t.TypeOf; export type VaultShareRequest = t.TypeOf; @@ -87,7 +89,7 @@ export interface IVault { status(): VaultData['status']; url(extra?: string): string; createWallet(params: CreateVaultWalletOptions): Promise; - // whole-vault: view/admin/spend; spend opens a key share (also how a spender services a + // whole-vault: view/admin/spend/dapp; spend opens a key share (also how a spender services a // vaultShareRequests entry in UMS orgs) addMember(params: AddVaultMemberOptions): Promise; // share ONE vault wallet, not the whole vault diff --git a/modules/sdk-core/test/unit/bitgo/vault/vaults.ts b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts index 49a78114a5..4f10572b25 100644 --- a/modules/sdk-core/test/unit/bitgo/vault/vaults.ts +++ b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts @@ -41,10 +41,12 @@ describe('Vaults', function () { await vaults .finalizeVault('vid', { rootKeys: { - secp256k1Multisig: ['u', 'b', 'g'], - ecdsaMpc: ['u', 'b', 'g'], - eddsaMpc: ['u', 'b', 'g'], - ed25519Multisig: ['u', 'b', 'g'], + hot: { + secp256k1Multisig: ['u', 'b', 'g'], + ecdsaMpc: ['u', 'b', 'g'], + eddsaMpc: ['u', 'b', 'g'], + ed25519Multisig: ['u', 'b', 'g'], + }, }, }) .should.be.rejectedWith(/not yet implemented .*Phase 2/); From 2fd4e0350734cd26a62210b4051eb357e0529f4f Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Thu, 9 Jul 2026 21:13:05 -0400 Subject: [PATCH 4/4] feat: rename vault to safe WCN-1192 TICKET: WCN-1192 --- .../src/bitgo/enterprise/enterprise.ts | 8 +- .../src/bitgo/enterprise/iEnterprise.ts | 4 +- modules/sdk-core/src/bitgo/index.ts | 2 +- .../src/bitgo/{vault => safe}/codecs.ts | 96 ++++++------- modules/sdk-core/src/bitgo/safe/iSafe.ts | 102 ++++++++++++++ modules/sdk-core/src/bitgo/safe/iSafes.ts | 60 ++++++++ modules/sdk-core/src/bitgo/safe/index.ts | 4 + modules/sdk-core/src/bitgo/safe/safe.ts | 131 ++++++++++++++++++ modules/sdk-core/src/bitgo/safe/safes.ts | 83 +++++++++++ modules/sdk-core/src/bitgo/vault/iVault.ts | 102 -------------- modules/sdk-core/src/bitgo/vault/iVaults.ts | 60 -------- modules/sdk-core/src/bitgo/vault/index.ts | 4 - modules/sdk-core/src/bitgo/vault/vault.ts | 131 ------------------ modules/sdk-core/src/bitgo/vault/vaults.ts | 90 ------------ .../bitgo/{vault/vault.ts => safe/safe.ts} | 82 +++++------ .../sdk-core/test/unit/bitgo/safe/safes.ts | 68 +++++++++ .../sdk-core/test/unit/bitgo/vault/vaults.ts | 70 ---------- 17 files changed, 544 insertions(+), 553 deletions(-) rename modules/sdk-core/src/bitgo/{vault => safe}/codecs.ts (57%) create mode 100644 modules/sdk-core/src/bitgo/safe/iSafe.ts create mode 100644 modules/sdk-core/src/bitgo/safe/iSafes.ts create mode 100644 modules/sdk-core/src/bitgo/safe/index.ts create mode 100644 modules/sdk-core/src/bitgo/safe/safe.ts create mode 100644 modules/sdk-core/src/bitgo/safe/safes.ts delete mode 100644 modules/sdk-core/src/bitgo/vault/iVault.ts delete mode 100644 modules/sdk-core/src/bitgo/vault/iVaults.ts delete mode 100644 modules/sdk-core/src/bitgo/vault/index.ts delete mode 100644 modules/sdk-core/src/bitgo/vault/vault.ts delete mode 100644 modules/sdk-core/src/bitgo/vault/vaults.ts rename modules/sdk-core/test/unit/bitgo/{vault/vault.ts => safe/safe.ts} (54%) create mode 100644 modules/sdk-core/test/unit/bitgo/safe/safes.ts delete mode 100644 modules/sdk-core/test/unit/bitgo/vault/vaults.ts diff --git a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts index 1b1b0cb0c5..da4f7e6124 100644 --- a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts +++ b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts @@ -7,7 +7,7 @@ import { BitGoBase } from '../bitgoBase'; import { EnterpriseData, EnterpriseFeatureFlag, IEnterprise } from '../enterprise'; import { getFirstPendingTransaction } from '../internal'; import { ListWalletOptions, Wallet } from '../wallet'; -import { Vaults } from '../vault'; +import { Safes } from '../safe'; import { BitGoProofSignatures, EcdsaUtils, SerializedNtildeWithVerifiers } from '../utils/tss/ecdsa'; import { EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { verifyEcdhSignature } from '../ecdh'; @@ -252,10 +252,10 @@ export class Enterprise implements IEnterprise { } /** - * Get the vaults collection accessor scoped to this Enterprise + * Get the safes collection accessor scoped to this Enterprise * @experimental */ - vaults(): Vaults { - return new Vaults(this.bitgo, this.id); + safes(): Safes { + return new Safes(this.bitgo, this.id); } } diff --git a/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts b/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts index 614eb22b82..e2727916a9 100644 --- a/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts +++ b/modules/sdk-core/src/bitgo/enterprise/iEnterprise.ts @@ -3,7 +3,7 @@ import { IWallet } from '../wallet'; import { Buffer } from 'buffer'; import { BitGoProofSignatures, SerializedNtildeWithVerifiers } from '../utils/tss/ecdsa'; import { EcdhDerivedKeypair } from '../keychain'; -import { IVaults } from '../vault'; +import { ISafes } from '../safe'; // useEnterpriseEcdsaTssChallenge is deprecated export type EnterpriseFeatureFlag = 'useEnterpriseEcdsaTssChallenge'; @@ -41,5 +41,5 @@ export interface IEnterprise { ): Promise; hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean; /** @experimental */ - vaults(): IVaults; + safes(): ISafes; } diff --git a/modules/sdk-core/src/bitgo/index.ts b/modules/sdk-core/src/bitgo/index.ts index 965df3d260..18b116ce4c 100644 --- a/modules/sdk-core/src/bitgo/index.ts +++ b/modules/sdk-core/src/bitgo/index.ts @@ -27,7 +27,7 @@ export * from './tss'; export { sendSignatureShare } from './tss'; export * from './types'; export * from './utils'; -export * from './vault'; +export * from './safe'; export * from './wallet'; export * from './webhook'; export { bitcoinUtil }; diff --git a/modules/sdk-core/src/bitgo/vault/codecs.ts b/modules/sdk-core/src/bitgo/safe/codecs.ts similarity index 57% rename from modules/sdk-core/src/bitgo/vault/codecs.ts rename to modules/sdk-core/src/bitgo/safe/codecs.ts index c4484f9ed7..3dab6848d5 100644 --- a/modules/sdk-core/src/bitgo/vault/codecs.ts +++ b/modules/sdk-core/src/bitgo/safe/codecs.ts @@ -1,10 +1,10 @@ /** * @prettier * - * io-ts codecs for the vault REST surface. These are the single source of truth for the - * vault data shapes: the TypeScript interfaces in `iVault.ts` are derived from them via + * io-ts codecs for the safe REST surface. These are the single source of truth for the + * safe data shapes: the TypeScript interfaces in `iSafe.ts` are derived from them via * `t.TypeOf`, request bodies are encoded with `postWithCodec`, and responses are decoded - * (and validated) with `decodeWithCodec` — so there are no `as VaultData` casts. + * (and validated) with `decodeWithCodec` — so there are no `as SafeData` casts. * * Timestamps use `DateFromISOString`: the wire representation stays an ISO-8601 string while * the decoded object exposes a real `Date`. @@ -13,7 +13,7 @@ import * as t from 'io-ts'; import { DateFromISOString } from 'io-ts-types'; /** - * The four static root-key slots of a vault, keyed by (curve, scheme): + * The four static root-key slots of a safe, keyed by (curve, scheme): * - secp256k1Multisig ① — ex. UTXO/XRP/XTZ/TRX/EOS * - ecdsaMpc ② — ex. EVM/Cosmos (DKLS) * - eddsaMpc ③ — ex. SOL/SUI/NEAR/TON/APT/DOT @@ -29,14 +29,14 @@ export const RootKeyType = t.keyof( 'RootKeyType' ); -export const VaultPermission = t.keyof( +export const SafePermission = t.keyof( { view: null, spend: null, admin: null, dapp: null, }, - 'VaultPermission' + 'SafePermission' ); /** An ordered [userKeyId, backupKeyId, bitgoKeyId] triplet — same shape/order as wallet.keys[]. */ @@ -53,93 +53,93 @@ export const RootKeysByType = t.type( 'RootKeysByType' ); -/** Custody models a vault's roots can be created under. v1 implements `hot` only. */ -export const VaultCustodyType = t.keyof( +/** Custody models a safe's roots can be created under. v1 implements `hot` only. */ +export const SafeCustodyType = t.keyof( { hot: null, cold: null, custodial: null, }, - 'VaultCustodyType' + 'SafeCustodyType' ); /** - * A vault's root keys grouped by custody model — each model holds its own set of 4 (curve, scheme) + * A safe's root keys grouped by custody model — each model holds its own set of 4 (curve, scheme) * root triplets. Only `hot` is populated in v1; `cold`/`custodial` are reserved for later phases. */ -export const VaultRootKeys = t.partial( +export const SafeRootKeys = t.partial( { hot: RootKeysByType, cold: RootKeysByType, custodial: RootKeysByType, }, - 'VaultRootKeys' + 'SafeRootKeys' ); -export const VaultMembershipData = t.intersection( +export const SafeMembershipData = t.intersection( [ t.type({ userId: t.string, - permissions: t.array(VaultPermission), + permissions: t.array(SafePermission), }), t.partial({ needsRecovery: t.boolean, }), ], - 'VaultMembershipData' + 'SafeMembershipData' ); /** A pending UMS spend grant awaiting a key share — mirror of the wallet's walletShareRequests[]. */ -export const VaultShareRequest = t.type( +export const SafeShareRequest = t.type( { userId: t.string, - permissions: t.array(VaultPermission), + permissions: t.array(SafePermission), createdAt: DateFromISOString, }, - 'VaultShareRequest' + 'SafeShareRequest' ); -export const VaultFreeze = t.partial( +export const SafeFreeze = t.partial( { time: DateFromISOString, expires: DateFromISOString, reason: t.string, }, - 'VaultFreeze' + 'SafeFreeze' ); -export const VaultStatus = t.keyof( +export const SafeStatus = t.keyof( { initializing: null, active: null, archived: null, }, - 'VaultStatus' + 'SafeStatus' ); -export const VaultData = t.intersection( +export const SafeData = t.intersection( [ t.type({ id: t.string, enterpriseId: t.string, label: t.string, - status: VaultStatus, + status: SafeStatus, creator: t.string, - users: t.array(VaultMembershipData), + users: t.array(SafeMembershipData), createdAt: DateFromISOString, }), t.partial({ - vaultShareRequests: t.array(VaultShareRequest), - freeze: VaultFreeze, - rootKeys: VaultRootKeys, + safeShareRequests: t.array(SafeShareRequest), + freeze: SafeFreeze, + rootKeys: SafeRootKeys, archivedAt: DateFromISOString, }), ], - 'VaultData' + 'SafeData' ); -/** Vault key-share states — identical to WalletShare states, no new states. */ -export const VaultShareState = t.keyof( +/** Safe key-share states — identical to WalletShare states, no new states. */ +export const SafeShareState = t.keyof( { pendingapproval: null, active: null, @@ -147,11 +147,11 @@ export const VaultShareState = t.keyof( canceled: null, rejected: null, }, - 'VaultShareState' + 'SafeShareState' ); -/** One of the 4 root USER keyshares carried on a VaultShare, ECDH-re-encrypted to the recipient. */ -export const VaultShareKeychain = t.type( +/** One of the 4 root USER keyshares carried on a SafeShare, ECDH-re-encrypted to the recipient. */ +export const SafeShareKeychain = t.type( { rootKeyType: RootKeyType, rootKeyId: t.string, @@ -161,40 +161,40 @@ export const VaultShareKeychain = t.type( toPubKey: t.string, path: t.string, }, - 'VaultShareKeychain' + 'SafeShareKeychain' ); -export const VaultShareData = t.intersection( +export const SafeShareData = t.intersection( [ t.type({ id: t.string, enterpriseId: t.string, - vaultId: t.string, + safeId: t.string, fromUser: t.string, toUser: t.string, - permissions: t.array(VaultPermission), - state: VaultShareState, + permissions: t.array(SafePermission), + state: SafeShareState, createdAt: DateFromISOString, }), t.partial({ - vaultLabel: t.string, + safeLabel: t.string, message: t.string, pendingApprovalId: t.string, isUMSInitiated: t.boolean, - keychains: t.array(VaultShareKeychain), + keychains: t.array(SafeShareKeychain), updatedAt: DateFromISOString, }), ], - 'VaultShareData' + 'SafeShareData' ); // ---- request bodies ---- -/** POST /enterprise/:eId/vaults — Phase 1 carries no key material. */ -export const InitializeVaultBody = t.type({ label: t.string }, 'InitializeVaultBody'); +/** POST /enterprise/:eId/safes — Phase 1 carries no key material. */ +export const InitializeSafeBody = t.type({ label: t.string }, 'InitializeSafeBody'); -/** POST /enterprise/:eId/vaults/:vId/finalize — the 12 key ids as 4 ordered triplets. */ -export const FinalizeVaultBody = t.type({ rootKeys: VaultRootKeys }, 'FinalizeVaultBody'); +/** POST /enterprise/:eId/safes/:safeId/finalize — the 12 key ids as 4 ordered triplets. */ +export const FinalizeSafeBody = t.type({ rootKeys: SafeRootKeys }, 'FinalizeSafeBody'); -/** POST /enterprise/:eId/vaults/:vId/freeze */ -export const FreezeVaultBody = t.partial({ duration: t.number }, 'FreezeVaultBody'); +/** POST /enterprise/:eId/safes/:safeId/freeze */ +export const FreezeSafeBody = t.partial({ duration: t.number }, 'FreezeSafeBody'); diff --git a/modules/sdk-core/src/bitgo/safe/iSafe.ts b/modules/sdk-core/src/bitgo/safe/iSafe.ts new file mode 100644 index 0000000000..275fdcb284 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/iSafe.ts @@ -0,0 +1,102 @@ +/** + * @prettier + * + * @experimental The safe client surface is experimental and may change (including breaking + * changes) before the public release. + */ +import * as t from 'io-ts'; +import type { FreezeOptions, Wallet, WalletShare } from '../wallet'; +import * as SafeCodecs from './codecs'; + +// ---- data shapes (derived from the io-ts codecs in ./codecs — single source of truth) ---- + +export type RootKeyType = t.TypeOf; +export type SafePermission = t.TypeOf; +export type SafeCustodyType = t.TypeOf; +export type RootKeysByType = t.TypeOf; +export type SafeRootKeys = t.TypeOf; +export type SafeMembershipData = t.TypeOf; +export type SafeShareRequest = t.TypeOf; +export type SafeData = t.TypeOf; +export type SafeShareState = t.TypeOf; +export type SafeShareKeychain = t.TypeOf; +export type SafeShareData = t.TypeOf; + +export interface InitializeSafeOptions { + label: string; +} + +// Phase 3 — the client hands back the 12 key ids it created in Phase 2: +export interface FinalizeSafeOptions { + rootKeys: SafeRootKeys; +} + +/** + * Sharing ONE safe wallet with a non-member rides the existing wallet-share handshake (FR-13), + * so the result is the existing WalletShare shape. + */ +export type WalletShareData = WalletShare; + +// ---- per-safe operation options (bodies land in WCN-1203 / WCN-1204) ---- + +export interface CreateSafeWalletOptions { + coin: string; + label: string; + type?: string; + multisigTypeVersion?: string; +} + +interface AddSafeMemberBase { + permissions: SafePermission[]; + /** required when 'spend' is included — the 4 root user keys ECDH-re-encrypted to the invitee */ + keychains?: SafeShareKeychain[]; + message?: string; + /** when true, suppress the invitation email that would otherwise be sent to `email` */ + disableEmail?: boolean; +} + +/** Add a member by either `userId` or `email` — exactly one is required. */ +export type AddSafeMemberOptions = + | (AddSafeMemberBase & { userId: string; email?: never }) + | (AddSafeMemberBase & { email: string; userId?: never }); + +export interface AddSafeWalletMemberOptions { + walletId: string; + /** required — sharing re-encrypts the user key, which needs hardened derivation from the passphrase */ + walletPassphrase: string; + email?: string; + permissions?: string[]; + message?: string; +} + +export type AcceptSafeShareAsSpenderOptions = { + safeShareId: string; + userPassword: string; + newWalletPassphrase?: string; +}; +export type AcceptSafeShareAsNonSpenderOptions = { + safeShareId: string; +}; +export type AcceptSafeShareOptions = AcceptSafeShareAsSpenderOptions | AcceptSafeShareAsNonSpenderOptions; + +/** + * @experimental + */ +export interface ISafe { + id(): string; + enterpriseId(): string; + label(): string; + status(): SafeData['status']; + url(extra?: string): string; + createWallet(params: CreateSafeWalletOptions): Promise; + // whole-safe: view/admin/spend/dapp; spend opens a key share (also how a spender services a + // safeShareRequests entry in UMS orgs) + addMember(params: AddSafeMemberOptions): Promise; + // share ONE safe wallet, not the whole safe + addMemberToWallet(params: AddSafeWalletMemberOptions): Promise; + listShares(params?: { state?: SafeShareState }): Promise; + acceptShare(params: AcceptSafeShareOptions): Promise; + freeze(params?: FreezeOptions): Promise; + archive(): Promise; + toJSON(): SafeData; +} diff --git a/modules/sdk-core/src/bitgo/safe/iSafes.ts b/modules/sdk-core/src/bitgo/safe/iSafes.ts new file mode 100644 index 0000000000..467d033f73 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/iSafes.ts @@ -0,0 +1,60 @@ +/** + * @prettier + * + * @experimental The safe client surface is experimental and may change (including breaking + * changes) before the public release. + */ +import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe'; +import { Safe } from './safe'; + +/** + * Options for safe creation. + * + * v1 targets the HOT custody model only: the multisig root user/backup keys are generated locally + * by the SDK, encrypted with `passphrase`, and registered on BitGo; the MPC roots run the standard + * hot ceremonies with the same passphrase. Self-managed cold keys and custodial safes are out of + * scope for v1. + */ +export interface CreateSafeOptions { + label: string; + passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies +} + +/** Handle returned by `initializeSafe`, threaded into the key ceremonies and finalize. */ +export interface SafeCreationHandle { + safeId: string; +} + +/** + * The 12 minted root key ids produced by `createSafeKeys`, as 4 ordered [user, backup, bitgo] + * triplets — exactly the payload `finalizeSafe` consumes. + */ +export type SafeKeys = FinalizeSafeOptions; + +export interface ListSafesOptions { + cursor?: string; // opaque cursor from a previous response's nextCursor + limit?: number; +} + +export interface GetSafeOptions { + id: string; +} + +/** + * @experimental + */ +export interface ISafes { + /** + * One-call convenience wrapper: initialize → createSafeKeys (4 safeId-tagged ceremonies) → + * finalize → keycard. HOT custody only in v1. + */ + generateSafe(params: CreateSafeOptions): Promise; + /** Phase 1 — initialize a safe (metadata only, no key material). */ + initializeSafe(params: InitializeSafeOptions): Promise; + /** Phase 2 — run the 4 root key ceremonies tagged with `safeId`; returns the 12 minted key ids. */ + createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise; + /** Phase 3 — finalize a safe with the 12 root key ids. Idempotent. */ + finalizeSafe(safeId: string, params: FinalizeSafeOptions): Promise; + list(params?: ListSafesOptions): Promise<{ safes: Safe[]; nextCursor?: string }>; + get(params: GetSafeOptions): Promise; +} diff --git a/modules/sdk-core/src/bitgo/safe/index.ts b/modules/sdk-core/src/bitgo/safe/index.ts new file mode 100644 index 0000000000..5a4f621ee3 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/index.ts @@ -0,0 +1,4 @@ +export * from './iSafe'; +export * from './iSafes'; +export * from './safe'; +export * from './safes'; diff --git a/modules/sdk-core/src/bitgo/safe/safe.ts b/modules/sdk-core/src/bitgo/safe/safe.ts new file mode 100644 index 0000000000..177b993a5f --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/safe.ts @@ -0,0 +1,131 @@ +/** + * @prettier + * + * @experimental The safe client surface is experimental and may change (including breaking + * changes) before the public release. + */ +import * as _ from 'lodash'; +import { BitGoBase } from '../bitgoBase'; +import { decodeWithCodec } from '../utils/codecs'; +import { postWithCodec } from '../utils/postWithCodec'; +import { FreezeOptions, Wallet } from '../wallet'; +import * as SafeCodecs from './codecs'; +import { + AcceptSafeShareOptions, + AddSafeMemberOptions, + AddSafeWalletMemberOptions, + CreateSafeWalletOptions, + ISafe, + SafeData, + SafeShareData, + SafeShareState, + WalletShareData, +} from './iSafe'; + +/** + * @experimental + */ +export class Safe implements ISafe { + private readonly bitgo: BitGoBase; + public readonly _safe: SafeData; + + constructor(bitgo: BitGoBase, safeData: SafeData) { + this.bitgo = bitgo; + if (!_.isObject(safeData)) { + throw new Error('safeData has to be an object'); + } + if (!_.isString(safeData.id)) { + throw new Error('safe id has to be a string'); + } + if (!_.isString(safeData.enterpriseId)) { + throw new Error('safe enterpriseId has to be a string'); + } + this._safe = safeData; + } + + id(): string { + return this._safe.id; + } + + enterpriseId(): string { + return this._safe.enterpriseId; + } + + label(): string { + return this._safe.label; + } + + status(): SafeData['status'] { + return this._safe.status; + } + + /** + * Enterprise-scoped v2 URL for this safe, e.g. /api/v2/enterprise/:eId/safes/:safeId + * @param extra + */ + url(extra = ''): string { + return this.bitgo.url(`/enterprise/${this.enterpriseId()}/safes/${this.id()}${extra}`, 2); + } + + /** + * Mint a child wallet in this safe (server-side public derivation — no ceremony). + * Body lands in WCN-1203. + */ + async createWallet(params: CreateSafeWalletOptions): Promise { + throw new Error('Safe.createWallet is not yet implemented (WCN-1203)'); + } + + /** + * Add a member to the whole safe (view/admin/spend). Spend opens a key share. + * Body lands in WCN-1204. + */ + async addMember(params: AddSafeMemberOptions): Promise { + throw new Error('Safe.addMember is not yet implemented (WCN-1204)'); + } + + /** + * Share ONE safe wallet with a non-member via the existing wallet-share handshake (FR-13). + * Body lands in WCN-1204. + */ + async addMemberToWallet(params: AddSafeWalletMemberOptions): Promise { + throw new Error('Safe.addMemberToWallet is not yet implemented (WCN-1204)'); + } + + /** + * List the safe key shares visible to the caller. + * Body lands in WCN-1204. + */ + async listShares(params: { state?: SafeShareState } = {}): Promise { + throw new Error('Safe.listShares is not yet implemented (WCN-1204)'); + } + + /** + * Accept a safe key share addressed to the caller. + * Body lands in WCN-1204. + */ + async acceptShare(params: AcceptSafeShareOptions): Promise { + throw new Error('Safe.acceptShare is not yet implemented (WCN-1204)'); + } + + /** + * Freeze the safe — blocks withdrawals on all safe wallets. Safe stays 'active'. + * @param params + */ + async freeze(params: FreezeOptions = {}): Promise { + const response = await postWithCodec(this.bitgo, this.url('/freeze'), SafeCodecs.FreezeSafeBody, params).result(); + return decodeWithCodec(SafeCodecs.SafeData, response, 'SafeData'); + } + + /** + * Archive the safe. Requires every safe wallet to already be archived; also the abandonment + * path for a stuck 'initializing' safe. + */ + async archive(): Promise { + const response = await this.bitgo.post(this.url('/archive')).send().result(); + return decodeWithCodec(SafeCodecs.SafeData, response, 'SafeData'); + } + + toJSON(): SafeData { + return this._safe; + } +} diff --git a/modules/sdk-core/src/bitgo/safe/safes.ts b/modules/sdk-core/src/bitgo/safe/safes.ts new file mode 100644 index 0000000000..230359bd94 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/safes.ts @@ -0,0 +1,83 @@ +/** + * @prettier + * + * @experimental The safe client surface is experimental and may change (including breaking + * changes) before the public release. + */ +import { BitGoBase } from '../bitgoBase'; +import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe'; +import { CreateSafeOptions, GetSafeOptions, ISafes, ListSafesOptions, SafeCreationHandle, SafeKeys } from './iSafes'; +import { Safe } from './safe'; + +/** + * Collection accessor for a single enterprise's safes, mirroring Wallets / Enterprises. + * Safe routes are enterprise-scoped (/api/v2/enterprise/:eId/safes), so the accessor is + * constructed with the enterprise id (see Enterprise.safes()). + * + * @experimental + */ +export class Safes implements ISafes { + private readonly bitgo: BitGoBase; + private readonly enterpriseId: string; + + constructor(bitgo: BitGoBase, enterpriseId: string) { + this.bitgo = bitgo; + this.enterpriseId = enterpriseId; + } + + /** + * Enterprise-scoped v2 URL for the safes collection, e.g. /api/v2/enterprise/:eId/safes + * @param extra + */ + private url(extra = ''): string { + return this.bitgo.url(`/enterprise/${this.enterpriseId}/safes${extra}`, 2); + } + + /** + * One-call convenience wrapper: initialize → createSafeKeys (4 safeId-tagged ceremonies) → + * finalize → keycard. HOT custody only. Implemented in WCN-1192 Phase 2. + */ + async generateSafe(params: CreateSafeOptions): Promise { + throw new Error('Safes.generateSafe is not yet implemented (WCN-1192 Phase 2)'); + } + + /** + * Phase 1 — initialize a safe (metadata only, no key material). + * Implemented in WCN-1192 Phase 2 (blocked on WCN-1175). + */ + async initializeSafe(params: InitializeSafeOptions): Promise { + throw new Error('Safes.initializeSafe is not yet implemented (WCN-1192 Phase 2)'); + } + + /** + * Phase 2 — run the 4 root key ceremonies tagged with `safeId`; returns the 12 minted key ids. + * Implemented in WCN-1192 Phase 2 (blocked on WCN-1176). + */ + async createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise { + throw new Error('Safes.createSafeKeys is not yet implemented (WCN-1192 Phase 2)'); + } + + /** + * Phase 3 — finalize a safe with the 12 root key ids as 4 ordered [user, backup, bitgo] triplets. + * Idempotent. Implemented in WCN-1192 Phase 2 (blocked on WCN-1175). + */ + async finalizeSafe(safeId: string, params: FinalizeSafeOptions): Promise { + throw new Error('Safes.finalizeSafe is not yet implemented (WCN-1192 Phase 2)'); + } + + /** + * List the enterprise's safes (cursor pagination). + * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). + */ + async list(params: ListSafesOptions = {}): Promise<{ safes: Safe[]; nextCursor?: string }> { + throw new Error('Safes.list is not yet implemented (WCN-1192 Phase 3)'); + } + + /** + * Fetch a single safe by id. + * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). + */ + async get(params: GetSafeOptions): Promise { + throw new Error('Safes.get is not yet implemented (WCN-1192 Phase 3)'); + } +} diff --git a/modules/sdk-core/src/bitgo/vault/iVault.ts b/modules/sdk-core/src/bitgo/vault/iVault.ts deleted file mode 100644 index 49d769921f..0000000000 --- a/modules/sdk-core/src/bitgo/vault/iVault.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @prettier - * - * @experimental The vault client surface is experimental and may change (including breaking - * changes) before the public release. - */ -import * as t from 'io-ts'; -import type { FreezeOptions, Wallet, WalletShare } from '../wallet'; -import * as VaultCodecs from './codecs'; - -// ---- data shapes (derived from the io-ts codecs in ./codecs — single source of truth) ---- - -export type RootKeyType = t.TypeOf; -export type VaultPermission = t.TypeOf; -export type VaultCustodyType = t.TypeOf; -export type RootKeysByType = t.TypeOf; -export type VaultRootKeys = t.TypeOf; -export type VaultMembershipData = t.TypeOf; -export type VaultShareRequest = t.TypeOf; -export type VaultData = t.TypeOf; -export type VaultShareState = t.TypeOf; -export type VaultShareKeychain = t.TypeOf; -export type VaultShareData = t.TypeOf; - -export interface InitializeVaultOptions { - label: string; -} - -// Phase 3 — the client hands back the 12 key ids it created in Phase 2: -export interface FinalizeVaultOptions { - rootKeys: VaultRootKeys; -} - -/** - * Sharing ONE vault wallet with a non-member rides the existing wallet-share handshake (FR-13), - * so the result is the existing WalletShare shape. - */ -export type WalletShareData = WalletShare; - -// ---- per-vault operation options (bodies land in WCN-1203 / WCN-1204) ---- - -export interface CreateVaultWalletOptions { - coin: string; - label: string; - type?: string; - multisigTypeVersion?: string; -} - -interface AddVaultMemberBase { - permissions: VaultPermission[]; - /** required when 'spend' is included — the 4 root user keys ECDH-re-encrypted to the invitee */ - keychains?: VaultShareKeychain[]; - message?: string; - /** when true, suppress the invitation email that would otherwise be sent to `email` */ - disableEmail?: boolean; -} - -/** Add a member by either `userId` or `email` — exactly one is required. */ -export type AddVaultMemberOptions = - | (AddVaultMemberBase & { userId: string; email?: never }) - | (AddVaultMemberBase & { email: string; userId?: never }); - -export interface AddVaultWalletMemberOptions { - walletId: string; - /** required — sharing re-encrypts the user key, which needs hardened derivation from the passphrase */ - walletPassphrase: string; - email?: string; - permissions?: string[]; - message?: string; -} - -export type AcceptVaultShareAsSpenderOptions = { - vaultShareId: string; - userPassword: string; - newWalletPassphrase?: string; -}; -export type AcceptVaultShareAsNonSpenderOptions = { - vaultShareId: string; -}; -export type AcceptVaultShareOptions = AcceptVaultShareAsSpenderOptions | AcceptVaultShareAsNonSpenderOptions; - -/** - * @experimental - */ -export interface IVault { - id(): string; - enterpriseId(): string; - label(): string; - status(): VaultData['status']; - url(extra?: string): string; - createWallet(params: CreateVaultWalletOptions): Promise; - // whole-vault: view/admin/spend/dapp; spend opens a key share (also how a spender services a - // vaultShareRequests entry in UMS orgs) - addMember(params: AddVaultMemberOptions): Promise; - // share ONE vault wallet, not the whole vault - addMemberToWallet(params: AddVaultWalletMemberOptions): Promise; - listShares(params?: { state?: VaultShareState }): Promise; - acceptShare(params: AcceptVaultShareOptions): Promise; - freeze(params?: FreezeOptions): Promise; - archive(): Promise; - toJSON(): VaultData; -} diff --git a/modules/sdk-core/src/bitgo/vault/iVaults.ts b/modules/sdk-core/src/bitgo/vault/iVaults.ts deleted file mode 100644 index 5be7a790b8..0000000000 --- a/modules/sdk-core/src/bitgo/vault/iVaults.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @prettier - * - * @experimental The vault client surface is experimental and may change (including breaking - * changes) before the public release. - */ -import { FinalizeVaultOptions, InitializeVaultOptions } from './iVault'; -import { Vault } from './vault'; - -/** - * Options for vault creation. - * - * v1 targets the HOT custody model only: the multisig root user/backup keys are generated locally - * by the SDK, encrypted with `passphrase`, and registered on BitGo; the MPC roots run the standard - * hot ceremonies with the same passphrase. Self-managed cold keys and custodial vaults are out of - * scope for v1. - */ -export interface CreateVaultOptions { - label: string; - passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies -} - -/** Handle returned by `initializeVault`, threaded into the key ceremonies and finalize. */ -export interface VaultCreationHandle { - vaultId: string; -} - -/** - * The 12 minted root key ids produced by `createVaultKeys`, as 4 ordered [user, backup, bitgo] - * triplets — exactly the payload `finalizeVault` consumes. - */ -export type VaultKeys = FinalizeVaultOptions; - -export interface ListVaultsOptions { - cursor?: string; // opaque cursor from a previous response's nextCursor - limit?: number; -} - -export interface GetVaultOptions { - id: string; -} - -/** - * @experimental - */ -export interface IVaults { - /** - * One-call convenience wrapper: initialize → createVaultKeys (4 vaultId-tagged ceremonies) → - * finalize → keycard. HOT custody only in v1. - */ - generateVault(params: CreateVaultOptions): Promise; - /** Phase 1 — initialize a vault (metadata only, no key material). */ - initializeVault(params: InitializeVaultOptions): Promise; - /** Phase 2 — run the 4 root key ceremonies tagged with `vaultId`; returns the 12 minted key ids. */ - createVaultKeys(params: CreateVaultOptions & VaultCreationHandle): Promise; - /** Phase 3 — finalize a vault with the 12 root key ids. Idempotent. */ - finalizeVault(vaultId: string, params: FinalizeVaultOptions): Promise; - list(params?: ListVaultsOptions): Promise<{ vaults: Vault[]; nextCursor?: string }>; - get(params: GetVaultOptions): Promise; -} diff --git a/modules/sdk-core/src/bitgo/vault/index.ts b/modules/sdk-core/src/bitgo/vault/index.ts deleted file mode 100644 index 60f23f7252..0000000000 --- a/modules/sdk-core/src/bitgo/vault/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './iVault'; -export * from './iVaults'; -export * from './vault'; -export * from './vaults'; diff --git a/modules/sdk-core/src/bitgo/vault/vault.ts b/modules/sdk-core/src/bitgo/vault/vault.ts deleted file mode 100644 index 4d6da4c797..0000000000 --- a/modules/sdk-core/src/bitgo/vault/vault.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @prettier - * - * @experimental The vault client surface is experimental and may change (including breaking - * changes) before the public release. - */ -import * as _ from 'lodash'; -import { BitGoBase } from '../bitgoBase'; -import { decodeWithCodec } from '../utils/codecs'; -import { postWithCodec } from '../utils/postWithCodec'; -import { FreezeOptions, Wallet } from '../wallet'; -import * as VaultCodecs from './codecs'; -import { - AcceptVaultShareOptions, - AddVaultMemberOptions, - AddVaultWalletMemberOptions, - CreateVaultWalletOptions, - IVault, - VaultData, - VaultShareData, - VaultShareState, - WalletShareData, -} from './iVault'; - -/** - * @experimental - */ -export class Vault implements IVault { - private readonly bitgo: BitGoBase; - public readonly _vault: VaultData; - - constructor(bitgo: BitGoBase, vaultData: VaultData) { - this.bitgo = bitgo; - if (!_.isObject(vaultData)) { - throw new Error('vaultData has to be an object'); - } - if (!_.isString(vaultData.id)) { - throw new Error('vault id has to be a string'); - } - if (!_.isString(vaultData.enterpriseId)) { - throw new Error('vault enterpriseId has to be a string'); - } - this._vault = vaultData; - } - - id(): string { - return this._vault.id; - } - - enterpriseId(): string { - return this._vault.enterpriseId; - } - - label(): string { - return this._vault.label; - } - - status(): VaultData['status'] { - return this._vault.status; - } - - /** - * Enterprise-scoped v2 URL for this vault, e.g. /api/v2/enterprise/:eId/vaults/:vId - * @param extra - */ - url(extra = ''): string { - return this.bitgo.url(`/enterprise/${this.enterpriseId()}/vaults/${this.id()}${extra}`, 2); - } - - /** - * Mint a child wallet in this vault (server-side public derivation — no ceremony). - * Body lands in WCN-1203. - */ - async createWallet(params: CreateVaultWalletOptions): Promise { - throw new Error('Vault.createWallet is not yet implemented (WCN-1203)'); - } - - /** - * Add a member to the whole vault (view/admin/spend). Spend opens a key share. - * Body lands in WCN-1204. - */ - async addMember(params: AddVaultMemberOptions): Promise { - throw new Error('Vault.addMember is not yet implemented (WCN-1204)'); - } - - /** - * Share ONE vault wallet with a non-member via the existing wallet-share handshake (FR-13). - * Body lands in WCN-1204. - */ - async addMemberToWallet(params: AddVaultWalletMemberOptions): Promise { - throw new Error('Vault.addMemberToWallet is not yet implemented (WCN-1204)'); - } - - /** - * List the vault key shares visible to the caller. - * Body lands in WCN-1204. - */ - async listShares(params: { state?: VaultShareState } = {}): Promise { - throw new Error('Vault.listShares is not yet implemented (WCN-1204)'); - } - - /** - * Accept a vault key share addressed to the caller. - * Body lands in WCN-1204. - */ - async acceptShare(params: AcceptVaultShareOptions): Promise { - throw new Error('Vault.acceptShare is not yet implemented (WCN-1204)'); - } - - /** - * Freeze the vault — blocks withdrawals on all vault wallets. Vault stays 'active'. - * @param params - */ - async freeze(params: FreezeOptions = {}): Promise { - const response = await postWithCodec(this.bitgo, this.url('/freeze'), VaultCodecs.FreezeVaultBody, params).result(); - return decodeWithCodec(VaultCodecs.VaultData, response, 'VaultData'); - } - - /** - * Archive the vault. Requires every vault wallet to already be archived; also the abandonment - * path for a stuck 'initializing' vault. - */ - async archive(): Promise { - const response = await this.bitgo.post(this.url('/archive')).send().result(); - return decodeWithCodec(VaultCodecs.VaultData, response, 'VaultData'); - } - - toJSON(): VaultData { - return this._vault; - } -} diff --git a/modules/sdk-core/src/bitgo/vault/vaults.ts b/modules/sdk-core/src/bitgo/vault/vaults.ts deleted file mode 100644 index 51c325b2f0..0000000000 --- a/modules/sdk-core/src/bitgo/vault/vaults.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @prettier - * - * @experimental The vault client surface is experimental and may change (including breaking - * changes) before the public release. - */ -import { BitGoBase } from '../bitgoBase'; -import { FinalizeVaultOptions, InitializeVaultOptions } from './iVault'; -import { - CreateVaultOptions, - GetVaultOptions, - IVaults, - ListVaultsOptions, - VaultCreationHandle, - VaultKeys, -} from './iVaults'; -import { Vault } from './vault'; - -/** - * Collection accessor for a single enterprise's vaults, mirroring Wallets / Enterprises. - * Vault routes are enterprise-scoped (/api/v2/enterprise/:eId/vaults), so the accessor is - * constructed with the enterprise id (see Enterprise.vaults()). - * - * @experimental - */ -export class Vaults implements IVaults { - private readonly bitgo: BitGoBase; - private readonly enterpriseId: string; - - constructor(bitgo: BitGoBase, enterpriseId: string) { - this.bitgo = bitgo; - this.enterpriseId = enterpriseId; - } - - /** - * Enterprise-scoped v2 URL for the vaults collection, e.g. /api/v2/enterprise/:eId/vaults - * @param extra - */ - private url(extra = ''): string { - return this.bitgo.url(`/enterprise/${this.enterpriseId}/vaults${extra}`, 2); - } - - /** - * One-call convenience wrapper: initialize → createVaultKeys (4 vaultId-tagged ceremonies) → - * finalize → keycard. HOT custody only. Implemented in WCN-1192 Phase 2. - */ - async generateVault(params: CreateVaultOptions): Promise { - throw new Error('Vaults.generateVault is not yet implemented (WCN-1192 Phase 2)'); - } - - /** - * Phase 1 — initialize a vault (metadata only, no key material). - * Implemented in WCN-1192 Phase 2 (blocked on WCN-1175). - */ - async initializeVault(params: InitializeVaultOptions): Promise { - throw new Error('Vaults.initializeVault is not yet implemented (WCN-1192 Phase 2)'); - } - - /** - * Phase 2 — run the 4 root key ceremonies tagged with `vaultId`; returns the 12 minted key ids. - * Implemented in WCN-1192 Phase 2 (blocked on WCN-1176). - */ - async createVaultKeys(params: CreateVaultOptions & VaultCreationHandle): Promise { - throw new Error('Vaults.createVaultKeys is not yet implemented (WCN-1192 Phase 2)'); - } - - /** - * Phase 3 — finalize a vault with the 12 root key ids as 4 ordered [user, backup, bitgo] triplets. - * Idempotent. Implemented in WCN-1192 Phase 2 (blocked on WCN-1175). - */ - async finalizeVault(vaultId: string, params: FinalizeVaultOptions): Promise { - throw new Error('Vaults.finalizeVault is not yet implemented (WCN-1192 Phase 2)'); - } - - /** - * List the enterprise's vaults (cursor pagination). - * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). - */ - async list(params: ListVaultsOptions = {}): Promise<{ vaults: Vault[]; nextCursor?: string }> { - throw new Error('Vaults.list is not yet implemented (WCN-1192 Phase 3)'); - } - - /** - * Fetch a single vault by id. - * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). - */ - async get(params: GetVaultOptions): Promise { - throw new Error('Vaults.get is not yet implemented (WCN-1192 Phase 3)'); - } -} diff --git a/modules/sdk-core/test/unit/bitgo/vault/vault.ts b/modules/sdk-core/test/unit/bitgo/safe/safe.ts similarity index 54% rename from modules/sdk-core/test/unit/bitgo/vault/vault.ts rename to modules/sdk-core/test/unit/bitgo/safe/safe.ts index 8f78cc85dc..10fecb7d51 100644 --- a/modules/sdk-core/test/unit/bitgo/vault/vault.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safe.ts @@ -1,38 +1,38 @@ import * as sinon from 'sinon'; import 'should'; -import { Vault, VaultData } from '../../../../src'; +import { Safe, SafeData } from '../../../../src'; -describe('Vault', function () { - let vault: Vault; +describe('Safe', function () { + let safe: Safe; let mockBitGo: any; - let vaultData: VaultData; - // wire-shaped VaultData (timestamps as ISO strings) for mocked REST responses that get decoded - let vaultDataWire: any; + let safeData: SafeData; + // wire-shaped SafeData (timestamps as ISO strings) for mocked REST responses that get decoded + let safeDataWire: any; beforeEach(function () { mockBitGo = { url: sinon.stub().callsFake((path: string) => path), post: sinon.stub(), }; - vaultData = { - id: 'test-vault-id', + safeData = { + id: 'test-safe-id', enterpriseId: 'test-enterprise-id', - label: 'my vault', + label: 'my safe', status: 'active', creator: 'creator-id', users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], createdAt: new Date('2026-07-07T00:00:00.000Z'), }; - vaultDataWire = { - id: 'test-vault-id', + safeDataWire = { + id: 'test-safe-id', enterpriseId: 'test-enterprise-id', - label: 'my vault', + label: 'my safe', status: 'active', creator: 'creator-id', users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], createdAt: '2026-07-07T00:00:00.000Z', }; - vault = new Vault(mockBitGo, vaultData); + safe = new Safe(mockBitGo, safeData); }); afterEach(function () { @@ -40,43 +40,43 @@ describe('Vault', function () { }); describe('constructor', function () { - it('throws when vaultData is not an object', function () { - (() => new Vault(mockBitGo, undefined as unknown as VaultData)).should.throw('vaultData has to be an object'); + it('throws when safeData is not an object', function () { + (() => new Safe(mockBitGo, undefined as unknown as SafeData)).should.throw('safeData has to be an object'); }); it('throws when id is not a string', function () { - (() => new Vault(mockBitGo, { ...vaultData, id: 123 as unknown as string })).should.throw( - 'vault id has to be a string' + (() => new Safe(mockBitGo, { ...safeData, id: 123 as unknown as string })).should.throw( + 'safe id has to be a string' ); }); it('throws when enterpriseId is not a string', function () { (() => - new Vault(mockBitGo, { - ...vaultData, + new Safe(mockBitGo, { + ...safeData, enterpriseId: undefined as unknown as string, - })).should.throw('vault enterpriseId has to be a string'); + })).should.throw('safe enterpriseId has to be a string'); }); }); describe('getters', function () { it('exposes id/enterpriseId/label/status', function () { - vault.id().should.equal('test-vault-id'); - vault.enterpriseId().should.equal('test-enterprise-id'); - vault.label().should.equal('my vault'); - vault.status().should.equal('active'); + safe.id().should.equal('test-safe-id'); + safe.enterpriseId().should.equal('test-enterprise-id'); + safe.label().should.equal('my safe'); + safe.status().should.equal('active'); }); it('toJSON returns the underlying data', function () { - vault.toJSON().should.equal(vaultData); + safe.toJSON().should.equal(safeData); }); }); describe('url', function () { it('builds the enterprise-scoped v2 path', function () { - vault.url().should.equal('/enterprise/test-enterprise-id/vaults/test-vault-id'); - vault.url('/freeze').should.equal('/enterprise/test-enterprise-id/vaults/test-vault-id/freeze'); - mockBitGo.url.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id', 2).should.be.true(); + safe.url().should.equal('/enterprise/test-enterprise-id/safes/test-safe-id'); + safe.url('/freeze').should.equal('/enterprise/test-enterprise-id/safes/test-safe-id/freeze'); + mockBitGo.url.calledWith('/enterprise/test-enterprise-id/safes/test-safe-id', 2).should.be.true(); }); }); @@ -88,21 +88,21 @@ describe('Vault', function () { return { sendStub }; } - it('freeze posts the encoded body to the freeze route and decodes VaultData', async function () { - const frozen = { ...vaultDataWire, freeze: { reason: 'incident' } }; + it('freeze posts the encoded body to the freeze route and decodes SafeData', async function () { + const frozen = { ...safeDataWire, freeze: { reason: 'incident' } }; const { sendStub } = stubPost(frozen); - const result = await vault.freeze({ duration: 3600 }); - mockBitGo.post.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id/freeze').should.be.true(); + const result = await safe.freeze({ duration: 3600 }); + mockBitGo.post.calledWith('/enterprise/test-enterprise-id/safes/test-safe-id/freeze').should.be.true(); sendStub.calledWith({ duration: 3600 }).should.be.true(); result.freeze!.reason!.should.equal('incident'); result.createdAt.should.be.instanceof(Date); }); - it('archive posts an empty body to the archive route and decodes VaultData', async function () { - const archived = { ...vaultDataWire, status: 'archived' as const }; + it('archive posts an empty body to the archive route and decodes SafeData', async function () { + const archived = { ...safeDataWire, status: 'archived' as const }; const { sendStub } = stubPost(archived); - const result = await vault.archive(); - mockBitGo.post.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id/archive').should.be.true(); + const result = await safe.archive(); + mockBitGo.post.calledWith('/enterprise/test-enterprise-id/safes/test-safe-id/archive').should.be.true(); sendStub.calledWithExactly().should.be.true(); result.status.should.equal('archived'); }); @@ -110,23 +110,23 @@ describe('Vault', function () { describe('member/share methods are stubbed (WCN-1203 / WCN-1204)', function () { it('createWallet throws not-implemented (WCN-1203)', async function () { - await vault.createWallet({ coin: 'tbtc', label: 'w' }).should.be.rejectedWith(/WCN-1203/); + await safe.createWallet({ coin: 'tbtc', label: 'w' }).should.be.rejectedWith(/WCN-1203/); }); it('addMember throws not-implemented (WCN-1204)', async function () { - await vault.addMember({ userId: 'u', permissions: ['view'] }).should.be.rejectedWith(/WCN-1204/); + await safe.addMember({ userId: 'u', permissions: ['view'] }).should.be.rejectedWith(/WCN-1204/); }); it('addMemberToWallet throws not-implemented (WCN-1204)', async function () { - await vault.addMemberToWallet({ walletId: 'w', walletPassphrase: 'p' }).should.be.rejectedWith(/WCN-1204/); + await safe.addMemberToWallet({ walletId: 'w', walletPassphrase: 'p' }).should.be.rejectedWith(/WCN-1204/); }); it('listShares throws not-implemented (WCN-1204)', async function () { - await vault.listShares().should.be.rejectedWith(/WCN-1204/); + await safe.listShares().should.be.rejectedWith(/WCN-1204/); }); it('acceptShare throws not-implemented (WCN-1204)', async function () { - await vault.acceptShare({ vaultShareId: 's' }).should.be.rejectedWith(/WCN-1204/); + await safe.acceptShare({ safeShareId: 's' }).should.be.rejectedWith(/WCN-1204/); }); }); }); diff --git a/modules/sdk-core/test/unit/bitgo/safe/safes.ts b/modules/sdk-core/test/unit/bitgo/safe/safes.ts new file mode 100644 index 0000000000..813f6fb149 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/safe/safes.ts @@ -0,0 +1,68 @@ +import * as sinon from 'sinon'; +import 'should'; +import { Enterprise, Safes } from '../../../../src'; + +describe('Safes', function () { + let safes: Safes; + let mockBitGo: any; + + beforeEach(function () { + mockBitGo = { + url: sinon.stub().callsFake((path: string) => path), + }; + safes = new Safes(mockBitGo, 'test-enterprise-id'); + }); + + afterEach(function () { + sinon.restore(); + }); + + // Phase 2 (generateSafe/initialize/createSafeKeys/finalize) and Phase 3 (list/get) are not yet + // implemented — they are gated on WCN-1175 / WCN-1176 / WCN-1177. Assert the interface is wired + // and stubbed for now. + describe('unimplemented lifecycle methods', function () { + it('generateSafe throws (Phase 2)', async function () { + await safes.generateSafe({ label: 'v', passphrase: 'p' }).should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + + it('initializeSafe throws (Phase 2)', async function () { + await safes.initializeSafe({ label: 'v' }).should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + + it('createSafeKeys throws (Phase 2)', async function () { + await safes + .createSafeKeys({ label: 'v', passphrase: 'p', safeId: 'vid' }) + .should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + + it('finalizeSafe throws (Phase 2)', async function () { + await safes + .finalizeSafe('vid', { + rootKeys: { + hot: { + secp256k1Multisig: ['u', 'b', 'g'], + ecdsaMpc: ['u', 'b', 'g'], + eddsaMpc: ['u', 'b', 'g'], + ed25519Multisig: ['u', 'b', 'g'], + }, + }, + }) + .should.be.rejectedWith(/not yet implemented .*Phase 2/); + }); + + it('list throws (Phase 3)', async function () { + await safes.list().should.be.rejectedWith(/not yet implemented .*Phase 3/); + }); + + it('get throws (Phase 3)', async function () { + await safes.get({ id: 'vid' }).should.be.rejectedWith(/not yet implemented .*Phase 3/); + }); + }); + + describe('Enterprise.safes() accessor', function () { + it('returns a Safes instance scoped to the enterprise', function () { + const enterprise = new Enterprise(mockBitGo, {} as any, { id: 'ent-id', name: 'ent' }); + enterprise.safes().should.be.instanceof(Safes); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/vault/vaults.ts b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts deleted file mode 100644 index 4f10572b25..0000000000 --- a/modules/sdk-core/test/unit/bitgo/vault/vaults.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as sinon from 'sinon'; -import 'should'; -import { Enterprise, Vaults } from '../../../../src'; - -describe('Vaults', function () { - let vaults: Vaults; - let mockBitGo: any; - - beforeEach(function () { - mockBitGo = { - url: sinon.stub().callsFake((path: string) => path), - }; - vaults = new Vaults(mockBitGo, 'test-enterprise-id'); - }); - - afterEach(function () { - sinon.restore(); - }); - - // Phase 2 (generateVault/initialize/createVaultKeys/finalize) and Phase 3 (list/get) are not yet - // implemented — they are gated on WCN-1175 / WCN-1176 / WCN-1177. Assert the interface is wired - // and stubbed for now. - describe('unimplemented lifecycle methods', function () { - it('generateVault throws (Phase 2)', async function () { - await vaults - .generateVault({ label: 'v', passphrase: 'p' }) - .should.be.rejectedWith(/not yet implemented .*Phase 2/); - }); - - it('initializeVault throws (Phase 2)', async function () { - await vaults.initializeVault({ label: 'v' }).should.be.rejectedWith(/not yet implemented .*Phase 2/); - }); - - it('createVaultKeys throws (Phase 2)', async function () { - await vaults - .createVaultKeys({ label: 'v', passphrase: 'p', vaultId: 'vid' }) - .should.be.rejectedWith(/not yet implemented .*Phase 2/); - }); - - it('finalizeVault throws (Phase 2)', async function () { - await vaults - .finalizeVault('vid', { - rootKeys: { - hot: { - secp256k1Multisig: ['u', 'b', 'g'], - ecdsaMpc: ['u', 'b', 'g'], - eddsaMpc: ['u', 'b', 'g'], - ed25519Multisig: ['u', 'b', 'g'], - }, - }, - }) - .should.be.rejectedWith(/not yet implemented .*Phase 2/); - }); - - it('list throws (Phase 3)', async function () { - await vaults.list().should.be.rejectedWith(/not yet implemented .*Phase 3/); - }); - - it('get throws (Phase 3)', async function () { - await vaults.get({ id: 'vid' }).should.be.rejectedWith(/not yet implemented .*Phase 3/); - }); - }); - - describe('Enterprise.vaults() accessor', function () { - it('returns a Vaults instance scoped to the enterprise', function () { - const enterprise = new Enterprise(mockBitGo, {} as any, { id: 'ent-id', name: 'ent' }); - enterprise.vaults().should.be.instanceof(Vaults); - }); - }); -});