gate the transfer.allow limit parse so an oversized limit cannot grant MaxInt64 - #238
Open
lordbutterfly-hive wants to merge 1 commit into
Open
gate the transfer.allow limit parse so an oversized limit cannot grant MaxInt64#238lordbutterfly-hive wants to merge 1 commit into
transfer.allow limit parse so an oversized limit cannot grant MaxInt64#238lordbutterfly-hive wants to merge 1 commit into
Conversation
…mit cannot grant MaxInt64
The error from common.ParseDecimalsToBaseUnits was discarded when building a
transaction's per-token pull ceilings. strconv.ParseInt is asymmetric on failure:
a syntax error returns 0, but an OUT-OF-RANGE value returns math.MaxInt64. So the
two malformed cases degraded in opposite directions —
limit "abc" -> 0 -> draw refused (safe)
limit "99999999999999999999" -> MaxInt64 -> effectively unbounded (not safe)
— on the one mechanism that exists to bound what a contract may take from a
caller. A client that displays "1.000" while signing an oversized limit obtains
an allowance the user never intended to grant.
Fixed behind a gate, DEFAULT OFF, because it cannot be fixed unconditionally:
this feeds contract execution, so changing what a malformed limit resolves to
changes whether a draw succeeds, which changes state. An upgraded and a
not-yet-upgraded node would disagree and a replay from genesis would stop
reproducing the chain. Same hazard EvmAddressChecksumHeight documents.
With the gate off, behaviour is byte-identical to before, MaxInt64 clamp
included. Activation is a separate decision: resolve it from the chain-active
consensus version exactly as WithTryCatch is resolved.
- extract the intent loop into buildTokenLimits(intents, strict), unchanged in
the default path
- add strictIntentLimits + WithStrictIntentLimits, mirroring WithTryCatch
- propagate the gate into nested contract calls; a nested call builds its own
tokenLimits, so without this one transaction would enforce two different
ceilings depending on call depth
- document that first-in-array wins for a duplicate (type, token) — not the
largest, not the last
Tests pin both sides of the gate and the wiring: the default must stay
MaxInt64 (an ungated fix fails this), the option must actually reach the parse
(an inert gate fails this), and the nested call must propagate it. All three
verified by mutation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(contract): gate the
transfer.allowlimit parse so an oversized limit cannot grantMaxInt64Branch:
fix/intent-limit-parse-gate→mainBase:
cc069b3f(origin/main)Files: 2 —
modules/contract/execution-context/execution-context.go,modules/contract/execution-context/intent_limits_test.go(new)Behaviour change on merge: none. The fix is gated and the gate defaults to off.
The bug
An intent is the permission slip that bounds what a contract may pull from the
caller.
transfer.allowsets a per-token ceiling, andPullBalanceis bounded byit. Building those ceilings discarded the parse error:
ParseDecimalsToBaseUnitsends instrconv.ParseInt, which is asymmetric onfailure: a syntax error returns
0, an out-of-range value returnsmath.MaxInt64— both alongside an error that was thrown away. So the twomalformed cases degraded in opposite directions:
"abc"0"1.2.3"0"99999999999999999999"math.MaxInt64The second row is a defence-in-depth failure on the only mechanism that limits
what a contract can take. It is not a way to steal from a stranger — the intent is
signed by the account whose funds are at risk — but it does mean a client that
displays
1.000while signing an oversized limit obtains an allowance the usernever intended to grant, and the clamp is what makes a malformed value
permissive rather than refused.
Found while stress-testing intent handling for wallet-signed (
did:pkh)transactions. Everything else in that sweep failed closed; this was the only
fail-open.
Why it is gated instead of just fixed
This function feeds contract execution. What a malformed limit resolves to decides
whether a draw succeeds, which decides state. Change it unconditionally and an
upgraded node and a not-yet-upgraded node compute different state from the same
block, and a replay from genesis stops reproducing the chain.
That is the hazard
ConsensusParams.EvmAddressChecksumHeightalready documents atlength, and the reason this repo moved to consensus-version gating for
consensus-affecting changes.
So the strict rule rides
strictIntentLimits, default false, which isbyte-identical to today including the
MaxInt64clamp. Nothing changes when thismerges.
Activation is a separate decision and is deliberately not made here. Resolve
it from the chain-active consensus version exactly as
tryCatchActiveisresolved, on whichever line you want to batch it with. Until then the gate is
inert and the tests prove both that it is inert and that it works when switched
on.
What changed
buildTokenLimits(intents, strict)— verbatim inthe default path. It now runs after the option loop in
Newso it can read thegate; nothing between the struct literal and there reads
tokenLimits, so thatreorder is behaviour-preserving.
strictIntentLimitsfield +WithStrictIntentLimits(active bool), mirroringtryCatchActive/WithTryCatch.tokenLimitsfromopts.Intents, so without this a single transaction wouldenforce two different ceilings depending on call depth. (This was missed in the
first draft and caught by asking why the change was sitting on the wrong branch —
worth stating plainly.)
(type, token)the first intent in arrayorder wins — not the largest, not the last. Unchanged behaviour, just no
longer a thing a reader has to guess.
Tests
intent_limits_test.go, 8 cases. Three of them exist specifically to catch theways this change could go wrong, and each was verified by mutation:
stricthard-coded true) — the forkNewDefaultsToLegacyBehaviourNewHonoursTheOptionGatePropagatesIntoNestedContractCallsNote the first mutant initially passed: the earlier tests called
buildTokenLimitsdirectly and so pinned the function without pinning the wiring.NewDefaultsToLegacyBehaviourgoes throughNewand closes that.The rest pin: legacy
MaxInt64on overflow with the gate off, refusal for everyunparseable limit with it on, valid limits identical either way, first-wins on
duplicates, and that unknown or incomplete intents contribute nothing.
Verification
Built and run in a container matching the repo's own
Dockerfile(Go 1.25 +WasmEdge 0.13.4, same version and checksum it pins):
go vet ./modules/contract/... ./modules/state-processing/ ./modules/common/...— cleango test ./modules/contract/... ./modules/common/...— all green(execution-context, session, common, consensusversion, params, system-config)
gofmtclean; both files LFReviewer notes
upgrade in step. The coordination question arrives only when you activate it.
change needed is in the state engine where
WithTryCatchis already resolvedper-tx — pass
WithStrictIntentLimits(<same style of resolver>)beside it.PullBalance's own missing-limit path does the refusing. That keeps the failurein one place rather than inventing a second "zero means refuse" convention.
Not in this PR
common.EncodeDagCbordiscards three errors and always returnsnil. Today amalformed input panics; making it return an error would let any caller doing
bytes, _ :=proceed with empty bytes and therefore a wrong CID, silently —worse than the panic. Fixing it properly means auditing its ~24 call sites, which
belongs in its own change.