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
22 changes: 22 additions & 0 deletions modules/bitgo/test/v2/unit/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
5 changes: 4 additions & 1 deletion modules/sdk-api/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ function errFromResponse<ResponseBodyType>(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);
}

/**
Expand Down
15 changes: 14 additions & 1 deletion modules/sdk-core/src/bitgo/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -185,20 +193,25 @@ export class ApiResponseError<ResponseBodyType = any> 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;
this.status = status;
this.result = result;
this.invalidToken = invalidToken;
this.needsOTP = needsOTP;
this.requestId = requestId;
}
}

Expand Down
14 changes: 12 additions & 2 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion modules/sdk-core/src/bitgo/safe/safes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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})` : ''}`);
}
});

Expand Down
Loading