Skip to content

add bundles & relocks#1031

Merged
alexcos20 merged 4 commits into
mainfrom
feature/escrow_bundles_and_relocks
Jul 3, 2026
Merged

add bundles & relocks#1031
alexcos20 merged 4 commits into
mainfrom
feature/escrow_bundles_and_relocks

Conversation

@alexcos20

@alexcos20 alexcos20 commented Jul 1, 2026

Copy link
Copy Markdown
Member

Closing #1029
Closing #1028

Escrow: reLock, batch bundling, richer Auth events

Summary

Extends the Escrow and EnterpriseEscrow contracts with a re-lock capability and two
batching entrypoints so that off-chain actors (payers onboarding, and nodes servicing many
jobs) can do more in a single transaction, plus a small ABI enrichment and a calldata gas
pass. Behaviour of existing functions is unchanged; all additions are covered by new unit tests.

Scope of this PR is limited to:

  • contracts/escrow/Escrow.sol
  • contracts/escrow/EnterpriseEscrow.sol
  • test/unit/escrow/Escrow.test.js
  • test/unit/escrow/EnterpriseEscrow.test.js

All changes are applied symmetrically to both contracts. The only difference is that
EnterpriseEscrow additionally re-checks its enterprise fee gate (see below).

Motivation

  • reLock — a payee that has locked a maximum amount for a job often needs to adjust it
    before claiming (the job turned out cheaper/pricier, or needs more time). Previously the only
    options were to claim/cancel and re-create. reLock lets the payee change a live lock's
    amount and expiry in place, re-validated against the payer's authorization.
  • bundle — lets a payer onboard in one transaction (multiple deposits, permit-deposits and
    authorizations) instead of N transactions.
  • bundleJobs — lets a node servicing many jobs batch its lock lifecycle operations
    (claims, expired-lock cancels, new locks, re-locks) in one transaction.
  • Auth event token — the Auth event omitted which token the authorization applied to,
    forcing indexers to infer it. Now it is emitted directly.

Changes

1. Auth event now includes token (ABI change)

// before
event Auth(address indexed payer, address indexed payee, uint256 maxLockedAmount, uint256 maxLockSeconds, uint256 maxLockCounts);
// after
event Auth(address indexed payer, address indexed payee, address token, uint256 maxLockedAmount, uint256 maxLockSeconds, uint256 maxLockCounts);

2. reLock / reLocks (new payee functions) + ReLock event

A payee may change the amount and/or expiry of one of their still-active locks (both up
or down). Every createLock check is re-evaluated against the new values, and the accounting is
adjusted atomically (payer available/locked, the authorization's currentLockedAmount; the
lock count is unchanged since the slot is reused).

Two guarantees on the expiry:

  • The lock must not be expired (Lock expired otherwise).
  • The total lifetime measured from the original creation may not exceed the authorization's
    maxLockSeconds: require(block.timestamp + expiry <= startTime + maxLockSeconds). This bounds
    how long a payee can keep a payer's funds locked to exactly what a single up-front
    createLock(maxLockSeconds) would have allowed.

To support this, the lock struct gains a startTime field (set at createLock, preserved
across reLock):

struct lock { uint256 jobId; address payer; uint256 amount; uint256 expiry; address token; uint256 startTime; }

ABI note: startTime is appended last so existing field positions in getLocks(...) returns
are stable, but the returned tuple has one extra trailing element.

New event:

event ReLock(address payer, address payee, uint256 jobId, uint256 oldAmount, uint256 newAmount, uint256 newExpiry, address token);

EnterpriseEscrow only: reLock re-runs the enterprise fee gate with the new amount
(isTokenAllowed(token) and enterpriseFee < amount), mirroring createLock.

3. bundle — batch payer onboarding

function bundle(DepositData[] calldata deposits, PermitData[] calldata permits, AuthData[] calldata auths) external nonReentrant;

Runs deposits → permit-deposits → authorizations in one call. Any sub-array may be empty. The
existing depositWithPermit body was refactored into an internal _depositWithPermit so both
the standalone entrypoint and bundle share it (no duplicated logic). New helper structs:
DepositData, PermitData, AuthData.

4. bundleJobs — batch payee (node) operations

function bundleJobs(ClaimData[] calldata claims, CancelData[] calldata cancels, LockData[] calldata newLocks, LockData[] calldata reLockOps) external nonReentrant;

A thin dispatcher over the existing internal _claimLock / _cancelExpiredLock / _createLock /
_reLock, executed in a fixed order: claims → cancels → creates → reLocks. The ordering is
deliberate: claims and cancels free lock slots (currentLocks) and locked amounts, so a node at
its maxLockCounts limit can claim/cancel finished jobs and open new ones in the same
transaction. cancels reuses the existing "release expired locks" semantics (no new
active-cancel capability). New helper structs: LockData (shared by create & reLock),
ClaimData, CancelData. Any sub-array may be empty.

5. calldata gas optimization

All external functions that take array / bytes parameters now use calldata instead of
memory (both the new functions and the pre-existing batch functions: depositMultiple,
withdraw, authorizeMultiple, createLocks, claimLock(s), claimLock(s)AndWithdraw,
cancelExpiredLocks), and the internal _claimLock takes bytes calldata proof. This avoids
calldata→memory copies. Beyond gas, it reduced deployed bytecode by ~1 KiB per contract.

6. Docs

The top-of-file flow comments in both contracts were updated to describe bundle/bundleJobs
and reLock.

Backwards compatibility

  • No existing function signature or behaviour changed; all mutating/view functions behave as
    before.
  • Off-chain decoders must update for two ABI changes: the Auth event has a new token
    field, and the lock tuple returned by getLocks has a new trailing startTime.

Tests

New unit tests were added to both test/unit/escrow/Escrow.test.js and
test/unit/escrow/EnterpriseEscrow.test.js. The full escrow suites pass (52 passing:
25 for Escrow, 27 for EnterpriseEscrow). New scenarios:

  • Auth event carries the token field.
  • bundle: deposits + a signed-permit deposit + multiple authorizations in one call; and an
    empty-sub-array (auths-only) call.
  • reLock: amount up and down (asserting available/locked, currentLockedAmount,
    currentLocks unchanged, startTime preserved, and the ReLock event); the total-lifetime
    cap (extend within startTime + maxLockSeconds succeeds, beyond it reverts Expiry too high);
    and reverts for Lock not found, Payer does not have enough funds, Exceeds maxLockedAmount,
    and Lock expired.
  • bundleJobs: the capacity-ordering case (at maxLockCounts, a bundle that claims one lock
    and creates another succeeds where a standalone create reverts Exceeds maxLockCounts);
    cancelling an expired lock inside a bundle; and an all-empty no-op.
  • EnterpriseEscrow only: the enterprise fee gate is re-checked by reLock and still enforced
    for creates inside bundleJobs.

Running

nvm use   # Node v20.19.0 (see .nvmrc)
npx hardhat test test/unit/escrow/Escrow.test.js test/unit/escrow/EnterpriseEscrow.test.js

Notes

  • Both contracts remain under the 24,576-byte EIP-170 limit (Escrow ~22.9 KB, EnterpriseEscrow
    ~23.3 KB).
  • bundle/bundleJobs are atomic (any failing sub-operation reverts the whole call), consistent
    with the existing *Multiple / *Locks batch helpers.

Summary by CodeRabbit

  • New Features

    • Added batch actions for escrow workflows, including payer-side bundle() and payee-side bundleJobs(), to combine deposits/permits/auths and job actions in fewer transactions.
    • Enhanced re-locking so updates preserve the original lock start time for consistent expiry behavior.
  • Bug Fixes

    • Enforced max lifetime caps on re-locks based on the original lock creation time.
    • Fixed locked/available balance and authorization state updates when lock amounts change.
    • Improved Auth event data by including the authorized token.
  • Chores

    • Updated deployment gas settings and escrow contract addresses across supported networks.

@alexcos20 alexcos20 self-assigned this Jul 1, 2026
@alexcos20 alexcos20 requested a review from trentmc as a code owner July 1, 2026 12:04
@alexcos20

Copy link
Copy Markdown
Member Author

@coderabbitai review

@alexcos20

Copy link
Copy Markdown
Member Author

/run-security-scan

@alexcos20 alexcos20 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI automated code review (Gemini 3).

Overall risk: low

Summary:
The PR introduces bundle operations (bundle and bundleJobs) to batch multiple actions in a single transaction, significantly reducing overhead. It also implements the reLock functionality to dynamically adjust existing lock amounts and expiries while preserving original limits, and optimizes external array inputs by changing them from memory to calldata. The implementation is well-designed, logically sound, and thoroughly tested. LGTM!

Comments:
• [WARNING][architecture] Modifying the Auth event signature by adding the token parameter is a breaking change for off-chain indexers and subgraphs. Ensure that the corresponding frontend and backend teams are notified to update their ABI and event decoders.

-    event Auth(address indexed payer,address indexed payee,uint256 maxLockedAmount,
+    event Auth(address indexed payer,address indexed payee,address token,uint256 maxLockedAmount,

• [INFO][style] There is a minor typo in the revert message for _reLock. 'Payeer' should be 'Payer'.

-        require(payer!=msg.sender,'Payeer cannot be payee');
+        require(payer!=msg.sender,'Payer cannot be payee');

• [INFO][style] The arithmetic funds[payer][token].available+tempLock.amount-amount perfectly relies on Solidity's left-to-right evaluation guarantees (and the require statement above it ensures it will never underflow). While completely safe and gas-efficient, some strict linters might complain about potential temporary underflows if analyzed out of context. No change strictly required, but it's an interesting mathematical property of this implementation.
• [INFO][performance] Excellent optimization: changing memory to calldata for array parameters in external functions (depositMultiple, withdraw, authorizeMultiple, etc.) avoids expensive memory allocation and copying, significantly reducing execution gas costs.
• [INFO][architecture] The execution order in bundleJobs (claims -> cancels -> newLocks -> reLocks) is a highly effective design choice. It natively allows the caller to free up lock capacities and immediately reuse them in the exact same transaction without hitting strict capacity walls.

Comment thread contracts/escrow/EnterpriseEscrow.sol Fixed
Comment thread contracts/escrow/EnterpriseEscrow.sol Fixed
Comment thread contracts/escrow/Escrow.sol Fixed
Comment thread contracts/escrow/Escrow.sol Fixed
Comment thread contracts/escrow/EnterpriseEscrow.sol Fixed
Comment on lines 568 to 629
@@ -445,7 +629,7 @@ contract EnterpriseEscrow is
* @param amount amount in wei to claim
Comment on lines 568 to 629
@@ -445,7 +629,7 @@ contract EnterpriseEscrow is
* @param amount amount in wei to claim
Comment on lines 568 to 629
@@ -445,7 +629,7 @@ contract EnterpriseEscrow is
* @param amount amount in wei to claim
Comment thread contracts/escrow/Escrow.sol Fixed
Comment on lines 560 to 610
@@ -438,7 +610,7 @@ contract Escrow is
* @param amount amount in wei to claim
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@alexcos20

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@alexcos20, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a50b698a-b6aa-413b-9ee2-65bd0f6df139

📥 Commits

Reviewing files that changed from the base of the PR and between 66cd246 and 86f18c7.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • .bumpversion.cfg
  • package.json
  • setup.cfg
📝 Walkthrough

Walkthrough

EnterpriseEscrow and Escrow add batching entrypoints, preserve lock creation time for reLock expiry checks, switch several array/proof parameters to calldata, update unit tests, and refresh deployment addresses and gas settings.

Changes

Escrow batching and reLock changes

Layer / File(s) Summary
Surface and batching entrypoints
contracts/escrow/EnterpriseEscrow.sol, contracts/escrow/Escrow.sol
Docstrings, lock data, batch request/event types, permit helper flow, and the new payer and payee batching entrypoints are updated.
Payer calldata conversions
contracts/escrow/EnterpriseEscrow.sol, contracts/escrow/Escrow.sol, scripts/deploy_escrow.js
depositMultiple, withdraw, and authorizeMultiple switch array parameters to calldata in both contracts.
createLock and reLock lifetime enforcement
contracts/escrow/EnterpriseEscrow.sol, contracts/escrow/Escrow.sol
createLocks switches to calldata, new locks store startTime, and reLock/reLocks revalidate expiry against the preserved creation time while updating amounts, balances, and ReLock fields.
Claim and cancel calldata conversions
contracts/escrow/EnterpriseEscrow.sol, contracts/escrow/Escrow.sol
claimLock, claimLocks, claimLockAndWithdraw, claimLocksAndWithdraw, _claimLock, and cancelExpiredLocks switch proof and array parameters to calldata, and _claimLock matches the expanded lock layout.
Unit tests for bundling and reLock
test/unit/escrow/EnterpriseEscrow.test.js, test/unit/escrow/Escrow.test.js
The escrow unit tests add coverage for Auth token emission, bundle flows, reLock amount and lifetime behavior, reLock revert paths, bundleJobs ordering, expired-lock cancellation, fee gating, and empty batches.

Deployment configuration updates

Layer / File(s) Summary
Network address mappings
addresses/address.json
Escrow and EnterpriseEscrow addresses are replaced for several networks.
Deployment gas settings
scripts/deploy_enterpriseescrow.js, scripts/deploy_escrow.js
Per-chain gas price and gas limit values are updated for multiple deployment targets.

Estimated code review effort: 4 (Complex) | ~75 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main additions: bundling actions and re-lock support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/escrow_bundles_and_relocks

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

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@openzeppelin-code

openzeppelin-code Bot commented Jul 1, 2026

Copy link
Copy Markdown

add bundles & relocks

Generated at commit: b3e0129de76544e29a231fe06d14b32bd6f06af0

🚨 Report Summary

Severity Level Results
Contracts Critical
High
Medium
Low
Note
Total
2
1
0
9
40
52
Dependencies Critical
High
Medium
Low
Note
Total
0
0
0
0
0
0

For more details view the full report in OpenZeppelin Code Inspector

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
test/unit/escrow/EnterpriseEscrow.test.js (1)

586-679: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

reLock tests depend on side effects from an unrelated, earlier it() block.

The comment at Line 587 (auth set in the Auth-event test: 1000 max, 1000s, 10 counts) confirms these reLock tests rely on the authorize call made in the separate 'Escrow - Auth event includes token' test (Line 536-538) rather than establishing their own preconditions. The same implicit dependency carries through the lifetime-cap test (Line 627-642) and the revert-cases test (Line 644-666), both of which reference the 1000/maxLockedAmount values from that earlier authorization in comments only.

This makes the suite order-dependent: running these tests in isolation (--grep) or reordering/removing the earlier test silently breaks them with unrelated failures ("Lock not found", incorrect balances, etc.) rather than clear setup errors. The bundleJobs tests later in the file (e.g. Line 687, 715) avoid this by calling authorize explicitly — the same pattern should be applied here.

♻️ Suggested fix: make each `reLock` test self-contained
   it('Escrow - reLock increases and decreases the amount', async function () {
-    // fund payer3 and lock as payee3 (auth set in the Auth-event test: 1000 max, 1000s, 10 counts)
+    // fund payer3 and lock as payee3
     await Mock20Contract.connect(payer3).approve(EscrowContract.address, web3.utils.toWei("10000"));
     await EscrowContract.connect(payer3).deposit(Mock20Contract.address, web3.utils.toWei("2000"));
+    await EscrowContract.connect(payer3).authorize(Mock20Contract.address, payee3.address, web3.utils.toWei("1000"), 1000, 10);
     const jobId = 1001;

Apply the same explicit authorize call to the lifetime-cap and revert-cases tests (Line 627, Line 644).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/escrow/EnterpriseEscrow.test.js` around lines 586 - 679, The reLock
test cases are order-dependent because they rely on authorization created in a
different test instead of setting up their own preconditions. Update the
reLock-related `it()` blocks in `EnterpriseEscrow.test.js` to call `authorize`
explicitly before `createLock` or `reLock`, using the same token/payer/payee
setup already used by `createLock`, so each test is self-contained. Apply this
to the lifetime-cap and revert-case tests as well as the main reLock behavior
test, and keep references localized to the `reLock`, `createLock`, and
`authorize` flows.
test/unit/escrow/Escrow.test.js (2)

696-715: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Re-authorization on 696-711 depends on prior test's ending state; consider self-contained fixtures.

This test raises the caps on the same payer2/payee2/Mock20Contract authorization left over from the previous bundleJobs test (currentLocks at 2/2 capacity), rather than using a fresh payer/payee/token combination. This is the same cross-test coupling pattern flagged above (Lines 582-662) — worth addressing together for suite robustness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/escrow/Escrow.test.js` around lines 696 - 715, The `Escrow -
bundleJobs cancels expired locks` test is relying on leftover authorization
state from the previous test instead of setting up its own clean fixture. Update
this test to use a fresh payer/payee/token combination or create isolated setup
within the test so `authorize`, `createLock`, and `bundleJobs` do not depend on
prior `EscrowContract` state; keep the assertions around `getLocks`, `getFunds`,
and the `Canceled` event unchanged.

582-662: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

New reLock tests are chained to earlier tests via shared, mutated state.

These tests (and the lifetime-cap/revert tests that follow) reuse the authorization created in the Auth-event test at Line 533 rather than setting up their own authorize() call, as the inline comment at Line 583 acknowledges. This makes correctness dependent on execution order/prior test side effects (deposited funds, existing locks, auth caps) — running these specs in isolation, with .only, or reordering the suite would break them.

Consider having each it() establish its own authorization/deposit fixture (as the bundleJobs claims-before-creates test at Lines 666-694 already does) to make these tests independent and resilient to reordering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/escrow/Escrow.test.js` around lines 582 - 662, The new reLock tests
in Escrow.test.js depend on shared state from earlier specs, so make each it()
self-contained by setting up its own deposit and authorize() fixture before
createLock/reLock. Update the affected reLock test blocks and any helper setup
they use (such as the createLock/getLocks flow) so they do not rely on the
Auth-event test or prior mutated balances/locks, matching the isolation pattern
used by the bundleJobs test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/unit/escrow/EnterpriseEscrow.test.js`:
- Around line 586-679: The reLock test cases are order-dependent because they
rely on authorization created in a different test instead of setting up their
own preconditions. Update the reLock-related `it()` blocks in
`EnterpriseEscrow.test.js` to call `authorize` explicitly before `createLock` or
`reLock`, using the same token/payer/payee setup already used by `createLock`,
so each test is self-contained. Apply this to the lifetime-cap and revert-case
tests as well as the main reLock behavior test, and keep references localized to
the `reLock`, `createLock`, and `authorize` flows.

In `@test/unit/escrow/Escrow.test.js`:
- Around line 696-715: The `Escrow - bundleJobs cancels expired locks` test is
relying on leftover authorization state from the previous test instead of
setting up its own clean fixture. Update this test to use a fresh
payer/payee/token combination or create isolated setup within the test so
`authorize`, `createLock`, and `bundleJobs` do not depend on prior
`EscrowContract` state; keep the assertions around `getLocks`, `getFunds`, and
the `Canceled` event unchanged.
- Around line 582-662: The new reLock tests in Escrow.test.js depend on shared
state from earlier specs, so make each it() self-contained by setting up its own
deposit and authorize() fixture before createLock/reLock. Update the affected
reLock test blocks and any helper setup they use (such as the
createLock/getLocks flow) so they do not rely on the Auth-event test or prior
mutated balances/locks, matching the isolation pattern used by the bundleJobs
test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 63c0770d-9eb2-4e2b-855d-755a2b8485ba

📥 Commits

Reviewing files that changed from the base of the PR and between 1c779f9 and fe2657e.

📒 Files selected for processing (4)
  • contracts/escrow/EnterpriseEscrow.sol
  • contracts/escrow/Escrow.sol
  • test/unit/escrow/EnterpriseEscrow.test.js
  • test/unit/escrow/Escrow.test.js

@alexcos20 alexcos20 merged commit 44c7592 into main Jul 3, 2026
22 checks passed
@alexcos20 alexcos20 deleted the feature/escrow_bundles_and_relocks branch July 3, 2026 11:22
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.

Escrow: Auth events should include token Escrow prolong + bundle

3 participants