Skip to content
Closed
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
17 changes: 17 additions & 0 deletions modules/abstract-eth/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,18 @@
# BitGo abstract-eth

## EVM TSS transaction verification

`verifyTssTransaction` decodes the EVM bytes about to be signed and compares `to` / `value` /
`data` with the user intent before the user key share signs (TOB-BITGOEDMPC-2 / INFOSEC-671).

- The caller's own `txParams` are authoritative whenever they declare `type` or `recipients`. A
stored tx request intent is consulted only when the caller declared nothing (pending-approval
signing); server data never fills absent caller calldata or replaces the declared token.
- The asset is resolved locally from the recipient's `tokenAddress` / `tokenName`, the top-level
`tokenName` and the coin instance. Unresolvable or contradictory identity fails closed, and the
calldata shape must match the asset: a native send, an ERC-20 / ERC-7984 transfer, or one batch
that moves a single asset with the matching batch method.
- Mismatches that already threw (native destination and value, ERC-20 recipient and amount,
consolidation target) keep throwing. New findings are reported through `console.warn` and the
optional `onVerificationWarning` callback; pass `strictTssVerification: true` to `BitGoAPI` to
make them hard failures.
42 changes: 32 additions & 10 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,14 @@ import {
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
import {
buildEvmTssIntentContext,
callerDeclaresIntent,
canonicalizeEvmTssIntentType,
DecodedEvmTx,
evaluateEvmTssIntent,
isUnsupportedEvmTssIntentType,
mapDefiParamsFromIntent,
resolveCoinTokenContractAddress,
selectDeclaredIntent,
} from './tssVerification';
import { bip32 } from '@bitgo/secp256k1';
import {
Expand Down Expand Up @@ -3580,6 +3583,15 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
const ethNetwork = this.getNetwork();
const { txParams, txPrebuild, wallet, intent } = params;
// Exactly one source speaks for the user intent: the caller's own params when it declared a
// type or recipients, otherwise the stored tx request intent (pending approval). The other
// source is dropped, so server data never selects the handler or fills in what the caller
// left out. See selectDeclaredIntent.
const { callerParams, storedIntent } = selectDeclaredIntent({
txParams,
callerTxParams: params.callerTxParams,
intent,
});

// Helper to throw TxIntentMismatchRecipientError with recipient details
const throwRecipientMismatch = async (message: string, mismatchedRecipients: Recipient[]): Promise<never> => {
Expand All @@ -3599,7 +3611,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
!(
txParams.prebuildTx?.consolidateId ||
txPrebuild?.consolidateId ||
intent?.consolidateId ||
storedIntent?.consolidateId ||
txParams.stakingRequestId ||
txParams.prebuildTx?.stakingRequestId ||
(txParams.type && NO_RECIPIENT_TX_TYPES.has(txParams.type))
Expand All @@ -3615,13 +3627,21 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
throw new Error('tx cannot be both a batch and hop transaction');
}

// Canonical verifier intent type. Caller-supplied txParams.type wins; on the
// pending-approval path resolveEffectiveTxParams stores the raw intent type in
// txParams.type, so the stored spelling (payment, defi-approve, …) is normalized here.
const explicitType =
canonicalizeEvmTssIntentType(txParams.type) || canonicalizeEvmTssIntentType(intent?.intentType);
const consolidateId =
txParams.prebuildTx?.consolidateId || txPrebuild?.consolidateId || intent?.consolidateId || undefined;
// Canonical verifier intent type, derived only from the source that speaks for the user.
// The caller's own `type` wins and is never defaulted from the stored intent: on a direct
// send `txParams.type` is the caller's alone, and a caller that declared recipients without a
// type is making a payment. Only when the caller declared nothing does the stored intent
// type apply: resolveEffectiveTxParams has already put the raw stored spelling (payment,
// defi-approve, …) in txParams.type, which is normalized here.
const explicitType = storedIntent
? canonicalizeEvmTssIntentType(txParams.type) || canonicalizeEvmTssIntentType(storedIntent.intentType)
: canonicalizeEvmTssIntentType(callerParams?.type);
// A consolidation id on the prebuild or the stored intent is server data. It marks the tx as a
// consolidation only when the caller declared nothing of its own; it can never reroute a
// caller-declared payment to the consolidate handler.
const consolidateId = callerDeclaresIntent(callerParams)
? undefined
: txParams.prebuildTx?.consolidateId || txPrebuild?.consolidateId || storedIntent?.consolidateId || undefined;
const isConsolidation =
!!params.verification?.consolidationToBaseAddress || explicitType === 'consolidate' || !!consolidateId;
const canonicalType = explicitType || (isConsolidation ? 'consolidate' : 'transfer');
Expand Down Expand Up @@ -3706,7 +3726,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
}

const baseAddress = wallet.coinSpecific()?.baseAddress;
const defiParams = txParams.defiParams ?? (intent ? mapDefiParamsFromIntent(intent) : undefined);
const defiParams = txParams.defiParams ?? (storedIntent ? mapDefiParamsFromIntent(storedIntent) : undefined);
if (!baseAddress || !defiParams) {
await throwRecipientMismatch('DeFi transaction is missing required intent parameters', []);
return false;
Expand Down Expand Up @@ -3846,7 +3866,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
const ctx = buildEvmTssIntentContext({
intentType: canonicalType,
txParams,
intent,
callerParams,
storedIntent,
coinTokenContractAddress: resolveCoinTokenContractAddress(this.getChain()),
baseAddress: typeof baseAddress === 'string' ? baseAddress : undefined,
batcherAddress: ethNetwork?.batcherContractAddress,
});
Expand Down
Loading
Loading