diff --git a/modules/sdk-core/src/bitgo/enterprise/enterprise.ts b/modules/sdk-core/src/bitgo/enterprise/enterprise.ts index 0a2a8ced4f..da4f7e6124 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 { Safes } from '../safe'; 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 safes collection accessor scoped to this Enterprise + * @experimental + */ + 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 60e96f2df9..e2727916a9 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 { ISafes } from '../safe'; // useEnterpriseEcdsaTssChallenge is deprecated export type EnterpriseFeatureFlag = 'useEnterpriseEcdsaTssChallenge'; @@ -39,4 +40,6 @@ export interface IEnterprise { bitgoNitroChallenge: SerializedNtildeWithVerifiers ): Promise; hasFeatureFlags(flags: EnterpriseFeatureFlag[]): boolean; + /** @experimental */ + safes(): ISafes; } diff --git a/modules/sdk-core/src/bitgo/index.ts b/modules/sdk-core/src/bitgo/index.ts index 610c75d97d..18b116ce4c 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 './safe'; export * from './wallet'; export * from './webhook'; export { bitcoinUtil }; diff --git a/modules/sdk-core/src/bitgo/safe/codecs.ts b/modules/sdk-core/src/bitgo/safe/codecs.ts new file mode 100644 index 0000000000..3dab6848d5 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/codecs.ts @@ -0,0 +1,200 @@ +/** + * @prettier + * + * 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 SafeData` 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 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 + * - ed25519Multisig ④ — ex. ALGO/XLM/HBAR + */ +export const RootKeyType = t.keyof( + { + secp256k1Multisig: null, + ecdsaMpc: null, + eddsaMpc: null, + ed25519Multisig: null, + }, + 'RootKeyType' +); + +export const SafePermission = t.keyof( + { + view: null, + spend: null, + admin: null, + dapp: null, + }, + 'SafePermission' +); + +/** 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 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 safe's roots can be created under. v1 implements `hot` only. */ +export const SafeCustodyType = t.keyof( + { + hot: null, + cold: null, + custodial: null, + }, + 'SafeCustodyType' +); + +/** + * 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 SafeRootKeys = t.partial( + { + hot: RootKeysByType, + cold: RootKeysByType, + custodial: RootKeysByType, + }, + 'SafeRootKeys' +); + +export const SafeMembershipData = t.intersection( + [ + t.type({ + userId: t.string, + permissions: t.array(SafePermission), + }), + t.partial({ + needsRecovery: t.boolean, + }), + ], + 'SafeMembershipData' +); + +/** A pending UMS spend grant awaiting a key share — mirror of the wallet's walletShareRequests[]. */ +export const SafeShareRequest = t.type( + { + userId: t.string, + permissions: t.array(SafePermission), + createdAt: DateFromISOString, + }, + 'SafeShareRequest' +); + +export const SafeFreeze = t.partial( + { + time: DateFromISOString, + expires: DateFromISOString, + reason: t.string, + }, + 'SafeFreeze' +); + +export const SafeStatus = t.keyof( + { + initializing: null, + active: null, + archived: null, + }, + 'SafeStatus' +); + +export const SafeData = t.intersection( + [ + t.type({ + id: t.string, + enterpriseId: t.string, + label: t.string, + status: SafeStatus, + creator: t.string, + users: t.array(SafeMembershipData), + createdAt: DateFromISOString, + }), + t.partial({ + safeShareRequests: t.array(SafeShareRequest), + freeze: SafeFreeze, + rootKeys: SafeRootKeys, + archivedAt: DateFromISOString, + }), + ], + 'SafeData' +); + +/** Safe key-share states — identical to WalletShare states, no new states. */ +export const SafeShareState = t.keyof( + { + pendingapproval: null, + active: null, + accepted: null, + canceled: null, + rejected: null, + }, + 'SafeShareState' +); + +/** 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, + encryptedPrv: t.string, + publicIdentifier: t.string, + fromPubKey: t.string, + toPubKey: t.string, + path: t.string, + }, + 'SafeShareKeychain' +); + +export const SafeShareData = t.intersection( + [ + t.type({ + id: t.string, + enterpriseId: t.string, + safeId: t.string, + fromUser: t.string, + toUser: t.string, + permissions: t.array(SafePermission), + state: SafeShareState, + createdAt: DateFromISOString, + }), + t.partial({ + safeLabel: t.string, + message: t.string, + pendingApprovalId: t.string, + isUMSInitiated: t.boolean, + keychains: t.array(SafeShareKeychain), + updatedAt: DateFromISOString, + }), + ], + 'SafeShareData' +); + +// ---- request bodies ---- + +/** POST /enterprise/:eId/safes — Phase 1 carries no key material. */ +export const InitializeSafeBody = t.type({ label: t.string }, 'InitializeSafeBody'); + +/** 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/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/test/unit/bitgo/safe/safe.ts b/modules/sdk-core/test/unit/bitgo/safe/safe.ts new file mode 100644 index 0000000000..10fecb7d51 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/safe/safe.ts @@ -0,0 +1,132 @@ +import * as sinon from 'sinon'; +import 'should'; +import { Safe, SafeData } from '../../../../src'; + +describe('Safe', function () { + let safe: Safe; + let mockBitGo: 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(), + }; + safeData = { + id: 'test-safe-id', + enterpriseId: 'test-enterprise-id', + label: 'my safe', + status: 'active', + creator: 'creator-id', + users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], + createdAt: new Date('2026-07-07T00:00:00.000Z'), + }; + safeDataWire = { + id: 'test-safe-id', + enterpriseId: 'test-enterprise-id', + label: 'my safe', + status: 'active', + creator: 'creator-id', + users: [{ userId: 'creator-id', permissions: ['admin', 'spend'] }], + createdAt: '2026-07-07T00:00:00.000Z', + }; + safe = new Safe(mockBitGo, safeData); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('constructor', function () { + 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 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 Safe(mockBitGo, { + ...safeData, + enterpriseId: undefined as unknown as string, + })).should.throw('safe enterpriseId has to be a string'); + }); + }); + + describe('getters', function () { + it('exposes id/enterpriseId/label/status', function () { + 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 () { + safe.toJSON().should.equal(safeData); + }); + }); + + describe('url', function () { + it('builds the enterprise-scoped v2 path', function () { + 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(); + }); + }); + + 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 SafeData', async function () { + const frozen = { ...safeDataWire, freeze: { reason: 'incident' } }; + const { sendStub } = stubPost(frozen); + 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 SafeData', async function () { + const archived = { ...safeDataWire, status: 'archived' as const }; + const { sendStub } = stubPost(archived); + 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'); + }); + }); + + describe('member/share methods are stubbed (WCN-1203 / WCN-1204)', function () { + it('createWallet throws not-implemented (WCN-1203)', async function () { + await safe.createWallet({ coin: 'tbtc', label: 'w' }).should.be.rejectedWith(/WCN-1203/); + }); + + it('addMember throws not-implemented (WCN-1204)', async function () { + await safe.addMember({ userId: 'u', permissions: ['view'] }).should.be.rejectedWith(/WCN-1204/); + }); + + it('addMemberToWallet throws not-implemented (WCN-1204)', async function () { + await safe.addMemberToWallet({ walletId: 'w', walletPassphrase: 'p' }).should.be.rejectedWith(/WCN-1204/); + }); + + it('listShares throws not-implemented (WCN-1204)', async function () { + await safe.listShares().should.be.rejectedWith(/WCN-1204/); + }); + + it('acceptShare throws not-implemented (WCN-1204)', async function () { + 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); + }); + }); +});