From c852241bf0edaee99d039dbf06fb5d4598a4eca4 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Tue, 7 Jul 2026 12:40:32 -0400 Subject: [PATCH 1/2] 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 4a87ebf341f61520d9c51bf146b1de8c8d8479b6 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Thu, 9 Jul 2026 12:25:00 -0400 Subject: [PATCH 2/2] 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); }); });