From a1f488e82db4a06c5556d53f5a59c71d60866e71 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Wed, 15 Jul 2026 14:19:20 -0400 Subject: [PATCH 1/2] feat: implement safe createSafeKeys and finalize orchestrator Implement Phase 2 (root key ceremonies) and Phase 3 (finalize) of safe creation in sdk-core, plus the generateSafe orchestrator chaining all three phases. createSafeKeys runs the 4 root ceremonies in parallel, tagged with safeId, and returns the 12 key ids as ordered [user, backup, bitgo] triplets: - 2.1 multisig roots (secp256k1Multisig, ed25519Multisig): local user/backup keypairs + BitGo key via the existing keychain flows. - 2.2 MPC roots (ecdsaMpc DKLS, eddsaMpc EdDSA): the existing MPCv2 ceremonies via createMpc. If any ceremony fails, the partially-created safe is archived and an error listing every ceremony failure is thrown, so a failed run leaves no half-created safe behind. Thread an optional safeId through the shared key-generation paths: keychains.add/createMpc, the ecdsa/eddsa MPCv2 createKeychains ceremonies (round 1 generatekey envelope + participant registration), and their key-gen senders. Legacy MPCv1 paths accept and ignore safeId for signature compatibility. finalizeSafe posts the rootKeys to the finalize route using the existing public-types codec; generateSafe chains init -> keys -> finalize. WCN-1192 --- .../sdk-core/src/bitgo/keychain/iKeychains.ts | 10 + .../sdk-core/src/bitgo/keychain/keychains.ts | 3 + modules/sdk-core/src/bitgo/safe/iSafe.ts | 6 +- modules/sdk-core/src/bitgo/safe/iSafes.ts | 35 ++- modules/sdk-core/src/bitgo/safe/safes.ts | 192 +++++++++++++++- .../src/bitgo/utils/tss/ecdsa/ecdsa.ts | 3 + .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 51 +++-- .../utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts | 7 +- .../src/bitgo/utils/tss/eddsa/eddsa.ts | 3 + .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 63 ++++-- .../utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts | 7 +- .../sdk-core/test/unit/bitgo/safe/safes.ts | 213 ++++++++++++++++-- 12 files changed, 521 insertions(+), 72 deletions(-) diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 5b59a84af8..70a4d12d73 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -151,6 +151,9 @@ export interface AddKeychainOptions { * Sent as `webauthnInfo` in the POST /key body so the backend can link * the passkey to the keychain in the same request. */ webauthnInfo?: WebauthnInfo; + /** Public id of the safe this key is a root of (Wallet Safes v1). When set, the backend tags + * the key with the safe and stamps its curve server-side. @experimental */ + safeId?: string; } export interface ApiKeyShare { @@ -178,6 +181,8 @@ export interface CreateBackupOptions { encryptedPrv?: string; passphrase?: string; encryptionVersion?: EncryptionVersion; + /** Public id of the safe this backup key is a root of (Wallet Safes v1). @experimental */ + safeId?: string; } export interface CreateBitGoOptions { @@ -186,6 +191,8 @@ export interface CreateBitGoOptions { reqId?: IRequestTracer; keyType?: KeyType; isDistributedCustody?: boolean; + /** Public id of the safe this BitGo key is a root of (Wallet Safes v1). @experimental */ + safeId?: string; } export type DecryptedRetrofitPayload = { @@ -202,6 +209,9 @@ export interface CreateMpcOptions { retrofit?: DecryptedRetrofitPayload; webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; + /** Public id of the safe these MPC root keys belong to (Wallet Safes v1). Threaded through the + * ceremony so the resulting user/backup/bitgo keys land tagged. @experimental */ + safeId?: string; } export interface RecreateMpcOptions extends Omit { diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 8c7ddc5b09..d71e465351 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -257,6 +257,7 @@ export class Keychains implements IKeychains { 'originalPasscodeEncryptionCode', 'enterprise', 'derivedFromParentWithSeed', + 'safeId', ] ); @@ -295,6 +296,7 @@ export class Keychains implements IKeychains { coinSpecific: params.coinSpecific, webauthnDevices: params.webauthnDevices, webauthnInfo: params.webauthnInfo, + safeId: params.safeId, }) .result(); } @@ -384,6 +386,7 @@ export class Keychains implements IKeychains { retrofit: params.retrofit, webauthnInfo: params.webauthnInfo, encryptionVersion: params.encryptionVersion, + safeId: params.safeId, }); } diff --git a/modules/sdk-core/src/bitgo/safe/iSafe.ts b/modules/sdk-core/src/bitgo/safe/iSafe.ts index 9c587da711..f80dab8cb9 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafe.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafe.ts @@ -17,11 +17,15 @@ import type { } from '@bitgo/public-types'; import type { Wallet, WalletShare } from '../wallet'; +/** @experimental */ export interface InitializeSafeOptions { label: string; } -// Phase 3 — the client hands back the 12 key ids it created in Phase 2: +/** + * Phase 3 — the client hands back the 12 key ids it created in Phase 2. + * @experimental + */ export interface FinalizeSafeOptions { rootKeys: SafeRootKeys; } diff --git a/modules/sdk-core/src/bitgo/safe/iSafes.ts b/modules/sdk-core/src/bitgo/safe/iSafes.ts index 467d033f73..0d0d790b8a 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafes.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafes.ts @@ -14,13 +14,17 @@ import { Safe } from './safe'; * 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. + * @experimental */ 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. */ +/** + * Handle returned by `initializeSafe`, threaded into the key ceremonies and finalize. + * @experimental + */ export interface SafeCreationHandle { safeId: string; } @@ -28,14 +32,21 @@ export interface SafeCreationHandle { /** * The 12 minted root key ids produced by `createSafeKeys`, as 4 ordered [user, backup, bitgo] * triplets — exactly the payload `finalizeSafe` consumes. + * @experimental */ export type SafeKeys = FinalizeSafeOptions; +/** + * @experimental + */ export interface ListSafesOptions { cursor?: string; // opaque cursor from a previous response's nextCursor limit?: number; } +/** + * @experimental + */ export interface GetSafeOptions { id: string; } @@ -45,16 +56,28 @@ export interface GetSafeOptions { */ export interface ISafes { /** - * One-call convenience wrapper: initialize → createSafeKeys (4 safeId-tagged ceremonies) → - * finalize → keycard. HOT custody only in v1. + * One-call convenience wrapper chaining the three creation phases: + * initialize → createSafeKeys (4 safeId-tagged ceremonies) → finalize. HOT custody only in v1. + * @experimental */ generateSafe(params: CreateSafeOptions): Promise; - /** Phase 1 — initialize a safe (metadata only, no key material). */ + /** + * Phase 1 — initialize a safe (metadata only, no key material). + * @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 4 root key ceremonies tagged with `safeId`; returns the 12 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 12 root key ids. Idempotent. + * @experimental + */ finalizeSafe(safeId: string, params: FinalizeSafeOptions): Promise; + /** @experimental */ list(params?: ListSafesOptions): Promise<{ safes: Safe[]; nextCursor?: string }>; + /** @experimental */ get(params: GetSafeOptions): Promise; } diff --git a/modules/sdk-core/src/bitgo/safe/safes.ts b/modules/sdk-core/src/bitgo/safe/safes.ts index 6a67548d43..eab65f92d8 100644 --- a/modules/sdk-core/src/bitgo/safe/safes.ts +++ b/modules/sdk-core/src/bitgo/safe/safes.ts @@ -4,7 +4,9 @@ * @experimental The safe client surface is experimental and may change (including breaking * changes) before the public release. */ -import { InitializeSafeBody, SafeData } from '@bitgo/public-types'; +import { FinalizeSafeBody, InitializeSafeBody, RootKeyTriplet, RootKeyType, SafeData } from '@bitgo/public-types'; +import { Environments } from '../../common'; +import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { decodeWithCodec } from '../utils/codecs'; import { postWithCodec } from '../utils/postWithCodec'; @@ -12,6 +14,28 @@ import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe'; import { CreateSafeOptions, GetSafeOptions, ISafes, ListSafesOptions, SafeCreationHandle, SafeKeys } from './iSafes'; import { Safe } from './safe'; +/** + * Representative coin per root slot, by network. Safe roots are curve/scheme-scoped, not + * coin-scoped — WP stamps `curve` server-side from the coin's key curve, so any coin of the + * right (curve, scheme) works. These are stable, always-available choices used only to route + * the key-generation ceremony; the resulting root is interchangeable across coins of the slot. + * @experimental + */ +const ROOT_COIN_BY_NETWORK: Record<'mainnet' | 'testnet', Record> = { + mainnet: { + secp256k1Multisig: 'btc', + ecdsaMpc: 'eth', + eddsaMpc: 'sol', + ed25519Multisig: 'algo', + }, + testnet: { + secp256k1Multisig: 'tbtc', + ecdsaMpc: 'hteth', + eddsaMpc: 'tsol', + ed25519Multisig: 'talgo', + }, +}; + /** * 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 @@ -37,16 +61,24 @@ export class Safes implements ISafes { } /** - * One-call convenience wrapper: initialize → createSafeKeys (4 safeId-tagged ceremonies) → - * finalize → keycard. HOT custody only. Implemented in WCN-1192 Phase 2. + * One-call convenience wrapper chaining the three creation phases: + * initialize (Phase 1) → createSafeKeys (Phase 2, 4 safeId-tagged ceremonies) → + * finalize (Phase 3). HOT custody only in v1. + * + * If a key ceremony fails, `createSafeKeys` archives the safe before throwing, so a failed run + * leaves no half-created safe behind — create a new safe and retry. + * @experimental */ async generateSafe(params: CreateSafeOptions): Promise { - throw new Error('Safes.generateSafe is not yet implemented (WCN-1192 Phase 2)'); + const safe = await this.initializeSafe({ label: params.label }); + const rootKeys = await this.createSafeKeys({ ...params, safeId: safe.id() }); + return await this.finalizeSafe(safe.id(), rootKeys); } /** * Phase 1 — initialize a safe (metadata only, no key material). * POST /api/v2/enterprise/:eId/safes { label } + * @experimental */ async initializeSafe(params: InitializeSafeOptions): Promise { const response = await postWithCodec(this.bitgo, this.url(), InitializeSafeBody, params).result(); @@ -55,24 +87,165 @@ export class Safes implements ISafes { } /** - * 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). + * 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. + * + * All 4 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. + * + * Not independently idempotent: re-running against a safe that already has some roots hits WP's + * unique root-slot index. + * @experimental */ async createSafeKeys(params: CreateSafeOptions & SafeCreationHandle): Promise { - throw new Error('Safes.createSafeKeys is not yet implemented (WCN-1192 Phase 2)'); + const { safeId, passphrase } = params; + const enterprise = this.enterpriseId; + + // Slots are ordered to match the destructuring below; keep the two lists in sync. + const slots: RootKeyType[] = ['secp256k1Multisig', 'ecdsaMpc', 'eddsaMpc', 'ed25519Multisig']; + const results = await Promise.allSettled([ + // Phase 2.1 — multisig roots (①④): local user/backup keypairs + BitGo key, all safeId-tagged. + this.createMultisigRoot('secp256k1Multisig', 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), + this.createMultisigRoot('ed25519Multisig', safeId, passphrase, enterprise), + ]); + + // 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']; + const failures: string[] = []; + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + hot[slots[i]] = result.value; + } else { + const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); + failures.push(`${slots[i]}: ${reason}`); + } + }); + + if (failures.length > 0) { + // Best-effort abandon the partially-created safe so a retry starts from a clean slate. Keep + // the ceremony failures as the primary error; note it if the cleanup archive also failed. + let archiveError: Error | undefined; + try { + await this.archiveSafe(safeId); + } catch (e) { + archiveError = e instanceof Error ? e : new Error(String(e)); + } + let message = + `Safe key generation failed for ${failures.length} root ceremony/ies ` + + `[${failures.join('; ')}]. The safe (${safeId}) has been archived; create a new safe and retry.`; + if (archiveError) { + message += ` NOTE: archiving the safe also failed (${archiveError.message}); archive it manually before retrying.`; + } + throw new Error(message); + } + + return { rootKeys: { hot } }; + } + + /** + * Archive a safe. Also the abandonment path for a stuck `initializing` safe (used by + * `createSafeKeys` to clean up after a failed ceremony). + * POST /api/v2/enterprise/:eId/safes/:safeId/archive + * @experimental + */ + private async archiveSafe(safeId: string): Promise { + const response = await this.bitgo + .post(this.url(`/${safeId}/archive`)) + .send() + .result(); + const safeData = decodeWithCodec(SafeData, response, 'SafeData'); + return new Safe(this.bitgo, safeData); } /** * 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). + * POST /api/v2/enterprise/:eId/safes/:safeId/finalize { rootKeys }. + * Idempotent: re-finalizing with the same `rootKeys` returns the active safe again (200). + * @experimental */ async finalizeSafe(safeId: string, params: FinalizeSafeOptions): Promise { - throw new Error('Safes.finalizeSafe is not yet implemented (WCN-1192 Phase 2)'); + const response = await postWithCodec( + this.bitgo, + this.url(`/${safeId}/finalize`), + FinalizeSafeBody, + params + ).result(); + const safeData = decodeWithCodec(SafeData, response, 'SafeData'); + return new Safe(this.bitgo, safeData); + } + + /** + * Coin used to route a given root's key ceremony (see ROOT_COIN_BY_NETWORK). + * @experimental + */ + private coinForRoot(slot: RootKeyType): IBaseCoin { + // V1Network is exactly 'bitcoin' | 'testnet', so this branch is exhaustive over every + // environment: 'bitcoin' is mainnet and every other value is a testnet. + const network = Environments[this.bitgo.getEnv()].network === 'bitcoin' ? 'mainnet' : 'testnet'; + return this.bitgo.coin(ROOT_COIN_BY_NETWORK[network][slot]); + } + + /** + * Phase 2.1 helper — mint one onchain-multisig root (cold model, as wallet creation does today): + * generate user/backup keypairs locally, encrypt with `passphrase`, register via POST /:coin/key + * with `safeId`; the BitGo key is created through the same route's `source:'bitgo'` path. + * Returns the [user, backup, bitgo] key id triplet. + * @experimental + */ + private async createMultisigRoot( + slot: 'secp256k1Multisig' | 'ed25519Multisig', + safeId: string, + passphrase: string, + enterprise: string + ): Promise { + const keychains = this.coinForRoot(slot).keychains(); + + const userKeyPair = keychains.create(); + const userKeychainPromise = keychains.add({ + pub: userKeyPair.pub, + encryptedPrv: await this.bitgo.encrypt({ input: userKeyPair.prv, password: passphrase }), + source: 'user', + safeId, + }); + const backupKeychainPromise = keychains.createBackup({ passphrase, safeId }); + const bitgoKeychainPromise = keychains.createBitGo({ enterprise, safeId }); + + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + userKeychainPromise, + backupKeychainPromise, + bitgoKeychainPromise, + ]); + return [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; + } + + /** + * Phase 2.2 helper — run one MPC ceremony (DKLS for ecdsaMpc, EdDSA for eddsaMpc) via the + * existing `createMpc` flow with `safeId` threaded through, so the resulting user/backup/bitgo + * keys land tagged. Returns the [user, backup, bitgo] key id triplet. + * @experimental + */ + private async createMpcRoot( + slot: 'ecdsaMpc' | 'eddsaMpc', + safeId: string, + passphrase: string, + enterprise: string + ): Promise { + const { userKeychain, backupKeychain, bitgoKeychain } = await this.coinForRoot(slot) + .keychains() + .createMpc({ multisigType: 'tss', passphrase, enterprise, safeId }); + return [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; } /** * List the enterprise's safes (cursor pagination). * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). + * @experimental */ async list(params: ListSafesOptions = {}): Promise<{ safes: Safe[]; nextCursor?: string }> { throw new Error('Safes.list is not yet implemented (WCN-1192 Phase 3)'); @@ -81,6 +254,7 @@ export class Safes implements ISafes { /** * Fetch a single safe by id. * Implemented in WCN-1192 Phase 3 (blocked on WCN-1177). + * @experimental */ async get(params: GetSafeOptions): Promise { throw new Error('Safes.get is not yet implemented (WCN-1192 Phase 3)'); diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts index 5bd3b40546..945535c837 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts @@ -112,6 +112,9 @@ export class EcdsaUtils extends BaseEcdsaUtils { originalPasscodeEncryptionCode?: string | undefined; webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; + // Accepted for signature-compatibility with the MPCv2 path (Wallet Safes v1); the legacy + // MPCv1 ceremony does not support safe root tagging, so it is intentionally ignored here. + safeId?: string; }): Promise { const MPC = new Ecdsa(); const m = 2; diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index d8216d2de3..9d8fb1c426 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -72,6 +72,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { retrofit?: DecryptedRetrofitPayload; webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; + // Wallet Safes v1 (@experimental): tags the resulting user/backup/bitgo root keys with this safe. + safeId?: string; }): Promise { const { userSession, backupSession } = this.getUserAndBackupSession(2, 3, params.retrofit); const userGpgKey = await generateGPGKeyPair('secp256k1'); @@ -125,7 +127,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { ...round1Messages, walletId: params.retrofit.walletId, } - : round1Messages + : round1Messages, + params.safeId ); // #endregion @@ -340,7 +343,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { params.webauthnInfo, encryptionSession, params.encryptionVersion, - params.enterprise + params.enterprise, + params.safeId ); const backupKeychainPromise = this.addBackupKeychain( bitgoCommonKeychain, @@ -349,9 +353,10 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { params.passphrase, params.originalPasscodeEncryptionCode, encryptionSession, - params.encryptionVersion + params.encryptionVersion, + params.safeId ); - const bitgoKeychainPromise = this.addBitgoKeychain(bitgoCommonKeychain); + const bitgoKeychainPromise = this.addBitgoKeychain(bitgoCommonKeychain, params.safeId); const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ userKeychainPromise, @@ -385,7 +390,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { destroy(): void; }, encryptionVersion?: EncryptionVersion, - enterprise?: string + enterprise?: string, + safeId?: string ): Promise { let source: string; let encryptedPrv: string | undefined = undefined; @@ -437,6 +443,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { encryptedPrv, originalPasscodeEncryptionCode, isMPCv2: true, + safeId, }; if (webauthnInfo && participantIndex === MPCv2PartiesEnum.USER && privateMaterialBase64) { @@ -581,7 +588,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { destroy(): void; }, encryptionVersion?: EncryptionVersion, - enterprise?: string + enterprise?: string, + safeId?: string ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.USER, @@ -593,7 +601,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { webauthnInfo, encryptionSession, encryptionVersion, - enterprise + enterprise, + safeId ); } @@ -608,7 +617,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { decrypt(ciphertext: string): Promise; destroy(): void; }, - encryptionVersion?: EncryptionVersion + encryptionVersion?: EncryptionVersion, + safeId?: string ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.BACKUP, @@ -619,7 +629,9 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { originalPasscodeEncryptionCode, undefined, encryptionSession, - encryptionVersion + encryptionVersion, + undefined, + safeId ); } @@ -642,8 +654,20 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { return { userSession, backupSession }; } - private async addBitgoKeychain(commonKeychain: string): Promise { - return this.createParticipantKeychain(MPCv2PartiesEnum.BITGO, commonKeychain); + private async addBitgoKeychain(commonKeychain: string, safeId?: string): Promise { + return this.createParticipantKeychain( + MPCv2PartiesEnum.BITGO, + commonKeychain, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + safeId + ); } // #endregion @@ -737,10 +761,11 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { enterprise: string, userGpgPublicKey: string, backupGpgPublicKey: string, - payload: DklsTypes.AuthEncMessages & { walletId?: string } + payload: DklsTypes.AuthEncMessages & { walletId?: string }, + safeId?: string ): Promise { return this.sendKeyGenerationRound1BySender( - KeyGenSenderForEnterprise(this.bitgo, enterprise), + KeyGenSenderForEnterprise(this.bitgo, enterprise, safeId), userGpgPublicKey, backupGpgPublicKey, payload diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts index d488c1bed6..6e6d649022 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts @@ -9,12 +9,15 @@ export type EcdsaMPCv2KeyGenSendFn = export function KeyGenSenderForEnterprise( bitgo: BitGoBase, - enterprise: string + enterprise: string, + // Wallet Safes v1 (@experimental): when set, tags the resulting root keys with this safe. WP only reads it on + // round MPCv2-R1; passing it on a sender used solely for round 1 is sufficient. + safeId?: string ): EcdsaMPCv2KeyGenSendFn { return (round, payload) => { return bitgo .post(bitgo.url('/mpc/generatekey', 2)) - .send({ enterprise, type: KeyGenTypeEnum.MPCv2, round, payload }) + .send({ enterprise, safeId, type: KeyGenTypeEnum.MPCv2, round, payload }) .result(); }; } diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts index 20981ba76d..400d67402c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -380,6 +380,9 @@ export class EddsaUtils extends baseTSSUtils { originalPasscodeEncryptionCode?: string; webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; + // Accepted for signature-compatibility with the MPCv2 path (Wallet Safes v1); the legacy + // MPCv1 ceremony does not support safe root tagging, so it is intentionally ignored here. + safeId?: string; }): Promise { const MPC = await Eddsa.initialize(); const m = 2; diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 566bfa4a2d..311262515c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -63,6 +63,8 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { originalPasscodeEncryptionCode?: string; webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; + // Wallet Safes v1 (@experimental): tags the resulting user/backup/bitgo root keys with this safe. + safeId?: string; }): Promise { const userKeyPair = await generateGPGKeyPair('ed25519'); const userGpgKey = await pgp.readPrivateKey({ armoredKey: userKeyPair.privateKey }); @@ -105,12 +107,16 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); - const { sessionId, bitgoMsg1 } = await this.sendKeyGenerationRound1(params.enterprise, { - userGpgPublicKey, - backupGpgPublicKey, - userMsg1: userSignedMsg1, - backupMsg1: backupSignedMsg1, - }); + const { sessionId, bitgoMsg1 } = await this.sendKeyGenerationRound1( + params.enterprise, + { + userGpgPublicKey, + backupGpgPublicKey, + userMsg1: userSignedMsg1, + backupMsg1: backupSignedMsg1, + }, + params.safeId + ); // #endregion // #region round 2 @@ -189,7 +195,8 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { params.originalPasscodeEncryptionCode, params.webauthnInfo, params.encryptionVersion, - params.enterprise + params.enterprise, + params.safeId ); const backupKeychainPromise = this.addBackupKeychain( backupCommonKeychain, @@ -197,9 +204,10 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { backupReducedPrivateMaterial, params.passphrase, params.originalPasscodeEncryptionCode, - params.encryptionVersion + params.encryptionVersion, + params.safeId ); - const bitgoKeychainPromise = this.addBitgoKeychain(userCommonKeychain); + const bitgoKeychainPromise = this.addBitgoKeychain(userCommonKeychain, params.safeId); const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ userKeychainPromise, @@ -314,7 +322,8 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { originalPasscodeEncryptionCode?: string, webauthnInfo?: WebauthnKeyEncryptionInfo, encryptionVersion?: EncryptionVersion, - enterprise?: string + enterprise?: string, + safeId?: string ): Promise { let source: string; let encryptedPrv: string | undefined = undefined; @@ -360,6 +369,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { encryptedPrv, originalPasscodeEncryptionCode, isMPCv2: true, + safeId, }; if (webauthnInfo && participantIndex === MPCv2PartiesEnum.USER && privateMaterialBase64) { @@ -389,7 +399,8 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { originalPasscodeEncryptionCode?: string, webauthnInfo?: WebauthnKeyEncryptionInfo, encryptionVersion?: EncryptionVersion, - enterprise?: string + enterprise?: string, + safeId?: string ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.USER, @@ -400,7 +411,8 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { originalPasscodeEncryptionCode, webauthnInfo, encryptionVersion, - enterprise + enterprise, + safeId ); } @@ -410,7 +422,8 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { reducedPrivateMaterial: Buffer, passphrase: string, originalPasscodeEncryptionCode?: string, - encryptionVersion?: EncryptionVersion + encryptionVersion?: EncryptionVersion, + safeId?: string ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.BACKUP, @@ -420,20 +433,34 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { passphrase, originalPasscodeEncryptionCode, undefined, - encryptionVersion + encryptionVersion, + undefined, + safeId ); } - private async addBitgoKeychain(commonKeychain: string): Promise { - return this.createParticipantKeychain(MPCv2PartiesEnum.BITGO, commonKeychain); + private async addBitgoKeychain(commonKeychain: string, safeId?: string): Promise { + return this.createParticipantKeychain( + MPCv2PartiesEnum.BITGO, + commonKeychain, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + safeId + ); } // #endregion async sendKeyGenerationRound1( enterprise: string, - payload: EddsaMPCv2KeyGenRound1Request + payload: EddsaMPCv2KeyGenRound1Request, + safeId?: string ): Promise { - return this.sendKeyGenerationRound1BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise), payload); + return this.sendKeyGenerationRound1BySender(KeyGenSenderForEnterprise(this.bitgo, enterprise, safeId), payload); } async sendKeyGenerationRound1BySender( diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts index f11dae539d..c335f92adb 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2KeyGenSender.ts @@ -9,12 +9,15 @@ export type EddsaMPCv2KeyGenSendFn( bitgo: BitGoBase, - enterprise: string + enterprise: string, + // Wallet Safes v1 (@experimental): when set, tags the resulting root keys with this safe. WP only reads it on + // round MPCv2-R1; passing it on a sender used solely for round 1 is sufficient. + safeId?: string ): EddsaMPCv2KeyGenSendFn { return (round, payload) => { return bitgo .post(bitgo.url('/mpc/generatekey', 2)) - .send({ enterprise, type: KeyGenTypeEnum.MPCv2, curveType: KeyCurveEnum.EdDSA, round, payload }) + .send({ enterprise, safeId, type: KeyGenTypeEnum.MPCv2, curveType: KeyCurveEnum.EdDSA, round, payload }) .result(); }; } diff --git a/modules/sdk-core/test/unit/bitgo/safe/safes.ts b/modules/sdk-core/test/unit/bitgo/safe/safes.ts index ebab857957..f307a62ecb 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safes.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safes.ts @@ -42,34 +42,205 @@ describe('Safes', function () { }); }); - // generateSafe/createSafeKeys/finalize and list/get are not yet implemented — gated on - // WCN-1175 / WCN-1176 / WCN-1177. - 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/); + describe('createSafeKeys', function () { + // Per-coin keychains() mock: multisig coins use create/add/createBackup/createBitGo; MPC coins + // use createMpc. Ids are derived from the coin name so we can assert per-slot coin routing. + function makeKeychains(coin: string) { + return { + create: sinon.stub().returns({ pub: `${coin}-pub`, prv: `${coin}-prv` }), + add: sinon.stub().resolves({ id: `${coin}-user` }), + createBackup: sinon.stub().resolves({ id: `${coin}-backup` }), + createBitGo: sinon.stub().resolves({ id: `${coin}-bitgo` }), + createMpc: sinon.stub().resolves({ + userKeychain: { id: `${coin}-user` }, + backupKeychain: { id: `${coin}-backup` }, + bitgoKeychain: { id: `${coin}-bitgo` }, + }), + }; + } + + let keychainsByCoin: Record>; + + beforeEach(function () { + keychainsByCoin = {}; + mockBitGo.getEnv = sinon.stub().returns('test'); // testnet coin map + mockBitGo.encrypt = sinon.stub().resolves('encrypted-prv'); + mockBitGo.coin = sinon.stub().callsFake((name: string) => { + keychainsByCoin[name] = keychainsByCoin[name] || makeKeychains(name); + return { keychains: () => keychainsByCoin[name] }; + }); }); - 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('runs the 4 root ceremonies and returns 12 ids as ordered triplets', async function () { + const result = await safes.createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }); + + result.should.deepEqual({ + rootKeys: { + hot: { + secp256k1Multisig: ['tbtc-user', 'tbtc-backup', 'tbtc-bitgo'], + ecdsaMpc: ['hteth-user', 'hteth-backup', 'hteth-bitgo'], + eddsaMpc: ['tsol-user', 'tsol-backup', 'tsol-bitgo'], + ed25519Multisig: ['talgo-user', 'talgo-backup', 'talgo-bitgo'], + }, + }, + }); }); - it('finalizeSafe throws (Phase 2)', async function () { + it('tags every multisig key registration with safeId and the correct source', async function () { + await safes.createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }); + + const btc = keychainsByCoin['tbtc']; + sinon.assert.calledWithMatch(btc.add, { + pub: 'tbtc-pub', + encryptedPrv: 'encrypted-prv', + source: 'user', + safeId: 'safe-1', + }); + sinon.assert.calledWithMatch(btc.createBackup, { passphrase: 'pw', safeId: 'safe-1' }); + sinon.assert.calledWithMatch(btc.createBitGo, { enterprise: 'test-enterprise-id', safeId: 'safe-1' }); + }); + + it('threads safeId + enterprise into both MPC ceremonies', async function () { + await safes.createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }); + + sinon.assert.calledWithMatch(keychainsByCoin['hteth'].createMpc, { + multisigType: 'tss', + passphrase: 'pw', + enterprise: 'test-enterprise-id', + safeId: 'safe-1', + }); + sinon.assert.calledWithMatch(keychainsByCoin['tsol'].createMpc, { + multisigType: 'tss', + passphrase: 'pw', + enterprise: 'test-enterprise-id', + safeId: 'safe-1', + }); + }); + + it('selects mainnet coins in a prod environment', async function () { + mockBitGo.getEnv = sinon.stub().returns('prod'); + await safes.createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }); + + sinon.assert.calledWith(mockBitGo.coin, 'btc'); + sinon.assert.calledWith(mockBitGo.coin, 'eth'); + sinon.assert.calledWith(mockBitGo.coin, 'sol'); + sinon.assert.calledWith(mockBitGo.coin, 'algo'); + }); + + it('runs the 4 root ceremonies in parallel (none awaits another)', async function () { + // Gate exactly one call per root on a shared latch that only opens once all 4 roots have + // entered. If the roots ran sequentially, the first would await the latch forever → the + // test would time out; completing proves all 4 were in flight concurrently. + let release: () => void = () => undefined; + const gate = new Promise((resolve) => (release = resolve)); + let started = 0; + const gated = (result: any) => + sinon.stub().callsFake(async () => { + if (++started === 4) { + release(); + } + await gate; + return result; + }); + // one gated entry point per root: multisig roots via createBitGo, MPC roots via createMpc + keychainsByCoin['tbtc'] = makeKeychains('tbtc'); + keychainsByCoin['tbtc'].createBitGo = gated({ id: 'tbtc-bitgo' }); + keychainsByCoin['talgo'] = makeKeychains('talgo'); + keychainsByCoin['talgo'].createBitGo = gated({ id: 'talgo-bitgo' }); + keychainsByCoin['hteth'] = makeKeychains('hteth'); + keychainsByCoin['hteth'].createMpc = gated({ + userKeychain: { id: 'hteth-user' }, + backupKeychain: { id: 'hteth-backup' }, + bitgoKeychain: { id: 'hteth-bitgo' }, + }); + keychainsByCoin['tsol'] = makeKeychains('tsol'); + keychainsByCoin['tsol'].createMpc = gated({ + userKeychain: { id: 'tsol-user' }, + backupKeychain: { id: 'tsol-backup' }, + bitgoKeychain: { id: 'tsol-bitgo' }, + }); + + await safes.createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }); + started.should.equal(4); + }); + + 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'); + keychainsByCoin['hteth'].createMpc = sinon.stub().rejects(new Error('dkls boom')); + keychainsByCoin['talgo'] = makeKeychains('talgo'); + keychainsByCoin['talgo'].createBitGo = sinon.stub().rejects(new Error('bitgo key boom')); + // archive succeeds, returning a valid (archived) SafeData + const send = sinon.stub().returns({ + result: sinon.stub().resolves({ ...safeDataWire, status: 'archived' }), + }); + mockBitGo.post.returns({ send }); + + const err = await safes + .createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }) + .should.be.rejectedWith(/Safe key generation failed for 2 root/); + err.message.should.match(/ecdsaMpc: dkls boom/); + err.message.should.match(/ed25519Multisig: bitgo key boom/); + err.message.should.match(/has been archived/); + err.message.should.not.match(/archiving the safe also failed/); + sinon.assert.calledWith(mockBitGo.post, '/enterprise/test-enterprise-id/safes/safe-1/archive'); + }); + + it('notes when archiving also fails', async function () { + keychainsByCoin['tsol'] = makeKeychains('tsol'); + keychainsByCoin['tsol'].createMpc = sinon.stub().rejects(new Error('eddsa boom')); + mockBitGo.post.returns({ + send: sinon.stub().returns({ result: sinon.stub().rejects(new Error('archive 500')) }), + }); + 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/); + .createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }) + .should.be.rejectedWith(/archiving the safe also failed \(archive 500\).*archive it manually/); + }); + }); + + describe('finalizeSafe', function () { + const rootKeys = { + hot: { + secp256k1Multisig: ['u1', 'b1', 'g1'] as [string, string, string], + ecdsaMpc: ['u2', 'b2', 'g2'] as [string, string, string], + eddsaMpc: ['u3', 'b3', 'g3'] as [string, string, string], + ed25519Multisig: ['u4', 'b4', 'g4'] as [string, string, string], + }, + }; + + it('POSTs the rootKeys to the finalize URL and returns the active Safe', async function () { + const send = sinon.stub().returns({ result: sinon.stub().resolves(safeDataWire) }); + mockBitGo.post.returns({ send }); + + const result = await safes.finalizeSafe('test-safe-id', { rootKeys }); + + result.should.be.instanceof(Safe); + result.status().should.equal('active'); + sinon.assert.calledWith(mockBitGo.post, '/enterprise/test-enterprise-id/safes/test-safe-id/finalize'); + sinon.assert.calledWithMatch(send, { rootKeys }); + }); + }); + + describe('generateSafe', function () { + it('chains initialize → createSafeKeys → finalize, threading the safeId', async function () { + const initializing = new Safe(mockBitGo, { ...safeDataWire, status: 'initializing' } as any); + const rootKeys = { rootKeys: { hot: {} } } as any; + 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.calledWith(finalizeStub, 'test-safe-id', rootKeys); + sinon.assert.callOrder(initStub, keysStub, finalizeStub); + result.status().should.equal('active'); }); + }); + describe('unimplemented lifecycle methods', function () { it('list throws (Phase 3)', async function () { await safes.list().should.be.rejectedWith(/not yet implemented .*Phase 3/); }); From aeeaa6ad1eb8ebab54937fb0a3505ce726569306 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Wed, 15 Jul 2026 16:49:27 -0400 Subject: [PATCH 2/2] chore: address PR comments and set XLM as rep coin WCN-1192 --- modules/sdk-core/src/bitgo/safe/iSafes.ts | 5 ++++ modules/sdk-core/src/bitgo/safe/safes.ts | 17 ++++++++----- .../sdk-core/test/unit/bitgo/safe/safes.ts | 25 ++++++++++++++----- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/modules/sdk-core/src/bitgo/safe/iSafes.ts b/modules/sdk-core/src/bitgo/safe/iSafes.ts index 0d0d790b8a..ed51e8e86b 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafes.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafes.ts @@ -76,6 +76,11 @@ export interface ISafes { * @experimental */ finalizeSafe(safeId: string, params: FinalizeSafeOptions): Promise; + /** + * Archive a safe. Also the abandonment path for a stuck `initializing` safe. + * @experimental + */ + archiveSafe(safeId: string): Promise; /** @experimental */ list(params?: ListSafesOptions): Promise<{ safes: Safe[]; nextCursor?: string }>; /** @experimental */ diff --git a/modules/sdk-core/src/bitgo/safe/safes.ts b/modules/sdk-core/src/bitgo/safe/safes.ts index eab65f92d8..32c436592a 100644 --- a/modules/sdk-core/src/bitgo/safe/safes.ts +++ b/modules/sdk-core/src/bitgo/safe/safes.ts @@ -23,16 +23,20 @@ import { Safe } from './safe'; */ const ROOT_COIN_BY_NETWORK: Record<'mainnet' | 'testnet', Record> = { mainnet: { + // multisig roots secp256k1Multisig: 'btc', + ed25519Multisig: 'xlm', + // MPC roots ecdsaMpc: 'eth', eddsaMpc: 'sol', - ed25519Multisig: 'algo', }, testnet: { + // multisig roots secp256k1Multisig: 'tbtc', + ed25519Multisig: 'txlm', + // MPC roots ecdsaMpc: 'hteth', eddsaMpc: 'tsol', - ed25519Multisig: 'talgo', }, }; @@ -103,15 +107,16 @@ export class Safes implements ISafes { const { safeId, passphrase } = params; const enterprise = this.enterpriseId; - // Slots are ordered to match the destructuring below; keep the two lists in sync. - const slots: RootKeyType[] = ['secp256k1Multisig', 'ecdsaMpc', 'eddsaMpc', 'ed25519Multisig']; + // `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([ // 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), // 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), - this.createMultisigRoot('ed25519Multisig', safeId, passphrase, enterprise), ]); // Single pass over the settled results: `status === 'fulfilled'` narrows `.value` to a @@ -154,7 +159,7 @@ export class Safes implements ISafes { * POST /api/v2/enterprise/:eId/safes/:safeId/archive * @experimental */ - private async archiveSafe(safeId: string): Promise { + async archiveSafe(safeId: string): Promise { const response = await this.bitgo .post(this.url(`/${safeId}/archive`)) .send() diff --git a/modules/sdk-core/test/unit/bitgo/safe/safes.ts b/modules/sdk-core/test/unit/bitgo/safe/safes.ts index f307a62ecb..73d5eca6a9 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safes.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safes.ts @@ -80,7 +80,7 @@ describe('Safes', function () { secp256k1Multisig: ['tbtc-user', 'tbtc-backup', 'tbtc-bitgo'], ecdsaMpc: ['hteth-user', 'hteth-backup', 'hteth-bitgo'], eddsaMpc: ['tsol-user', 'tsol-backup', 'tsol-bitgo'], - ed25519Multisig: ['talgo-user', 'talgo-backup', 'talgo-bitgo'], + ed25519Multisig: ['txlm-user', 'txlm-backup', 'txlm-bitgo'], }, }, }); @@ -124,7 +124,7 @@ describe('Safes', function () { sinon.assert.calledWith(mockBitGo.coin, 'btc'); sinon.assert.calledWith(mockBitGo.coin, 'eth'); sinon.assert.calledWith(mockBitGo.coin, 'sol'); - sinon.assert.calledWith(mockBitGo.coin, 'algo'); + sinon.assert.calledWith(mockBitGo.coin, 'xlm'); }); it('runs the 4 root ceremonies in parallel (none awaits another)', async function () { @@ -145,8 +145,8 @@ describe('Safes', function () { // one gated entry point per root: multisig roots via createBitGo, MPC roots via createMpc keychainsByCoin['tbtc'] = makeKeychains('tbtc'); keychainsByCoin['tbtc'].createBitGo = gated({ id: 'tbtc-bitgo' }); - keychainsByCoin['talgo'] = makeKeychains('talgo'); - keychainsByCoin['talgo'].createBitGo = gated({ id: 'talgo-bitgo' }); + keychainsByCoin['txlm'] = makeKeychains('txlm'); + keychainsByCoin['txlm'].createBitGo = gated({ id: 'txlm-bitgo' }); keychainsByCoin['hteth'] = makeKeychains('hteth'); keychainsByCoin['hteth'].createMpc = gated({ userKeychain: { id: 'hteth-user' }, @@ -168,8 +168,8 @@ describe('Safes', function () { // Two ceremonies fail (an MPC and a multisig root). keychainsByCoin['hteth'] = makeKeychains('hteth'); keychainsByCoin['hteth'].createMpc = sinon.stub().rejects(new Error('dkls boom')); - keychainsByCoin['talgo'] = makeKeychains('talgo'); - keychainsByCoin['talgo'].createBitGo = sinon.stub().rejects(new Error('bitgo key boom')); + keychainsByCoin['txlm'] = makeKeychains('txlm'); + keychainsByCoin['txlm'].createBitGo = sinon.stub().rejects(new Error('bitgo key boom')); // archive succeeds, returning a valid (archived) SafeData const send = sinon.stub().returns({ result: sinon.stub().resolves({ ...safeDataWire, status: 'archived' }), @@ -222,6 +222,19 @@ describe('Safes', function () { }); }); + describe('archiveSafe', function () { + it('POSTs to the archive URL and returns the archived Safe', async function () { + const send = sinon.stub().returns({ result: sinon.stub().resolves({ ...safeDataWire, status: 'archived' }) }); + mockBitGo.post.returns({ send }); + + const result = await safes.archiveSafe('test-safe-id'); + + result.should.be.instanceof(Safe); + result.status().should.equal('archived'); + sinon.assert.calledWith(mockBitGo.post, '/enterprise/test-enterprise-id/safes/test-safe-id/archive'); + }); + }); + describe('generateSafe', function () { it('chains initialize → createSafeKeys → finalize, threading the safeId', async function () { const initializing = new Safe(mockBitGo, { ...safeDataWire, status: 'initializing' } as any);