diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index 8a840a5ddb..07eed600e7 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -587,6 +587,28 @@ describe('V2 Keychains', function () { }); }); + ['tbsc'].forEach((coin) => { + it('should reject safe root creation when the resolved ceremony is legacy MPCv1', async function () { + nock(bgUrl).get('/api/v2/tss/settings').reply(200, { + coinSettings: {}, + }); + const createKeychains = sandbox + .stub(ECDSAUtils.EcdsaUtils.prototype, 'createKeychains') + .resolves(stubbedKeychainsTriplet); + await bitgo + .coin(coin) + .keychains() + .createMpc({ + multisigType: 'tss', + passphrase: 'password', + enterprise: 'enterprise', + safeId: 'safeId', + }) + .should.be.rejectedWith(/legacy MPCv1 ceremony/); + createKeychains.called.should.be.false(); + }); + }); + ['tbsc'].forEach((coin) => { it('should pass webauthnInfo to createKeychains for ECDSA TSS', async function () { nock(bgUrl).get('/api/v2/tss/settings').reply(200, { diff --git a/modules/sdk-api/src/api.ts b/modules/sdk-api/src/api.ts index e132426f81..05ca6a36ac 100644 --- a/modules/sdk-api/src/api.ts +++ b/modules/sdk-api/src/api.ts @@ -75,7 +75,10 @@ function errFromResponse(res: superagent.Response): ApiRespons const result = res.body as ResponseBodyType; const invalidToken = _.has(res.header, 'x-auth-required') && res.header['x-auth-required'] === 'true'; const needsOtp = res.body?.needsOTP !== undefined; - return new ApiResponseError(message, status, result, invalidToken, needsOtp); + // Server echoes the client's `Request-ID` header (or one it generated) back on every + // response, including errors — surface it so failures can be correlated with server logs. + const requestId = res.header?.['request-id']; + return new ApiResponseError(message, status, result, invalidToken, needsOtp, requestId); } /** diff --git a/modules/sdk-core/src/bitgo/errors.ts b/modules/sdk-core/src/bitgo/errors.ts index b6d92b71ad..abe381799c 100644 --- a/modules/sdk-core/src/bitgo/errors.ts +++ b/modules/sdk-core/src/bitgo/errors.ts @@ -29,6 +29,14 @@ export class UnsupportedCoinError extends BitGoJsError { } } +export class SafeMpcCeremonyUnsupportedError extends BitGoJsError { + public constructor(coinFamily: string) { + super( + `Cannot create Wallet Safe root keys for coin family '${coinFamily}': its TSS settings resolve to the legacy MPCv1 ceremony, which does not support safe root tagging (safeId). Enable MPCv2 for '${coinFamily}' before creating a safe with this root type.` + ); + } +} + export class AddressTypeChainMismatchError extends BitGoJsError { constructor(addressType: string, chain: number | string) { super(`address type ${addressType} does not correspond to chain ${chain}`); @@ -185,13 +193,17 @@ export class ApiResponseError extends BitGoJsError { result?: ResponseBodyType; invalidToken?: boolean; needsOTP?: boolean; + // Echoed back via the `Request-ID` response header (see logging-express's `beginLogging`), + // so it can be used to correlate this error with the server-side request logs. + requestId?: string; public constructor( message: string, status: number, result?: ResponseBodyType, invalidToken?: boolean, - needsOTP?: boolean + needsOTP?: boolean, + requestId?: string ) { super(message); this.message = message; @@ -199,6 +211,7 @@ export class ApiResponseError extends BitGoJsError { this.result = result; this.invalidToken = invalidToken; this.needsOTP = needsOTP; + this.requestId = requestId; } } diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index d71e465351..63a7962efb 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -4,6 +4,7 @@ import * as _ from 'lodash'; import * as common from '../../common'; import { IBaseCoin, KeychainsTriplet, KeyPair } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; +import { SafeMpcCeremonyUnsupportedError } from '../errors'; import { decodeOrElse, ECDSAUtils, EDDSAUtils, generateRandomPassword, RequestTracer } from '../utils'; import { AddKeychainOptions, @@ -371,11 +372,20 @@ export class Keychains implements IKeychains { const multisigTypeVersion = tssSettings.coinSettings[this.baseCoin.getFamily()]?.walletCreationSettings?.multiSigTypeVersion; + const isMPCv2 = multisigTypeVersion === 'MPCv2'; + if (params.safeId && !isMPCv2) { + // The legacy MPCv1 ceremonies accept a safeId for signature compatibility but silently + // ignore it (see EDDSAUtils.default.createKeychains / ECDSAUtils.EcdsaUtils.createKeychains), + // so the resulting keys never get tagged with the safe and WP's finalize step rejects them + // with a generic 400. Fail fast here instead of letting that confusing error surface later. + throw new SafeMpcCeremonyUnsupportedError(this.baseCoin.getFamily()); + } + let MpcUtils; if (this.baseCoin.getMPCAlgorithm() === 'eddsa') { - MpcUtils = multisigTypeVersion === 'MPCv2' ? EDDSAUtils.EddsaMPCv2Utils : EDDSAUtils.default; + MpcUtils = isMPCv2 ? EDDSAUtils.EddsaMPCv2Utils : EDDSAUtils.default; } else { - MpcUtils = multisigTypeVersion === 'MPCv2' ? ECDSAUtils.EcdsaMPCv2Utils : ECDSAUtils.EcdsaUtils; + MpcUtils = isMPCv2 ? ECDSAUtils.EcdsaMPCv2Utils : ECDSAUtils.EcdsaUtils; } const mpcUtils = new MpcUtils(this.bitgo, this.baseCoin); diff --git a/modules/sdk-core/src/bitgo/safe/safes.ts b/modules/sdk-core/src/bitgo/safe/safes.ts index ff499c40de..c7be05cbdd 100644 --- a/modules/sdk-core/src/bitgo/safe/safes.ts +++ b/modules/sdk-core/src/bitgo/safe/safes.ts @@ -16,6 +16,7 @@ import { import { Environments } from '../../common'; import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; +import { ApiResponseError } from '../errors'; import { decodeWithCodec } from '../utils/codecs'; import { postWithCodec } from '../utils/postWithCodec'; import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe'; @@ -147,7 +148,10 @@ export class Safes implements ISafes { hot[slots[i]] = result.value; } else { const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); - failures.push(`${slots[i]}: ${reason}`); + // ApiResponseError carries the server-echoed `Request-ID` header — include it (when + // present) so a failed ceremony can be correlated with WP's server-side logs. + const requestId = result.reason instanceof ApiResponseError ? result.reason.requestId : undefined; + failures.push(`${slots[i]}: ${reason}${requestId ? ` (requestId: ${requestId})` : ''}`); } });