Skip to content

fix: follow-up SDK fixes from the 0.14 audit - #462

Open
mr-zwets wants to merge 3 commits into
nextfrom
fix/sdk-audit-remaining
Open

mr-zwets wants to merge 3 commits into
nextfrom
fix/sdk-audit-remaining

Conversation

@mr-zwets

@mr-zwets mr-zwets commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Opened on behalf of Mathieu G. (mr-zwets), written by Claude Opus 5.5.

Replaces #460, which was opened from the wrong branch by mistake.

Follow-up to the 0.14 audit, meant to land after the 0.14.0 release, with the SDK findings that were left out to keep the release's scope small. Rebased on next (including #457 and #463); every fix below still has a test that fails on the current next.

Changes

  • Debugging:
    • Each input is debugged with its own contract's artifact, looked up by exact unlocking script id. Before, the lookup was by contract-name prefix, so with Vault and VaultSidecar in one transaction the sidecar input could use Vault's artifact: logs printed for code that didn't run, and a failing require crashed the debugger.
    • A failing final require(f(x)) with a non-inlined f is reported at its own line and message.
    • console.log statements after a failing instruction are no longer printed (twice), and a log after a division by zero no longer makes debug() throw a plain Error.
    • A function whose body is only a parameter type check (require(b) for bool b) no longer crashes debug(), send() and getBitauthUri().
  • send(): a NetworkProviderError is thrown as it is instead of being wrapped in a FailedTransactionError, so the documented error classes can be caught. Unrecognised Electrum rejections are a NetworkProviderError, and MockNetworkProvider throws NetworkProviderMissingInputsError for missing or spent UTXOs. This is breaking for code that catches FailedTransactionError for network rejections, so it fits 0.15 rather than a 0.14.x patch (or can be split out).
  • addBchChangeOutputIfNeeded(): ECDSA signatures are sized at their maximum length, since re-signing after adding the change output could push the fee below 1 sat/byte (about 1 in 4 builds with one ECDSA input). The docs note that placeholder inputs assume Schnorr.
  • addOpReturnOutput(): invalid hex ('0xabc', '0xzz') now throws instead of being mis-encoded.
  • Hex case: token categories and locking bytecode are compared case-insensitively. An upper case category passed to addTokenChangeOutputIfNeeded() used to add no change output, so with allowImplicitFungibleTokenBurn the tokens were burned.
  • asmToBytecode: unknown opcode names (previously encoded as OP_0) and invalid data tokens now throw, so an artifact with an opcode the installed libauth doesn't know no longer silently gets a different address.

No release or migration notes yet, since the 0.14 sections will be closed by then. For the send() change, a BREAKING release note and a migration note along these lines: "send() now throws the provider's NetworkProviderError (or a subclass such as NetworkProviderMissingInputsError) when the network rejects a transaction; catch it in addition to FailedTransactionError."

Tests

  • Prefix-named contracts in both input orders (logs and the failing require).
  • A final require(f(x)) with disableInlining, logs after a failed require and after a division by zero, and a parameter-check-only function through the SDK.
  • A double spend on MockNetworkProvider and a fake Electrum client, both through send().
  • 100 ECDSA change builds at 1 sat/byte.
  • Invalid OP_RETURN hex, upper case token category and locking bytecode.
  • Unknown opcodes and invalid data in asmToBytecode.

yarn build, yarn test, yarn lint and yarn spellcheck pass.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cashscript Ready Ready Preview Sep 29, 2026 9:58am UTC

Request Review

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.38710% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 89.68%. Comparing base (625d844) to head (230d098).

Files with missing lines Patch % Lines
packages/cashscript/src/transaction-utils.ts 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             next     #462      +/-   ##
==========================================
+ Coverage   89.23%   89.68%   +0.44%     
==========================================
  Files          61       61              
  Lines        5074     5111      +37     
  Branches      949      963      +14     
==========================================
+ Hits         4528     4584      +56     
+ Misses        421      409      -12     
+ Partials      125      118       -7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mr-zwets and others added 3 commits September 29, 2026 11:56
- Each input is now debugged with the artifact of its own contract, looked up
  by its exact unlocking script ID. The artifact was found by contract name
  prefix, so with contracts `Vault` and `VaultSidecar` in one transaction the
  sidecar input could be debugged with Vault's artifact: logs printed for code
  that did not run, and a failing require crashed the debugger instead of
  being reported.
- A failing final `require(f(x))` where `f` is defined with OP_DEFINE was
  located using steps of the function body as well, giving an ip that does not
  point into the contract's own bytecode. The failure was reported against an
  unrelated statement without its require message. Only the steps of the
  failing frame are used now.
- `console.log` statements were matched against the step that raised the error
  and against libauth's repeated final state, so a log after a failing
  instruction was printed (twice), and a log after a division by zero made
  debug() throw a plain Error. Steps with an error and the repeated final
  state are skipped now.
- formatBitAuthScript read past the end of the script when nothing follows the
  parameter type checks (e.g. `require(b)` for a `bool b`), so debug(), send()
  and getBitauthUri() threw a TypeError for a valid spend. The anchor is now
  clamped to the last opcode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…audit

- send() wrapped every broadcast error in a FailedTransactionError without a
  cause, so the documented NetworkProvider*Error classes could not be caught.
  A NetworkProviderError is now thrown as it is, the ElectrumNetworkProvider
  falls back to a NetworkProviderError (not a plain Error) for unrecognised
  rejections, and the MockNetworkProvider throws a
  NetworkProviderMissingInputsError for a missing or spent UTXO.
- addBchChangeOutputIfNeeded() calculated the fee from ECDSA signatures made
  before the change output existed. Signing again can make an ECDSA signature
  a byte longer, so about a quarter of transactions with one ECDSA input and
  a fee rate of 1 ended up below 1 sat/byte and failed to build. ECDSA
  signatures are now sized at their maximum length (73 bytes) for the change
  calculation. The docs mention that placeholder inputs assume Schnorr.
- addOpReturnOutput() silently encoded invalid hex ('0xabc' became ab0c,
  '0xzz' became 00), like function arguments did before #454. It now throws.
- Hex strings were compared case-sensitively when matching UTXOs to unlockers,
  token categories for token change and implicit burn checks, change locks and
  gatherFungibleTokenUtxos(). An upper case category passed to
  addTokenChangeOutputIfNeeded() added no change output, so with
  allowImplicitFungibleTokenBurn the tokens were burned. They are now compared
  case-insensitively.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
asmToBytecode encoded an unknown opcode name as OP_0 and decoded invalid hex
data tokens leniently. An artifact that uses an opcode the installed libauth
version does not know would silently get a different bytecode and address,
and funds sent there may be unspendable. Unknown opcode names and data tokens
that are not hex with an even number of digits now throw an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mr-zwets
mr-zwets force-pushed the fix/sdk-audit-remaining branch from 8cfeafb to 230d098 Compare September 29, 2026 09:58
@mr-zwets mr-zwets changed the title fix: address remaining SDK findings from the 0.14 audit fix: follow-up SDK fixes from the 0.14 audit Sep 29, 2026
@github-actions

Copy link
Copy Markdown

Pull request stats

Source Tests Docs Total Net Share
SDK (cashscript) +111 −28 +261 −4 +372 −32 +340 90%
Utils (@cashscript/utils) +13 −4 +21 −0 +34 −4 +30 8%
Website +5 −1 +5 −1 +4 1%
Total +124 −32 +282 −4 +5 −1 +411 −37 +374 100%

Reviewable churn: 448 lines (net +374), version bumps and generated files excluded.
Test lines per line of source: 1.83.
Comments: 25 of 109 added source lines, 23%.

Package source changed without an update to website/docs/releases/release-notes.md.

This branch was successfully deployed

1 active deployment
Preview — 230d098b Deployed Sep 29, 2026 by vercel[bot]
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.

1 participant