Fix/relay selectors and pool tags - #130
Merged
Merged
Conversation
…ing on wide words
…nd selector confusion
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.
Summary
Two independent security fixes, plus the tests and structure that keep them from regressing.
Both share a failure mode worth naming: they were silent. The relay was rejecting every call it was supposed to accept, and the pool was admitting duplicates of the same spend. Neither surfaced an error, and the existing tests stayed green throughout.
pallet-shielded-pool— pool admission tagged one entry per nullifierand_provides(x)contributes exactly one tag, so passing it aVecencoded the whole nullifier set plus the relayer into a single blob. Three consequences, each free for an attacker since the fee is only charged on execution:A+BandA+C) did not collide at all, so one note could back unboundedly many entries.Every variant propagates and is revalidated network-wide while at most one can ever execute.
The fix calls
and_providesonce per nullifier, under a sharedShieldedPoolSpendnamespace. Dummy nullifiers (all-zero) are excluded: they carry no identity, and tagging them would collide every padded single-input spend with every other.relayerdeliberately leaves the tag. Binding it made a copy with a swapped fee recipient a separate entry, so anyone could rebroadcast another user's spend pointed at their own account. Keyed on the nullifier the two are mutually exclusive, so taking the fee requires out-bidding — which means paying it.fc-rpcrelay — the selector whitelist was stale for both operationsThe client held
0x47fc44a2(unshield) and0x8c0f5d24(privateTransfer), while the decoder answers to0x4e505348and0x66ed2cd4.Derived by keccak, the stale pair turn out to be real selectors from signatures two versions old — they froze when the signatures gained parameters. Relaying was therefore rejecting every call as
unsupported selector, indistinguishable from a legitimate rejection. The same stale literals sat in the runtime's fallback list and ints-tests/test-relay-rpc.ts, so the tests stayed green while testing nothing.Both constants are now re-exported from
pallet_evm_precompile_shielded_pool::selectorsrather than copied, so the whitelist and the decoder have a single definition and cannot drift. A test still pins them to keccak of the ABI signatures, which the re-export alone does not guarantee.Two more fixes in the same path:
gas_priceand the fee word now saturate instead of panicking.U256::as_u128()panics above 2^128. The fee word is caller-controlled over an unauthenticated RPC, so one crafted 32-byte value took down the handler.gas_pricecomes from the runtime and is not attacker-reachable, but a panic there still kills the relay RPC.Testing
Every fix is pinned by mutation: the code was broken on purpose and the tests had to catch it.
and_providesover the wholeVec(the original bug)relayerback in the tagsaturating_mul→wrapping_mulin the gas floor>→>=One gap was found this way and closed.
amount + feein unshield's solvency check had no test on its overflow branch —AMOUNT_OVERFLOWappeared only in the constants test. Both operands are attacker-chosen and summed before the balance compare, and a sum that overflows comes out small, which passes. Four tests now cover it: the overflow itself, the solvency boundary (<not<=), the fee counting against the pool, and per-asset isolation.New coverage:
u128boundaries, a zero governance fee, the fee one planck below the floor, unshield's selector on privateTransfer-length calldata, and the size cap at exactly the boundary.u256::MAXoffsets, offsets aboveu32that look benign, huge array counts that must not allocate, oversizedu32slots, a maximal fee word, and two fuzz sweeps that must never panic.private_transfer_rejects_truncated_inputhad itself drifted — it used0x8c0f5d24and passed only because a wrong selector is rejected anyway, so it was testing nothing about truncation. It now takes the selector from the decoder.Refactor:
client/rpc/src/relayvalidation.rswas 763 lines — 151 of code, 610 of tests — and its own header claimed "no runtime dependencies" while holdingMAX_FEE_PER_GAS_WEIandRELAY_GAS_LIMIT, parameters of the transaction the relay signs rather than of the calldata it inspects.mod.rswas both the module root and the RPC implementation.Split by what each part needs in order to run, not by topic:
config.rsvalidation.rsrpc.rsOrbinumRelay: dry run, nonce, signing, pool submissionmod.rstests/validation.rsandadversarial.rsKeeping
validationfree of chain state is what makes the adversarial tests cheap: hostile calldata, no node, no async runtime.This also surfaced a real bug:
pallet-evm-precompile-shielded-poolhad been added to[dev-dependencies]instead of[dependencies], so it linked for tests but not for the lib.Not included
No runtime version bump. The
relay_config()fallback fix changes a value compiled into the WASM, so publishing this will needspec_version8 → 9 — deliberately left out of this PR.transaction_versionstays at 2 either way: no dispatch signature changes, so offline-signed extrinsics remain valid and wallet and runtime do not have to ship together. No migration, no state-transition change.Follow-up
The ts-sdk still carries
0x1ec439cfforprivateTransferand encodes an extraovkBlobargument. Against this node, every private transfer over the EVM path is rejected. That is a separate change, tracked outside this PR — but it means the SDK must land before the EVM transfer path works end to end.rpc.rs— dry run, nonce allocation, signing, pool submission — has no tests, as it needs a mock client and pool. The optimistic nonce underMutexis the part most exposed to concurrency bugs.Verification
pallet-shielded-pool— 347 passpallet-evm-precompile-shielded-pool— 85 passfc-rpc— 113 pass