Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions modules/sdk-core/src/bitgo/safe/iSafes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Safe>;
/**
* 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<InitializeSafeResponse>;
/**
* 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<SafeKeys>;
/**
* 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<Safe>;
Expand Down
47 changes: 28 additions & 19 deletions modules/sdk-core/src/bitgo/safe/safes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FinalizeSafeBody,
InitializeSafeBody,
InitializeSafeResponse,
RootKeysByType,
RootKeyTriplet,
RootKeyType,
SafeData,
Expand Down Expand Up @@ -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
Expand All @@ -95,14 +96,18 @@ export class Safes implements ISafes {
*/
async generateSafe(params: CreateSafeOptions): Promise<Safe> {
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<InitializeSafeResponse> {
Expand All @@ -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.
Expand All @@ -124,24 +130,27 @@ export class Safes implements ISafes {
* @experimental
*/
async createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise<SafeKeys> {
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<RootKeyType, () => Promise<RootKeyTriplet>> = {
// 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') {
Expand Down Expand Up @@ -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
Expand Down
57 changes: 52 additions & 5 deletions modules/sdk-core/test/unit/bitgo/safe/safes.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -236,17 +274,26 @@ 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));

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');
Expand Down
Loading