diff --git a/modules/sdk-core/src/bitgo/safe/iSafes.ts b/modules/sdk-core/src/bitgo/safe/iSafes.ts index 7a0212b8cd..7e308994ae 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafes.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafes.ts @@ -24,15 +24,19 @@ export interface CreateSafeOptions { /** * Handle returned by `initializeSafe`, threaded into the key ceremonies and finalize. + * + * `enabledRootSlots` is the server-decided (Flipt, evaluated once at initialize) set of root-key + * slots to generate; absent (older WP) falls back to all 4 slots. * @experimental */ export interface SafeCreationHandle { safeId: string; + enabledRootSlots?: InitializeSafeResponse['enabledRootSlots']; } /** - * The 12 minted root key ids produced by `createSafeKeys`, as 4 ordered [user, backup, bitgo] - * triplets — exactly the payload `finalizeSafe` consumes. + * The minted root key ids produced by `createSafeKeys`, as ordered [user, backup, bitgo] + * triplets for the enabled slots — exactly the payload `finalizeSafe` consumes. * @experimental */ export type SafeKeys = FinalizeSafeOptions; @@ -58,24 +62,25 @@ export interface GetSafeOptions { export interface ISafes { /** * One-call convenience wrapper chaining the three creation phases: - * initialize → createSafeKeys (4 safeId-tagged ceremonies) → finalize. HOT custody only in v1. + * initialize → createSafeKeys (safeId-tagged ceremonies for the enabled root slots) → finalize. + * HOT custody only in v1. * @experimental */ generateSafe(params: CreateSafeOptions): Promise; /** - * Phase 1 — initialize a safe (metadata only, no key material). The server response is just - * `{ id, status }` (no `label`/`enterpriseId`/`creator`/`users`/`createdAt` yet), so this - * returns that raw shape rather than a full `Safe`. + * Phase 1 — initialize a safe (metadata only, no key material). The server response is + * `{ id, status }` plus optional `enabledRootSlots` (no `label`/`enterpriseId`/`creator`/`users`/ + * `createdAt` yet), so this returns that raw shape rather than a full `Safe`. * @experimental */ initializeSafe(params: InitializeSafeOptions): Promise; /** - * Phase 2 — run the 4 root key ceremonies tagged with `safeId`; returns the 12 minted key ids. + * Phase 2 — run the enabled root key ceremonies tagged with `safeId`; returns the minted key ids. * @experimental */ createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise; /** - * Phase 3 — finalize a safe with the 12 root key ids. Idempotent. + * Phase 3 — finalize a safe with the minted root key ids. Idempotent. * @experimental */ finalizeSafe(safeId: string, params: FinalizeSafeOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/safe/safes.ts b/modules/sdk-core/src/bitgo/safe/safes.ts index c7be05cbdd..31b1d26407 100644 --- a/modules/sdk-core/src/bitgo/safe/safes.ts +++ b/modules/sdk-core/src/bitgo/safe/safes.ts @@ -9,6 +9,7 @@ import { FinalizeSafeBody, InitializeSafeBody, InitializeSafeResponse, + RootKeysByType, RootKeyTriplet, RootKeyType, SafeData, @@ -86,7 +87,7 @@ export class Safes implements ISafes { /** * One-call convenience wrapper chaining the three creation phases: - * initialize (Phase 1) → createSafeKeys (Phase 2, 4 safeId-tagged ceremonies) → + * initialize (Phase 1) → createSafeKeys (Phase 2, enabled-slot ceremonies) → * finalize (Phase 3). HOT custody only in v1. * * If a key ceremony fails, `createSafeKeys` archives the safe before throwing, so a failed run @@ -95,14 +96,18 @@ export class Safes implements ISafes { */ async generateSafe(params: CreateSafeOptions): Promise { const safe = await this.initializeSafe({ label: params.label }); - const rootKeys = await this.createSafeKeys({ ...params, safeId: safe.id }); + const rootKeys = await this.createSafeKeys({ + ...params, + safeId: safe.id, + enabledRootSlots: safe.enabledRootSlots, + }); return await this.finalizeSafe(safe.id, rootKeys); } /** * Phase 1 — initialize a safe (metadata only, no key material). * POST /api/v2/enterprise/:eId/safes { label } - * Response is just `{ id, status }` — the safe has no label/roster/etc. yet. + * Response is `{ id, status }` plus optional `enabledRootSlots` (absent on older WP). * @experimental */ async initializeSafe(params: InitializeSafeOptions): Promise { @@ -111,10 +116,11 @@ export class Safes implements ISafes { } /** - * Phase 2 — run the 4 root key ceremonies tagged with `safeId`; returns the 12 minted key ids - * as 4 ordered [user, backup, bitgo] triplets. HOT custody only in v1. + * Phase 2 — run the enabled root key ceremonies tagged with `safeId`; returns ordered + * [user, backup, bitgo] triplets for those slots. HOT custody only in v1. * - * All 4 ceremonies (2.1 multisig ①④, 2.2 MPC ②③) run in parallel. If any of them fail, the + * Absent `enabledRootSlots` (older WP, pre-gating) runs all 4 slots. Enabled ceremonies + * (2.1 multisig ①④, 2.2 MPC ②③) run in parallel. If any of them fail, the * partially-created safe is archived (a legal `initializing → archived` transition, so the * orphaned tagged keys are inert) and an error listing every ceremony failure is thrown — * create a new safe and retry. @@ -124,24 +130,27 @@ export class Safes implements ISafes { * @experimental */ async createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise { - const { safeId, passphrase } = params; + const { safeId, passphrase, enabledRootSlots } = params; const enterprise = this.enterpriseId; - // `slots` MUST stay index-aligned with the Promise.allSettled array below. Ordered by scheme: - // the two multisig roots first, then the two MPC roots. - const slots: RootKeyType[] = ['secp256k1Multisig', 'ed25519Multisig', 'ecdsaMpc', 'eddsaMpc']; - const results = await Promise.allSettled([ + // Absent `enabledRootSlots` (older WP, pre-gating) ⇒ all 4, preserving current behavior. + // Ordered by scheme: the two multisig roots first, then the two MPC roots. + const allSlots: RootKeyType[] = ['secp256k1Multisig', 'ed25519Multisig', 'ecdsaMpc', 'eddsaMpc']; + const enabled = new Set(enabledRootSlots ?? allSlots); + const slots = allSlots.filter((slot) => enabled.has(slot)); + const ceremonies: Record Promise> = { // Phase 2.1 — multisig roots (①④): local user/backup keypairs + BitGo key, all safeId-tagged. - this.createMultisigRoot('secp256k1Multisig', safeId, passphrase, enterprise), - this.createMultisigRoot('ed25519Multisig', safeId, passphrase, enterprise), + secp256k1Multisig: () => this.createMultisigRoot('secp256k1Multisig', safeId, passphrase, enterprise), + ed25519Multisig: () => this.createMultisigRoot('ed25519Multisig', safeId, passphrase, enterprise), // Phase 2.2 — MPC roots (②③): the existing DKLS (②) and EdDSA (③) ceremonies, safeId threaded. - this.createMpcRoot('ecdsaMpc', safeId, passphrase, enterprise), - this.createMpcRoot('eddsaMpc', safeId, passphrase, enterprise), - ]); + ecdsaMpc: () => this.createMpcRoot('ecdsaMpc', safeId, passphrase, enterprise), + eddsaMpc: () => this.createMpcRoot('eddsaMpc', safeId, passphrase, enterprise), + }; + const results = await Promise.allSettled(slots.map((slot) => ceremonies[slot]())); // Single pass over the settled results: `status === 'fulfilled'` narrows `.value` to a - // RootKeyTriplet (no cast needed), and rejections are collected per-slot for the error below. - const hot = {} as SafeKeys['rootKeys']['hot']; + // RootKeyTriplet, and rejections are collected per-slot for the error below. + const hot: RootKeysByType = {}; const failures: string[] = []; results.forEach((result, i) => { if (result.status === 'fulfilled') { @@ -192,7 +201,7 @@ export class Safes implements ISafes { } /** - * Phase 3 — finalize a safe with the 12 root key ids as 4 ordered [user, backup, bitgo] triplets. + * Phase 3 — finalize a safe with the minted root key ids as ordered [user, backup, bitgo] triplets. * POST /api/v2/enterprise/:eId/safes/:safeId/finalize { rootKeys }. * Idempotent: re-finalizing with the same `rootKeys` returns the active safe again (200). * @experimental diff --git a/modules/sdk-core/test/unit/bitgo/safe/safes.ts b/modules/sdk-core/test/unit/bitgo/safe/safes.ts index 941c340265..9b466ce4da 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safes.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safes.ts @@ -1,6 +1,7 @@ import * as sinon from 'sinon'; import 'should'; -import { Enterprise, Safe, Safes } from '../../../../src'; +import { InitializeSafeResponse } from '@bitgo/public-types'; +import { Enterprise, Safe, SafeKeys, Safes } from '../../../../src'; describe('Safes', function () { let safes: Safes; @@ -40,6 +41,23 @@ describe('Safes', function () { sinon.assert.calledWith(mockBitGo.post, '/enterprise/test-enterprise-id/safes'); sinon.assert.calledWith(send, { label: 'my safe' }); }); + + it('decodes enabledRootSlots when the server returns them', async function () { + const initializeResponseWire = { + id: 'test-safe-id', + status: 'initializing', + enabledRootSlots: ['secp256k1Multisig', 'ecdsaMpc'], + }; + const send = sinon.stub().returns({ result: sinon.stub().resolves(initializeResponseWire) }); + mockBitGo.post.returns({ send }); + + const result = await safes.initializeSafe({ label: 'my safe' }); + + if (result.enabledRootSlots === undefined) { + throw new Error('expected enabledRootSlots'); + } + result.enabledRootSlots.should.deepEqual(['secp256k1Multisig', 'ecdsaMpc']); + }); }); describe('createSafeKeys', function () { @@ -164,6 +182,26 @@ describe('Safes', function () { started.should.equal(4); }); + it('runs only the enabled ceremonies when enabledRootSlots is a subset', async function () { + const result = await safes.createSafeKeys({ + label: 'my safe', + passphrase: 'pw', + safeId: 'safe-1', + enabledRootSlots: ['ecdsaMpc', 'eddsaMpc'], + }); + + result.should.deepEqual({ + rootKeys: { + hot: { + ecdsaMpc: ['hteth-user', 'hteth-backup', 'hteth-bitgo'], + eddsaMpc: ['tsol-user', 'tsol-backup', 'tsol-bitgo'], + }, + }, + }); + keychainsByCoin.should.not.have.property('tbtc'); + keychainsByCoin.should.not.have.property('txlm'); + }); + it('archives the safe and throws listing every failed ceremony', async function () { // Two ceremonies fail (an MPC and a multisig root). keychainsByCoin['hteth'] = makeKeychains('hteth'); @@ -236,9 +274,13 @@ describe('Safes', function () { }); describe('generateSafe', function () { - it('chains initialize → createSafeKeys → finalize, threading the safeId', async function () { - const initializing = { id: 'test-safe-id', status: 'initializing' as const }; - const rootKeys = { rootKeys: { hot: {} } } as any; + it('chains initialize → createSafeKeys → finalize, threading the safeId and enabledRootSlots', async function () { + const initializing: InitializeSafeResponse = { + id: 'test-safe-id', + status: 'initializing', + enabledRootSlots: ['ecdsaMpc', 'eddsaMpc'], + }; + const rootKeys: SafeKeys = { rootKeys: { hot: {} } }; const initStub = sinon.stub(safes, 'initializeSafe').resolves(initializing); const keysStub = sinon.stub(safes, 'createSafeKeys').resolves(rootKeys); const finalizeStub = sinon.stub(safes, 'finalizeSafe').resolves(new Safe(mockBitGo, safeDataWire as any)); @@ -246,7 +288,12 @@ describe('Safes', function () { const result = await safes.generateSafe({ label: 'my safe', passphrase: 'pw' }); sinon.assert.calledWithMatch(initStub, { label: 'my safe' }); - sinon.assert.calledWithMatch(keysStub, { label: 'my safe', passphrase: 'pw', safeId: 'test-safe-id' }); + sinon.assert.calledWithMatch(keysStub, { + label: 'my safe', + passphrase: 'pw', + safeId: 'test-safe-id', + enabledRootSlots: ['ecdsaMpc', 'eddsaMpc'], + }); sinon.assert.calledWith(finalizeStub, 'test-safe-id', rootKeys); sinon.assert.callOrder(initStub, keysStub, finalizeStub); result.status().should.equal('active');