fix(sdk-coin-avaxp): prevent credential wipe and expose incomplete signing state#9287
Conversation
38e523f to
38b1cf2
Compare
There was a problem hiding this comment.
Pull request overview
This PR hardens the legacy sdk-coin-avaxp (deprecated avalanche SDK stack) signing flow to prevent credential regeneration from wiping partially signed state, and aims to expose incomplete signing earlier via transaction serialization.
Changes:
- Update
DeprecatedTransaction.hasCredentialssemantics to treatcredentials=[]as “initialized” (blocks rebuild/regeneration). - Add an explicit
credentials.length === 0guard inDeprecatedTransaction.sign()to avoid silent no-op signing. - Expand regression coverage in
exportP2CTxBuilderfor the credential-guard bypass incident (CECHO-1697) and signature exposure.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| modules/sdk-coin-avaxp/src/lib/deprecatedTransaction.ts | Adjusts credential guard logic, signing guard, and signature aggregation behavior in the deprecated AVAXP transaction stack. |
| modules/sdk-coin-avaxp/test/unit/lib/exportP2CTxBuilder.ts | Adds regression tests around credential guard bypass and signature visibility for partially signed transactions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
mohd-kashif
left a comment
There was a problem hiding this comment.
Review against CECHO-1697 — four inline findings on the relevant lines.
639e16e to
fd58ec6
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
modules/sdk-coin-avaxp/test/unit/lib/exportP2CTxBuilder.ts:235
- The intersection-regression test can silently pass without exercising the new behavior: if
credsis missing/has <2 elements ortargetIdxis-1, the test does no corruption and makes no assertion about the intersection result. This risks a false-positive where the test passes even if thesignaturegetter regresses.
// Read the real ECDSA2 bytes from credentials[0] slot 1 and remove it from credentials[1].
const creds = (fullTx as any).credentials;
if (creds && creds.length >= 2) {
// Corrupt credentials[1]: zero out the second signer's slot so it looks empty.
const cred1: any = creds[1];
b7cfec8 to
cebf2a2
Compare
e1729d1 to
9e3ab6c
Compare
9e3ab6c to
32fd092
Compare
…ng, block invalid broadcast - hasCredentials: use != null instead of .length > 0 so credentials=[] still blocks buildAvaxTransaction() from regenerating credentials on a partially-signed tx (CECHO-1697 root cause hypothesis) - sign(): guard directly on credentials array (not hasCredentials) since hasCredentials now returns true for [] - signature getter: intersection across all credentials — a signer's ECDSA counted only if present in every credential; RFC6979 determinism means same key → same bytes per input, so missing sig in any credential is detectable before broadcast - isAddrPlaceholder(): distinguish 90-zero-prefix + non-zero address bytes from a fully-empty slot - toBroadcastFormat(): throw when credentials=[] (always a bug state — unsigned txs have credentials=null, not []); throw when real ECDSA coexists with addr placeholder (exact shape from the guard bypass that sent a corrupt tx on Jul 16) References: CECHO-1697 Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
hasCredentialsnow checks!= nullonly (was!== undefined && length > 0).credentials=[]previously bypassed the guard inbuildAvaxTransaction(), causing it to regenerate fresh placeholder credentials and wipe any signature already placed by a prior signer.sign()adds an explicitlength === 0guard to compensate — empty credentials still throw an appropriate error.signaturegetter now computes the intersection of real ECDSAs across all credentials. A signer's ECDSA is counted only if it appears in every credential. Since RFC6979 is deterministic (same key + same tx hash = same bytes), a missing signature in any one credential is now detectable viatoJson().signaturesbefore the tx reaches the sendq.toBroadcastFormat()throws if a real ECDSA coexists with an address placeholder (r=0) in the same credential set — the exact shape produced by the guard bypass. This fires regardless of which upstream path produced the corruption.toBroadcastFormat()also throws ifcredentials=[]— a bug state wherehasCredentialsreturns true but no signer has touched the tx, which would otherwise serialize to a zero-credential tx.Changes Explained
Fix 1 —
hasCredentialsgetter (core bug)The bug:
credentials = [](empty array) returnedfalsebecause[].length > 0is false.buildAvaxTransaction()in the builder checksif (this.transaction.hasCredentials) return;before regenerating credentials. WhenhasCredentialswasfalseon a halfSigned tx (Signer 1's ECDSA in slot[0], Signer 2's placeholder in slot[1]), the guard failed to fire —buildAvaxTransaction()ran again and regenerated fresh placeholder credentials, wiping Signer 1's ECDSA from the in-memory transaction.The fix:
!= nullis true for[]— everything exceptnullandundefined. The semantics change from "credentials exist AND are non-empty" to "credentials field has been set from a parsed tx". If theavalancheSDK setscredentials = []during deserialization of the halfSigned tx, we still block the regeneration path.Fix 2 —
sign()guard extendedWhy: After Fix 1,
hasCredentialsreturnstrueforcredentials = []. Without this extra guard,sign()would callthis.credentials.forEach(...)on an empty array — silently doing nothing and producing no signature.Fix 3 —
signaturegetter uses intersection across all credentialsThe bug: the old getter only inspected
credentials[0]. For a multi-UTXO tx (N inputs → N credentials),cred[0] = {ECDSA1, ECDSA2}whilecred[1] = {ECDSA1, placeholder}would return length 2 — falsely showing a fully-signed state.The fix: intersection semantics. RFC6979 guarantees the same key produces identical bytes for the same tx across all credentials, so the intersection correctly reflects how many signers have signed every input.
length === 1means halfSigned;length === 2means ready to broadcast. The corrupt state above now returns length 1.Fix 4 —
toBroadcastFormat()rejects mixed ECDSA + addr placeholderThis is the catch-all guard suggested in review. It fires regardless of which path produced the corrupt credential shape — the production Jul 16 tx would have been blocked here even without the
hasCredentialsfix.isAddrPlaceholder()distinguishes a 90-zero-hex-char prefix followed by non-zero address bytes from a purely empty slot (all zeros), so half-signed txs with unfilled empty slots pass safely.Fix 5 —
toBroadcastFormat()rejectscredentials=[]credentials=[]is always a bug state:hasCredentialsreturnstrue(correct — the field was initialized from a parsed tx), but there are no credentials to include in the serialized tx. Unsigned txs havecredentials = null(field not yet set), so this check does not affect the normal unsigned-tx flow.Why all five fixes
buildAvaxTransaction()guardhasCredentials([]) = false→ guard misses → credentials wipedhasCredentials([]) = true→ guard fires → credentials preservedsign()credentials=[]→.forEachno-op, silent failurecredentials=[]→ throws"empty credentials to sign"signaturegettercredentials[0]only → corruptcredentials[1..N]invisibletoBroadcastFormat()"incomplete signing detected"toBroadcastFormat()credentials=[]→ zero-credential tx serialized silently"no credentials — cannot broadcast"Background
Production incident CECHO-1697 (2026-07-16): a custodial 2-of-3 AVAXP ExportTx P→C was broadcast with sig[1] still containing an address placeholder (r=0) in every credential, causing AvalancheGo to reject with:
Tx:
8xbiLpsKDKkJrN3YUqcZpFcxApLCwmQkpKUuvmHU1GL9RmAyuRoot cause hypothesis: if
credentials=[]arises during BitGo Signer 2's deserialization of the half-signed tx,hasCredentialsreturnedfalse,buildAvaxTransaction()regenerated fresh placeholders (wiping Signer 1's ECDSA), and Signer 2 signed into the wrong slot — leaving slot[1] as address placeholder with r=0. The exact trigger forcredentials=[]fromPVMTx.fromBuffer()is not yet confirmed.A retry the next day with a different signer succeeded on the same unsigned tx, confirming the bug is in the credential guard path, not the UTXOs or policy.
Code Architecture — Which Classes Are Affected
sdk-coin-avaxphas two parallel transaction stacks. The bug lives inDeprecatedTransaction, the base for all six old-SDK tx types:"Deprecated" refers to the old
avalanchenpm package, NOT the tx types. ExportTx P→C (typeID 18) is live production code and routes entirely throughDeprecatedTransaction. All six builders above are affected by thehasCredentialsguard bug.Flow Diagrams
Happy Path (correct signing flow)
Broken Path (production failure —
credentials=[])Credential Slot States
Test plan
test/diag/diag3-guard-bypass.ts— confirmhasCredentials=trueforcredentials=[]after fixtest/diag/diag4-reproduce-invalid-signature.ts— confirm Phase 3 (correct path) still produces 2 valid ECDSAssdk-coin-avaxpunit tests — all regression tests inexportP2CTxBuilder.tspassTODO(BG-56700)intest/unit/lib/exportC2PTxBuilder.ts🤖 Generated with Claude Code