Skip to content

feat: enhance multisig recovery with split AWM support#251

Merged
danielpeng1 merged 2 commits into
masterfrom
WCN-1082/split-user-backup-awm
Jul 3, 2026
Merged

feat: enhance multisig recovery with split AWM support#251
danielpeng1 merged 2 commits into
masterfrom
WCN-1082/split-user-backup-awm

Conversation

@danielpeng1

Copy link
Copy Markdown
Contributor

Adds two-phase split-AWM recovery (user half-sign -> backup full-sign) for recovery and recoveryConsolidations, for UTXO + EVM, local + external signing.

  • AWM multisigRecovery.ts: implements keyToSign (user/backup) with runtime guards on the half-signed tx shape; external signing failures now throw BitgoApiResponseError (preserves upstream status) and reject EVM backup with a rich halfSigned object (requires flat txHex).
  • MBE orchestration: when user and backup AWMs differ, signs synchronously in two phases; single-AWM behavior unchanged.
  • submitMultisigRecoveryJob gains a sources param; worker prefers awmBackupResponse when present, this is forward-compatible scaffolding.
  • Note: Split-AWM recovery always runs synchronously, even when async is enabled. Async jobs fan out to user and backup AWMs in parallel, so they can’t do step 1 (user half-sign) then step 2 (backup full-sign).

Tests

  • AWM: UTXO + EVM two-phase (local + external), keyToSign isolation, half-signed-tx guards (400s).
  • MBE: UTXO + EVM split-AWM two-phase; sync path used even with asyncEnabled (bridge not called).
  • Worker: awmBackupResponse preferred; malformed rejected. Utils: sources param. testUtils: X-OSO-Source header nock.

Ticket: WCN-1082

@danielpeng1 danielpeng1 self-assigned this Jun 26, 2026
@linear-code

linear-code Bot commented Jun 26, 2026

Copy link
Copy Markdown

WCN-1082

@danielpeng1 danielpeng1 marked this pull request as ready for review June 26, 2026 13:47
@danielpeng1 danielpeng1 requested review from a team as code owners June 26, 2026 13:47
@danielpeng1

Copy link
Copy Markdown
Contributor Author

@claude review and get_issue the ticket on linear for more context

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

Claude finished @danielpeng1's task —— View job


Code Review for Split AWM Recovery Enhancement

  • Read and analyze the PR changes across all 12 modified files
  • Attempt to get context from Linear ticket WCN-1082 (access denied, proceeding with PR context)
  • Review AWM multisig recovery implementation changes
  • Review MBE orchestration changes
  • Review worker and utility changes
  • Analyze test coverage and quality
  • Provide comprehensive code review feedback

Summary

This PR implements a significant enhancement to the multisig recovery system by adding split AWM support - enabling two-phase recovery where user keys and backup keys can be managed by separate AWM instances. The implementation is well-architected and addresses both UTXO and EVM recovery flows with proper error handling and comprehensive testing.

Key Strengths

1. Excellent Architecture Design

  • Clean separation of concerns: The keyToSign parameter elegantly controls which keys are used in each phase
  • Backward compatibility: Single-AWM mode remains unchanged, ensuring no breaking changes
  • Runtime guards: Strong validation ensures half-signed transactions match expected formats

2. Robust Error Handling

  • Preserves upstream errors: BitgoApiResponseError maintains original status codes from key provider failures (src/advancedWalletManager/handlers/multisigRecovery.ts:376-389)
  • Comprehensive validation: Both UTXO and EVM recovery have proper type guards and error messages
  • External signing safeguards: EVM backup signing properly validates flat txHex format

3. Comprehensive Test Coverage

  • 481 new test lines covering both local and external signing scenarios
  • Edge case testing: Malformed transaction validation, missing parameters, error conditions
  • Integration testing: End-to-end split-AWM recovery flows

Technical Analysis

AWM Recovery Handler (src/advancedWalletManager/handlers/multisigRecovery.ts)

Excellent additions:

// Lines 47-49: Clear validation for backup signing requirements
if (keyToSign === 'backup' && !halfSignedTransaction) {
  throw new BadRequestError('halfSignedTransaction is required when keyToSign is "backup"');
}

// Lines 58-67: Smart EVM external signing validation
if (keyToSign === 'backup' && baseCoin.isEVM()) {
  if (!halfSignedTransaction || typeof (halfSignedTransaction as { txHex?: unknown }).txHex !== 'string') {
    throw new BadRequestError('External backup signing for EVM coins requires halfSignedTransaction.txHex');
  }
}

Strong pattern for key retrieval optimization:

// Lines 80-87: Only fetch needed keys per phase
const userPrv = keyToSign === 'backup' ? undefined : await retrieveKeyProviderPrvKey(...);
const backupPrv = keyToSign === 'user' ? undefined : await retrieveKeyProviderPrvKey(...);

MBE Orchestration (src/masterBitgoExpress/handlers/recoveryWallet.ts)

Smart two-phase logic:

// Lines 119-127: Clear split-AWM detection and sequencing
if (userClient !== backupClient) {
  const halfSignedTx = await userClient.recoveryMultisigUserHalfSign(recoveryBody);
  return backupClient.recoveryMultisig({
    ...recoveryBody,
    keyToSign: 'backup',
    halfSignedTransaction: halfSignedTx,
  });
}

Worker Enhancement (src/masterBitgoExpress/workers/asyncJobWorker.ts)

Forward-compatible scaffolding:

// Lines 205-210: Intelligent response selection
const isSplitRecovery = job.awmBackupResponse !== undefined;
const finalResponse = isSplitRecovery ? job.awmBackupResponse : job.awmResponse;

Minor Recommendations

1. Code Duplication in EVM Recovery

In multisigRecovery.ts, the EVM signing logic for backup completion (lines 121-149) and full recovery (lines 204-232) share significant overlap. Consider extracting a helper function:

function buildEvmSigningParams(halfSignedTx, walletKeys, bitgoEnv) {
  return {
    gasPrice, gasLimit, // ... common params
    replayProtectionOptions: getReplayProtectionOptions(bitgoEnv, halfSignedTx?.replayProtectionOptions),
  };
}

2. Type Safety Enhancement

Consider making the keyToSign parameter required when halfSignedTransaction is provided to catch misconfigurations at compile time:

interface RecoveryMultisigOptions {
  // ... existing fields
  keyToSign: 'user' | 'backup';
  halfSignedTransaction?: keyToSign extends 'backup' ? SignedTransaction : never;
}

3. Test Organization

The test file recoveryMultisigTransaction.test.ts is quite large (481 lines). Consider splitting into separate files:

  • recoveryMultisigTransaction.utxo.test.ts
  • recoveryMultisigTransaction.evm.test.ts
  • recoveryMultisigTransaction.external.test.ts

4. Logging Consistency

Add structured logging for split recovery operations to aid in debugging:

logger.info('Split-AWM recovery initiated', { 
  userAwmUrl: userClient.baseUrl, 
  backupAwmUrl: backupClient.baseUrl,
  coin,
  phase: 'user-half-sign' 
});

Security Considerations

Proper validation of half-signed transaction formats prevents injection attacks
Key isolation ensures private keys are only retrieved when needed
Error wrapping preserves security context without leaking sensitive data
External signing validation prevents misconfiguration that could expose keys

Performance Impact

Minimal overhead for single-AWM scenarios (unchanged code path)
Optimized key retrieval (only fetch keys for current signing phase)
Efficient async job handling (smart response selection in worker)

Conclusion

This is a well-implemented, production-ready feature that significantly enhances the recovery system's flexibility. The code demonstrates excellent engineering practices with strong error handling, comprehensive testing, and thoughtful architecture. The backward compatibility and forward-compatible scaffolding show careful consideration for system evolution.

Recommendation: APPROVE

The minor suggestions above are enhancements rather than blockers. The implementation is solid and ready for production deployment.


Job Run: https://github.com/BitGo/advanced-wallets/actions/runs/
Branch: WCN-1082/split-user-backup-awm

@danielpeng1 danielpeng1 requested a review from pranavjain97 June 26, 2026 13:51
const logPrefix = '[asyncJobWorker:handleMultisigRecoveryOperation]';
const { jobId, version } = job;

const isSplitRecovery = job.awmBackupResponse !== undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we are not implementing split async recovery right now, wouldn't this be always undefined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this is just some currently dead forward compatible scaffolding for the async recovery i put in since I already had it; it's probably more confusing than not having it so i'll just remove it.

awmClient: AdvancedWalletManagerClient,
recoveryBody: MultisigRecoveryBody,
): Promise<SignedTransaction | AsyncJobResponse> {
const userClient = req.awmUserClient;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably should add a validation during setup that awmBackupUrl is mutually exclusive with aysncMode

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that's true I didn't add this here, let me do this in a followup.

// (halfSigned.txHex nested), so reject backup signing with such a payload as misconfiguration
// rather than silently passing undefined into the key provider.
if (keyToSign === 'backup' && baseCoin.isEVM()) {
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we not group these ifs together?

@@ -76,13 +108,54 @@ export async function recoveryMultisigTransaction(
DEFAULT_MUSIG_ETH_GAS_PARAMS;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this can be refactored now that I look at it:

if (!isEthLikeCoin(baseCoin)) { ... throw Error}

const { halfSigned } = halfSignedTx;
// Cast to BaseCoin for the loose SignTransactionOptions signature; the
// AbstractEthLikeNewCoins overload's stricter txPrebuild types don't fit recovery.
return await (baseCoin as BaseCoin).signTransaction({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is some duplicate code with the non-split flow. The non-split flow needs to produce the half signed transaction then sign that half-tx. The code in here with just signing the half-tx can be refactored with the second half of the non-split flow.

I would create a helper funciton to create a half signed tx and another to create a full tx, and have the handler call them.

@@ -164,34 +242,66 @@ export async function recoveryMultisigTransaction(
throw new Error(errorMsg);
}
} else if (isUtxoCoin(baseCoin)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be separate helper functions. 100 lines of if blocks are just hard to read.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, evm and utxo logic now live in their own top-lvl functions

// Wraps a key-provider signing failure so the response handler preserves upstream status/message.
function signingError(error: unknown, keySource: KeySource): BitgoApiResponseError {
const status =
error &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should be a ApiError class

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done; using BitgoApiResponseError now, and fixed a bug with the constructor not setting this.name (so responseHandler.ts's error.name === 'ApiResponseError' check never matched)

// The backup path reads halfSigned.txHex and optionally expireTime/backupKeyNonce/recipients,
// so require halfSigned to be a non-null object with a string txHex.
return (
typeof halfSigned === 'object' &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: these probably can be codecs.

@pranavjain97 pranavjain97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm mostly, some type issues

txHex: t.string,
}), // the full signed tx
200: t.any,
...ErrorResponses,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

200: t.any removes the only runtime type check on the recovery response shape. a caller getting back a malformed body won't be caught at the boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've tightened this now

logger.info('Recovering multisig (user half-sign) for coin: %s', this.coin);
const res = await request.decodeExpecting(200);

return res.body as SignedTransaction;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

res.body as SignedTransaction is unchecked since the response codec is t.any. if the AWM returns a wrong shape it silently passes through.

const backupClient = req.awmBackupClient;

// Split AWM stays sync: the OSO bridge can't sequence a user half-sign then backup full-sign.
if (userClient !== backupClient) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't a dedicated hasSeparateBackupAwm boolean be clearer here? reference equality breaks silently if clients are ever re-constructed.

@danielpeng1 danielpeng1 force-pushed the WCN-1082/split-user-backup-awm branch from 0371887 to 6f14fe1 Compare July 2, 2026 19:12
@danielpeng1

Copy link
Copy Markdown
Contributor Author

@alextse-bg @pranavjain97 I've implemented the requested changes, and added some tests for recoveryConsolidations that I found were missing. Check again if I missed anything 🙇‍♂️.

There's a couple things that I'd like to do in a followup, like this, recovery splitAwm integ test, as well as some fixes regarding error handling, but this PR is already getting pretty huge so these can come next

@pranavjain97

Copy link
Copy Markdown
Contributor

@claude

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Claude finished @pranavjain97's task —— View job


Code Review for Split AWM Recovery Enhancement

  • Read repository guidelines and understand the codebase
  • Analyze the split AWM recovery implementation changes
  • Review how previous feedback was addressed
  • Examine test coverage and quality
  • Provide comprehensive code review feedback

Summary

This PR successfully implements split AWM (Advanced Wallet Manager) recovery support, enabling two-phase recovery operations where user and backup keys can be managed by separate AWM instances. The implementation demonstrates excellent engineering practices with comprehensive error handling, robust validation, and thorough test coverage.

Key Strengths

1. Excellent Architecture & Design

  • Clean separation of concerns: The keyToSign parameter elegantly controls which keys are used in each recovery phase
  • Backward compatibility: Single-AWM behavior remains unchanged, ensuring no breaking changes
  • Runtime type safety: Strong validation with runtime guards for different transaction formats
  • Modular refactoring: Complex functions have been properly split into focused, reusable helpers

2. Outstanding Error Handling

The error handling improvements are particularly noteworthy:

  • Preserves upstream errors: BitgoApiResponseError maintains original HTTP status codes from external services (multisigRecovery.ts:506-518)
  • Rich validation messages: Clear, actionable error messages for misconfiguration scenarios
  • External signing safeguards: Proper validation for EVM external signing requiring flat txHex format
  • Proper error propagation: The fix to BitgoApiResponseError.name ensures correct error matching in response handlers

3. Comprehensive Test Coverage

The test suite is exceptionally thorough:

  • 484 new test lines covering both UTXO and EVM recovery scenarios
  • Both local and external signing modes tested comprehensively
  • Edge case validation: Missing parameters, malformed transactions, error conditions
  • Integration testing: End-to-end split-AWM recovery flows with proper nock isolation
  • Split-AWM specific tests: Validates that split recovery uses synchronous path even when async is enabled

4. Smart Implementation Patterns

Optimized key retrieval (multisigRecovery.ts:83-91):

const userPrv = keyToSign === 'backup' ? undefined : await retrieveKeyProviderPrvKey(...);
const backupPrv = keyToSign === 'user' ? undefined : await retrieveKeyProviderPrvKey(...);

Clean two-phase orchestration (recoveryWallet.ts:118-128):

const hasSeparateBackupAwm = userClient !== backupClient;
if (hasSeparateBackupAwm) {
  const halfSignedTx = await userClient.recoveryMultisigUserHalfSign(recoveryBody);
  return backupClient.recoveryMultisig({
    ...recoveryBody,
    keyToSign: 'backup', 
    halfSignedTransaction: halfSignedTx,
  });
}

Review of Addressed Feedback

The author has successfully addressed most of the previous review feedback:

Refactored large conditional blocks: The massive if/else logic has been properly extracted into separate functions (recoverEthLikeMultisigTransaction, recoverUtxoMultisigTransaction)

Improved error handling: Now uses BitgoApiResponseError with proper status preservation and fixed the constructor bug

Tightened API response types: The response codec is now properly typed instead of t.any

Removed forward-compatible scaffolding: Dead code for async job handling has been cleaned up

Technical Analysis

AWM Recovery Handler (multisigRecovery.ts)

Excellent validation logic:

// Lines 51-53: Clear backup signing requirements
if (keyToSign === 'backup' && !halfSignedTransaction) {
  throw new BadRequestError('halfSignedTransaction is required when keyToSign is "backup"');
}

// Lines 62-71: Smart EVM external signing validation  
if (keyToSign === 'backup' && baseCoin.isEVM() && 
    (!halfSignedTransaction || typeof halfSignedTransaction.txHex !== 'string')) {
  throw new BadRequestError('External backup signing for EVM coins requires halfSignedTransaction.txHex');
}

Clean function separation: The refactoring into recoverEthLikeMultisigTransaction and recoverUtxoMultisigTransaction significantly improves readability and maintainability.

MBE Integration (recoveryWallet.ts)

Smart client detection: Using reference equality (userClient !== backupClient) is a pragmatic approach for detecting split-AWM scenarios, though the previous reviewer's suggestion for a dedicated boolean flag has merit for future robustness.

Test Quality (recoveryMultisigTransaction.test.ts)

The test coverage is exemplary:

  • Proper isolation: Each test properly sets up and tears down nock mocks
  • Comprehensive scenarios: Both success paths and error conditions covered
  • Split-AWM specificity: Tests validate the two-phase nature and proper sequencing
  • Edge cases: Malformed input validation, missing required fields

Minor Recommendations

1. Consider Client Comparison Robustness

While userClient !== backupClient works for current implementation, consider making this more explicit:

const hasSeparateBackupAwm = req.config.awmBackupUrl && req.config.awmBackupUrl !== req.config.awmUserUrl;

2. Add Structured Logging

For production debugging, consider adding structured logging for split-AWM operations:

logger.info('Split-AWM recovery initiated', { 
  userAwmUrl: userClient.baseUrl, 
  backupAwmUrl: backupClient.baseUrl,
  coin,
  phase: keyToSign === 'user' ? 'user-half-sign' : 'backup-full-sign'
});

3. Consider Test Organization

The test file is quite comprehensive but large. Future maintainability might benefit from splitting into focused test files by scenario type.

Security Assessment

Input validation: Comprehensive validation prevents malformed transaction injection
Key isolation: Private keys only retrieved when needed for specific signing phases
Error sanitization: Sensitive information properly isolated in error responses
External signing validation: Proper format checking prevents key exposure via misconfiguration

Performance Impact

Minimal overhead: Single-AWM scenarios use existing code paths with no performance impact
Efficient key retrieval: Only fetches keys needed for current signing phase
Smart async handling: Correctly forces synchronous execution for split-AWM scenarios

Conclusion

This is a production-ready, well-engineered implementation that significantly enhances the recovery system's flexibility. The code demonstrates excellent software engineering practices:

  • Clear, maintainable architecture
  • Comprehensive error handling and validation
  • Extensive test coverage with proper isolation
  • Backward compatibility preservation
  • Strong security considerations

The author has been responsive to feedback and has successfully addressed the major architectural and implementation concerns raised by previous reviewers.

Recommendation: APPROVE

This PR is ready for production deployment and represents a meaningful enhancement to the wallet recovery capabilities.


Job Run: https://github.com/BitGo/advanced-wallets/actions/runs/28242234148
Branch: WCN-1082/split-user-backup-awm

@pranavjain97 pranavjain97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some of the claude suggestions can be done in followup

@danielpeng1 danielpeng1 merged commit 95a2371 into master Jul 3, 2026
27 checks passed
@danielpeng1 danielpeng1 deleted the WCN-1082/split-user-backup-awm branch July 3, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants