diff --git a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts index 0a2a8ced4f..1b1b0cb0c5 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,12 @@ 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 + * @experimental + */ + vaults(): Vaults { + 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 60e96f2df9..614eb22b82 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,6 @@ export interface IEnterprise { bitgoNitroChallenge: SerializedNtildeWithVerifiers ): Promise; hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean; + /** @experimental */ + 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/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 new file mode 100644 index 0000000000..9d6573617a --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/iVault.ts @@ -0,0 +1,100 @@ +/** + * @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 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; 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 new file mode 100644 index 0000000000..5be7a790b8 --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/iVaults.ts @@ -0,0 +1,60 @@ +/** + * @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 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..4d6da4c797 --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/vault.ts @@ -0,0 +1,131 @@ +/** + * @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 new file mode 100644 index 0000000000..51c325b2f0 --- /dev/null +++ b/modules/sdk-core/src/bitgo/vault/vaults.ts @@ -0,0 +1,90 @@ +/** + * @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/vault/vault.ts new file mode 100644 index 0000000000..8f78cc85dc --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/vault/vault.ts @@ -0,0 +1,132 @@ +import * as sinon from 'sinon'; +import 'should'; +import { Vault, VaultData } from '../../../../src'; + +describe('Vault', function () { + let vault: Vault; + let mockBitGo: 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(), + }; + 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', + status: 'active', + creator: 'creator-id', + users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], + createdAt: '2026-07-07T00:00:00.000Z', + }; + vault = new Vault(mockBitGo, vaultData); + }); + + afterEach(function () { + sinon.restore(); + }); + + 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 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' + ); + }); + + it('throws when enterpriseId is not a string', function () { + (() => + new Vault(mockBitGo, { + ...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('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.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 }; + const { sendStub } = stubPost(archived); + const result = await vault.archive(); + mockBitGo.post.calledWith('/enterprise/test-enterprise-id/vaults/test-vault-id/archive').should.be.true(); + sendStub.calledWithExactly().should.be.true(); + result.status.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({ 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/); + }); + + 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..49a78114a5 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/vault/vaults.ts @@ -0,0 +1,68 @@ +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: { + 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); + }); + }); +});