Skip to content

[CORE-1787] feat(validator): CATValidatorV1_1 with a committed target, pulled() and entryId() - #44

Draft
glarov95 wants to merge 13 commits into
mainfrom
feat/v1-1-hold-and-forward
Draft

glarov95 wants to merge 13 commits into
mainfrom
feat/v1-1-hold-and-forward

Conversation

@glarov95

@glarov95 glarov95 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds CATValidatorV1_1 and CATValidatorV1_1Tron, subclasses of CATValidator on main (the branch is merged with 85f700d). CATValidator gains virtual on entry, _domainNameAndVersion and _call, the same three hunks as #43; script/check-v1-bytecode.sh pins its creation bytecode, which is unchanged. CallProxy, Catapultar and LibExecutionConstraint are not modified.

CATValidator is a hold-and-forward validator. The executor delivers the outcome tokens to the validator during the call. After the call, the validator checks its own balance of each outcome token against Outcome.amount and forwards that whole balance to the outcome's destination. CATValidatorV1_1 keeps this settlement (the inherited _validatePayment) and adds:

  • a nine-argument entry whose signed constraint also commits executionTarget and payloadHash. Zero means unconstrained; a zero executionTarget on the reusable nonce 0 reverts ZeroDestination.
  • pulled(token), the amount the running entry moved out of the signer, and entryId(), the entry's id within the transaction. Both live in transient storage (EIP-1153) and are meant for contracts called during the entry.
  • a call to the proxy with value 0 from the nine-argument entry, so a force-fed native balance cannot block a non-payable target.
  • EIP-712 domain version "1.1" (the base is "1", feat(validator): CATValidatorV2 on top of the audited CATValidator #43's V2 is "2").
  • a _transfer that skips zero payouts, so {token, 0, refund} outcomes settle on tokens that reject zero transfers.
  • a transient reentrancy lock: a non-zero entry id means an entry is running, so the nine-argument entry does not use the base's storage nonReentrant.

Design doc: solidity/design/cat-validator-v1_1.md.

Affected packages: solidity and typescript.

Why

LI.FI settles non-custodial bridge fills through this validator. The signer's execute constraint pulls the deposit to the execution target and requires one unit of a receipt token at a commit address: { token: attester, amount: 1, destination: commit }. During the call, the execution target bridges the pulled funds through an attester-gated bridge entrypoint, which reports the exact bridge call to the attester. The attester reads pulled(token) and entryId() here, checks that the bridge spent what this entry pulled, and credits one receipt unit to the validator for that entry. After the call, _validatePayment finds the receipt and forwards it to commit. Without an attested bridge there is no receipt, the entry reverts and the deposit stays with the signer.

The refund constraint signs nonce 0, the refund address as executionTarget and keccak256("") as payloadHash, so the executor cannot redirect a refund even though its outcome floors are zero.

What an attester can rely on:

  • pulled(token) is non-zero only inside the nine-argument entry. It is written once, after the last allowance transfer, and cleared before the outcomes are paid, so token hooks during the pull and during the payout read zero.
  • entryId() is 1, 2, 3, ... for the nine-argument entries of a transaction and zero elsewhere, so a receipt can be tagged with the entry that earned it.
  • After the call, the validator forwards its whole balance of each outcome token, so a receipt credited during the entry leaves with that entry's outcome. A stray balance of an outcome token is forwarded with the next outcome in that token.
  • The nine-argument entry forwards no native value to the call.

The bridge side is lifinance/contracts#2583: AttestedBridgeFacet.startBridgeAttested runs an allow-listed bridge entrypoint of the LI.FI Diamond and reports the exact call to the attester in the same transaction.

What an attester must check itself: that the call comes from the bridge entrypoint it trusts, that pulled(token) is non-zero, that the reported bridge call spends what pulled reports for the current entryId(), and that it credits only the validator.

Next to #43. CATValidatorV2 (#43) also commits a target and a payload hash, but it checks after settlement: it runs a signed validation program over what moved through the validator, and it disables the seven-argument entry by making the base's entry virtual. An attester that runs during the call needs what this entry pulled while the call runs. V1.1 publishes that and has no program. It uses the same three base hooks as #43: _domainNameAndVersion returns "1.1", _call forwards no value, and the seven-argument entry reverts V1EntryDisabled. It also overrides _transfer for the zero-skipping payout. The two are independent subclasses.

Layout

Area Files
Source solidity/src/CATValidatorV1_1.sol, solidity/src/CATValidatorV1_1.tron.sol, solidity/src/interfaces/ICATValidatorV1_1.sol, solidity/src/libs/LibExecutionConstraintV1_1.sol
Tests solidity/test/CATValidatorV1_1{,.AllowanceParity,.differential,.equivalence}.t.sol, solidity/test/Tron/CATValidatorV1_1.tron.t.sol, solidity/test/reference/CATValidatorV1_1Standalone.sol, solidity/test/vc/ScaParity.t.sol and its fixture
TypeScript typescript/src/abi/CATValidatorV1_1.ts (exported as catValidatorV1_1Abi), typescript/src/protocol/validatorV1_1.spec.ts, typescript/test/fixtures/sca-parity.v1_1.vectors.json, an opt-in --step CATValidatorV1_1Tron in typescript/scripts/tron/deploy-catapultar.ts
Deploy solidity/script/deployV1_1.s.sol
CI solidity/script/check-v1-bytecode.sh and solidity/snapshots/v1-bytecode.json pin the base's creation hashes (step "Check audited v1 bytecode is unchanged"); solidity/script/check-v1-1-bytecode.sh and solidity/snapshots/v1-1-bytecode.json pin both V1.1 creation hashes and the four source blob ids; the coverage step gets --ir-minimum; the Foundry install steps follow main (Foundry v1.9.0); typescript/.oxfmtrc.json keeps the mirrored sca-parity vectors out of oxfmt, so the copy stays byte-identical to the Solidity fixture

TypeScript scope. The ABI export, the vectors and a viem spec that recomputes them. The SDK's ConstrainedAssetTransaction does not build V1.1 constraints yet; that path is not ported in this PR.

Deploying

deployV1_1 is a separate script, so the default deploy run is unchanged. It deploys through the deterministic deployer 0x4e59b44847b379578588920cA78FbF26c0B4956C at salt 0. At the default profile (solc 0.8.35, via-IR, bytecode_hash = 'none') the creation hashes are CATValidatorV1_1 0x684da2d47165c41001592a4294aabdd835b1d0d9a86184b17070be6221ecfa0c and CATValidatorV1_1Tron 0xbb496cd2485983c4cfa50de55eb54a3b718af59418d454a51668f35aa274d86a, and CATValidatorV1_1 lands at 0xA1d38c83e842ebA863238845Dc4fF14F5668041A. Another compiler gives another address. The tron profile gives the same bytes.

The constructor needs EIP-1153 and reverts without it. deployV1_1 does not check the chain first. On a chain without EIP-1153, forge's local simulation passes and the broadcast deployment reverts on chain: nothing is deployed and the gas is spent (reproduced on an anvil at --hardfork shanghai). Once #43 lands, deployV1_1 adopts its requireCancun() probe; that is a script-only change.

Things to know when reviewing

  • Base entry. The seven-argument entry reverts V1EntryDisabled, so the nine-argument entry is the only way in.
  • Account addresses use this repo's factory. ScaParity derives the addresses of deployWithDigest accounts with CatapultarFactory._salt as it is on main. The fixture's digests, struct hashes and domain do not depend on the factory; saltWithDigest and account do, so accounts created through an older factory must not reuse those two fields.
  • Tests that depend on the compiler. test_reference_is_pinned pins the creation hash of the standalone reference, so it moves with the compiler. It and test_gas_entry_delta_base_to_v1_1 fail under the unoptimized forge coverage --ir-minimum build, so the coverage step excludes them.
  • Coverage flag. The nine-argument entry does not compile under the non-IR pipeline forge coverage uses (stack too deep), which is also why feat(validator): CATValidatorV2 on top of the audited CATValidator #43 adds --ir-minimum. forge coverage does not attribute inline-assembly lines, which run on every entry, so the local lcov for CATValidatorV1_1.sol reads lines 60/72, branches 12/16 and functions 15/15. The four branches it reports missed are exercised: the constructor probe by test_revert_constructor_without_tstore (Shanghai EVM) and _call's revert path by test_entry_bubbles_long_revert_data_verbatim.
  • Isolation. The entry-id tests send their entries through one external call (SameTxHarness.runAll), so they pass with and without --isolate, the default since Foundry 1.8.
  • Gas. On fresh validators, the nine-argument entry with one allowance and no call payload uses 20,345 less frame gas than the base's entry (65,810 vs 86,155), mostly because the base's frame pays the first write of its storage nonReentrant guard. test_gas_entry_delta_base_to_v1_1 fails if the delta grows more than 200 gas above that. These are frame numbers under --isolate (the default since Foundry 1.8); steady-state numbers are under Tests.
  • PULLED_SEED and the constructor require are part of the pinned creation code and stay as they are. The seed is an arbitrary fixed constant for the transient pulled slots; its NatSpec says so.
  • Internal review. Reviewed internally before this PR: no critical, high or medium findings; end-to-end fork and Tron Nile runs passed on the bytes before this review round. On 2026-11-09 a fresh Tron Nile deployment of these CATValidatorV1_1Tron bytes (TCoRpXBWyvyXm7sCDSAKHh4yeyT2HnzVwA) passed: an attested fill that held and forwarded its receipt, a refused fill, a nonce-0 refund, a USDT payout by the validator, and the seven-argument entry reverting V1EntryDisabled.

Merge order with #43

Both PRs open as drafts; whichever merges second resolves the conflicts. Merging #43's head ed7851f4 into 7cdb93d conflicts in seven files; src/CATValidator.sol merges cleanly because both PRs add the same three virtual keywords:

  • solidity/script/check-v1-bytecode.sh, solidity/snapshots/v1-bytecode.json: both PRs add the same check with the same hashes; the comments name a different subclass. Keep either, naming both subclasses.
  • .github/workflows/forge-test.yml: this PR now uses main's install step; keep one v1 bytecode-check step and this PR's V1.1 step, and keep --ir-minimum plus this PR's --no-match-test on the coverage step.
  • solidity/foundry.toml: one fs_permissions array with feat(validator): CATValidatorV2 on top of the audited CATValidator #43's read access on test/vc/fixtures and this PR's read-write access on test/vc/fixtures/sca-parity (used by VC_REGEN_SCA_FIXTURE=true).
  • solidity/README.md, typescript/README.md, typescript/src/index.ts: keep both sides (both validators' sections, both ABI exports).

With that resolution, forge test passes at 7cdb93d (444 passed, 9 skipped: #43's RPC-gated tests), and both bytecode checks (check-v1-bytecode.sh, check-v1-1-bytecode.sh) hold.

Tests

At df125fd (main 85f700d merged, base hooks applied, _payout folded into the _transfer overrides, transient reentrancy lock, a Tron native-payout test, NatSpec and docs corrections), with Foundry 1.9.0 (the CI pin) and Bun 1.3.12:

  • forge fmt --check: clean. ./script/check-v1-bytecode.sh: CATValidator 0xe3d6838a… and CATValidatorTron 0x7ea3b701…, identical before and after the virtual keywords. ./script/check-v1-1-bytecode.sh: re-pinned, ok (the last commit changes only the CATValidatorV1_1.sol blob id; both creation hashes are unchanged).
  • forge build --sizes: CATValidatorV1_1 5,512 B runtime (was 5,932), CATValidatorV1_1Tron 5,751 B (was 6,165).
  • forge test: 27 suites, 375 passed, 0 failed (forge-std is now v1.17.0 from main). testFuzz_entry_v1_overload_is_disabled replaces the test of the old 7-argument flow; test_entry_leaves_the_storage_guard_slot_untouched and testFuzz_nested_entry_reverts_in_pull_and_payout cover the lock; test_outcome_native_trx_pays_destination covers the Tron override's native payout. The standalone reference (test/reference/CATValidatorV1_1Standalone.sol) uses the same transient lock, so test_gas_entry_delta_standalone_to_subclass compares like with like (65,810 vs 65,794). Gas snapshots (fresh validators, so the old numbers include the storage guard's first write): entryOneAllowance 107,263 to 84,913, entryRefundSweep 91,566 to 69,240; the standalone's entryOneAllowanceStandalone 107,012 to 84,897 and entryRefundSweepStandalone 91,134 to 69,019.
  • Steady state, each entry its own transaction (--isolate) on a validator already used once: V1.1 entry 74,754 to 69,739 gas of execution, against the base's 72,704. The storage guard also earned a refund of about 2,800 at the end of the transaction, so the net saving per entry is about 2,200. ScaParity and the TypeScript vectors are unchanged: same digests, salts and accounts.
  • bun install --frozen-lockfile, bun run lint, bun run format:check, bun run build (smoke: 71 named exports): pass. bun run test: 121 passed, 0 failed.

At 832f0576, before the merge (Foundry 1.8.6 and 1.7.1; Bun 1.3.14):

  • forge fmt --check: clean.
  • forge build --sizes: CATValidatorV1_1 5,932 B runtime, 6,716 B initcode; CATValidatorV1_1Tron 6,165 B, 6,949 B.
  • ./script/check-v1-1-bytecode.sh: both creation hashes and all four blob ids ok.
  • forge test -vvv: 27 suites, 372 passed, 0 failed (main: 281). The V1.1 suites add 91 (54 + 4 + 5 + 6 + 15 + 7), 32 of them fuzz. Gas snapshots: snapshots/CATValidatorV1_1Test.json, snapshots/ScaParityTest.json.
  • forge coverage --ir-minimum --no-match-coverage "(script|test)" --no-match-test "(test_reference_is_pinned|test_gas_entry_delta_base_to_v1_1)" --report lcov: 370 passed, 0 failed.
  • On both Foundry 1.8.6 (--isolate by default) and 1.7.1: forge fmt --check clean, forge test 372 passed, 0 failed, with the same creation hashes.
  • bun install --frozen-lockfile, bun run build (smoke: 71 named exports match across ESM and CJS) and bun run coverage:lcov: 112 passed, 0 failed. The spec recomputes the fixture's struct hashes, digests, factory salts and account addresses with viem, and checks the ABI selectors.
  • forge script deployV1_1 --sig "run(string[])" "[local]" --multi --broadcast against a local anvil: deploys CATValidatorV1_1 (5,932 B) at that head's address 0xd7f0DA58…, and eip712Domain() reports "CAT Validator", "1.1"; a second run broadcasts nothing. Against an anvil at --hardfork shanghai, the broadcast reverts and no code is deployed.

Audit scope

New Solidity: src/CATValidatorV1_1.sol (263 lines), src/libs/LibExecutionConstraintV1_1.sol (35), src/CATValidatorV1_1.tron.sol (25), src/interfaces/ICATValidatorV1_1.sol (41) and script/deployV1_1.s.sol (24). The base gains three virtual keywords; its bytecode is unchanged (check-v1-bytecode.sh).

Linear: CORE-1787

CATValidatorV1_1 subclasses CATValidator and commits an execution target and payload hash in the signed constraint, and publishes pulled(token) and entryId() to contracts called during the entry. CATValidatorV1_1Tron is the Tron variant.

Adds the sources next to CATValidator, tests, the ABI and sca-parity vectors with a viem spec, a bytecode pin check in CI, a deploy script and a design doc. CATValidator is unchanged.

Signed-off-by: georgi <georgi@li.finance>
The forge job installed the toolchain's default, stable, which now resolves to v1.8.x. v1.8 makes --isolate the default and formats test/libs/KeyedOwnable.t.sol differently, so the job no longer matched the repository's last green toolchain. The Bun job already pins v1.7.1.

Signed-off-by: georgi <georgi@li.finance>
The entry-id tests made several top-level calls and expected the transient counter to carry across them. Under forge test --isolate, the default since Foundry v1.8, every top-level call is its own transaction, the counter restarts and the tests failed. They now send their entries through SameTxHarness.runAll, a single external call, so they hold with and without isolation.

Signed-off-by: georgi <georgi@li.finance>
- NatSpec: @author LIFI on the new contracts, @PARAM for every argument
  of the nine-argument entry, NatSpec on ICATValidatorV1_1's functions,
  and PULLED_SEED documented as an arbitrary fixed seed. Comments only:
  the creation bytecode of CATValidatorV1_1 and CATValidatorV1_1Tron is
  unchanged; the source blob pins are refreshed.
- Design doc: pulled is written once, after the last transfer of the
  loop, as the code does. The entry id rollback, the domain version and
  deployV1_1 on a chain without EIP-1153 are described as they behave.
- Tests: the base validator is called base (BaseLoop, BASE_GAS_DELTA,
  test_gas_entry_delta_base_to_v1_1) and the Catapultar account is
  called account. Names follow the base suite (snake_case, test_fuzz_,
  test_revert_), the probes' at() helper is seenAt() because at is a
  future keyword, and section banners are single lines.
- sca-parity fixture: schema sca-parity-v1_1/v1, vector receipt-outcome
  and field account replace the old names. Every hash and address in it
  is unchanged.
- The CATValidatorV1_1Test gas snapshot drops by 22 gas per entry: those
  entries use the test contract as their ERC-1271 signer, and its
  dispatcher changed with the test renames.
- READMEs: the coverage command's excluded test name, the deployV1_1
  EIP-1153 note, and catValidatorV1_1Abi in the TypeScript ABI list.

Signed-off-by: georgi <georgi@li.finance>
@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

foundry-toolchain v1.7.0 runs the foundryup installer with bash, which fails now that foundryup ships as a compiled binary. Drops the forge-test version pin added on this branch, so that job installs stable again; the bun-test job keeps its v1.7.1 pin.

Signed-off-by: georgi <georgi@li.finance>
forge fmt 1.8 braces a nested if/else; the dangling else keeps its binding to the inner if. The file also passes forge fmt 1.7.1.

Signed-off-by: georgi <georgi@li.finance>
@codecov-commenter

codecov-commenter commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.14912% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.54%. Comparing base (85f700d) to head (df125fd).

Files with missing lines Patch % Lines
solidity/src/CATValidatorV1_1.sol 81.94% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #44      +/-   ##
==========================================
- Coverage   94.01%   91.54%   -2.48%     
==========================================
  Files          43       47       +4     
  Lines        3260     3715     +455     
  Branches       54       69      +15     
==========================================
+ Hits         3065     3401     +336     
- Misses        195      314     +119     

☔ 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.

@reednaa reednaa left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contact itself looks sufficient. I have a few recommendations on general code.

I am not a big fan of the way it has been built. It feels incredibly forced and inefficient. Something that has been forced together rather than created for the purpose

Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Comment thread solidity/src/CATValidatorV1_1.sol Resolved
Signed-off-by: georgi <georgi@li.finance>
CATValidator marks entry, _domainNameAndVersion and _call virtual, the same
three hunks as #43; check-v1-bytecode.sh pins its creation hashes, which are
unchanged. CATValidatorV1_1 returns version "1.1" from _domainNameAndVersion
instead of re-implementing Solady's EIP712, overrides _call with value 0, and
disables the base's 7-argument entry with V1EntryDisabled.

Signed-off-by: georgi <georgi@li.finance>
Signed-off-by: georgi <georgi@li.finance>
The 9-argument entry no longer takes the base's storage nonReentrant guard: a
non-zero entry id means an entry is running, so a nested entry reverts
Reentrancy() before any state change. Steady-state entry: 74,754 to 69,739 gas
of execution (the storage guard also earned a ~2,800 refund).

Signed-off-by: georgi <georgi@li.finance>
The Tron _transfer override's safeTransferETH branch had no test.

Signed-off-by: georgi <georgi@li.finance>
…e transient lock

entryId() no longer claims ids are never reused, and a zero spend is described as a zero transferFrom. The design doc and README describe the transient lock and the base's three virtual hooks, and the design doc notes the unconstrained-payload case on nonce 0. The standalone reference now uses the same transient lock as the subclass, so the subclass gas test compares like with like (65,810 vs 65,794). Creation bytecode is unchanged; the source blob id is refreshed.

Signed-off-by: georgi <georgi@li.finance>
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.

3 participants