Skip to content

Add a retargetter path to the §A.1 request-contract checks - #27

Merged
maxencerb merged 6 commits into
mainfrom
feat/retargetter-request-path
Sep 16, 2026
Merged

maxencerb merged 6 commits into
mainfrom
feat/retargetter-request-path

Conversation

@maxencerb

@maxencerb maxencerb commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

What

1. A retargetter path for the §A.1 request-contract checks.

A Retargetter (grunt/src/manager/rebalancer/Retargetter.sol) deploys its own Request in startRetargetting — through its bound factory, with itself as owner, puller and consumer — so the classic §A.1 checks can never pass for it (the retargetter is on none of the accepted owner / puller / consumer sets), and a full role-events scan proves nothing the retargetter's own code doesn't already guarantee.

New optional policy set acceptedRetargetters (coordinator: GUARDIAN_ACCEPTED_RETARGETTERS). The stage-1 multicall is unchanged; when the request contract's owner() is on the set, runA1 branches instead of scanning:

owner of request contract is on the accepted-retargetters list                    passed
request contract was deployed by an accepted factory                              skipped
owner of request contract is on the accepted-owners list                          skipped
puller role on request contract is held only by accepted parties                  skipped
consumer role on request contract is held only by accepted parties                skipped
request contract is the retargetter's attached operation request                  evaluated
retargetter repayment deadline is at least MIN_RETARGETTER_REPAYMENT_BUFFER ahead  evaluated
deadline within MAX_DEADLINE_SECONDS_AHEAD of now                                 evaluated

One extra read — the retargetter's operation() — backs the two new checks: operation().request must equal the request contract, and operation().repaymentDeadline must sit at least minRetargetterRepaymentBufferSeconds ahead of now. No getBlockNumber, no getLogs. §A.4 whitelist ops delegate to runA1 per contract, so they inherit the path. Every other request contract keeps the classic path; unset, nothing changes.

Minimum runway: 80 days (default). The Request is deployed with REPAYMENT_DEADLINE_OFFSET = 90 days, and on chain LibStorage.checkConsumptionWindow refuses to start the loan clock with less than MIN_DEADLINE_BUFFER = 80 days remaining. A signature issued below that floor could never be consumed, so the Guardian mirrors it — leaving a 10-day window after startRetargetting in which bindings can be signed. Configurable via GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS (non-negative integer, validated at construction); measured from now, like the other checks.

Choices worth a look:

  • Branch after stage 1, not before. The owner isn't known until the multicall returns, so the factory provenance slots are still issued and then ignored on this path rather than avoided. Keeping one multicall shape for both paths seemed better than a separate owner() round-trip for every classic request.
  • Never cached, and a stale classic entry is bypassed. operation().request flips at operation boundaries (startRetargetting sets it, resolution clears it), so an entry could outlive the operation that made it true; the path costs no scan, so there's nothing to amortise. A cached classic-path entry whose owner is now a listed retargetter is treated as a miss (left to expire, not evicted) so listing a retargetter takes effect immediately.
  • A listed retargetter that doesn't answer operation() is a 503, not a 422. Same rule as a misconfigured accepted factory: the address comes from operator configuration, so the failure is logged at error level and never cached as a client-blamed rejection.
  • Logged at info, not warn. Unlike trustedRequestContracts, this path does verify something on chain; every retargetting operation would otherwise emit a warning in normal operation.

2. Fixture: MockRetargetter. Same pattern as OwnableMockFund — exposes only operation(), with the production return tuple (asserted equal to the real Retargetter artifact's ABI, internalType aside), plus an unauthenticated setOperation. deployGuardianStack now also mints a second real Request through the RequestFactory with the mock as owner / puller / consumer and a 90-day deadline, attached to the mock. Only the local artifact was re-stamped; the grunt / whitelist pins are untouched.

3. Dependency updates (separate commit, easy to drop):

Package From To Notes
viem, zod, @elysiajs/openapi, @noble/hashes, @aws-sdk/client-kms, prool, @types/bun, oxlint — latest in range bun update per workspace
elysia (override) 1.4.29 1.4.30 patch
better-result 2.9.2 3.0.1 major — TaggedError subclasses drop the trailing (); matchError / isTaggedError / tryPromise unchanged. The error classes are part of @3flabs/guardian's public surface, so the changeset bumps it minor and notes that hosts using their own better-result should be on 3.x.
vitest 4.1.8 5.0.0 major — nothing here uses vi.mock hoisting, sequential, bench, or the removed entry points; unit + integration suites pass.
@google-cloud/kms 5.5.1 6.1.0 major — only raises the Node floor to 22; the coordinator runs on Bun 1.3.14.
oxfmt 0.54.0 0.67.0 every file already formats identically under 0.67.

Deliberately left alone:

  • @changesets/cli 3 — private packages are no longer versioned by default (would drop guardian-test-fixtures), changeset tag is renamed, and changeset version now exits 1 with no pending changesets; all three touch release.yml / changesets/action, so that migration deserves its own PR.
  • @noble/curves 2 — viem / ox pin 1.9.1 (the coordinator's exact pin exists to dedupe with them), and the DER Signature API the KMS signer uses was renamed.
  • typescript 7 — the native port; Microsoft's guidance is to adopt 6.0 first.

Testing

bun run test (355), test:integration (40, real anvil), typecheck, lint, format:check, build, fixtures:check all pass.

New coverage:

  • §A.1 unit: exact entries on the happy path with the factory answering false (a pass can only come from the retargetter path); attached-to-another-request and idle (address(0)) failures with their reasons; below / at the 80-day floor; custom buffer including 0; signature deadline still enforced; owner not on the set stays classic; per-chain scoping + case-insensitivity on both the set and the attached address; deterministic operation() failure → 503 and no cache entry; transport failure → 503; never writes the cache; bypasses a stale classic entry; builder rejects a fractional / negative buffer.
  • §A.4 unit: a mixed whitelist batch where one contract takes the retargetter path (suffixed entries) and the other stays classic — asserted on the exact multicall addresses.
  • §A.1 integration (anvil): the retargetter's Request fails the classic path when no retargetter is listed, passes via live operation() when it is, fails when the buffer exceeds the fixture's 90-day runway, and fails once the mock is re-attached to another Request.
  • Coordinator: env parsing for both variables, including checksum validation and rejection of negative / fractional buffers.

CI fix (unrelated to the feature)

The first run failed at Install Foundry before any repo step: foundryup is now a compiled binary and the pinned foundry-toolchain v1.8.0 still runs it through bash (foundry-rs/foundry-toolchain#170). The last commit bumps the action pin to v1.9.1; it would have hit any PR opened against main today.

When a request contract's owner() is on the new acceptedRetargetters
policy set, §A.1 skips the factory, owner and puller / consumer role
checks (no role-events scan) and instead reads the retargetter's live
operation(): the contract must be its attached operation request and
the operation's repaymentDeadline must sit at least
minRetargetterRepaymentBufferSeconds ahead of now (default 80 days,
the contract's MIN_DEADLINE_BUFFER). Nothing is cached on this path.
§A.4 whitelist ops inherit it per request contract; every other
request contract keeps the classic path.

The coordinator exposes the set as GUARDIAN_ACCEPTED_RETARGETTERS and
the buffer as GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS. Ships
retargetterAbi and a MockRetargetter fixture whose operation() tuple
matches the production contract.
In-range: viem 2.56.3, zod 4.6.2, @elysiajs/openapi 1.4.16, @noble/hashes
2.4.0, @aws-sdk/client-kms 3.1130.0, prool 0.2.15, @types/bun 1.4.2,
oxlint 1.82.0. The elysia override moves to 1.4.30.

Majors, each reviewed against its migration notes:
- better-result 3.0.1: TaggedError subclasses drop the trailing factory
  call; matchError / isTaggedError / tryPromise are unchanged.
- vitest 5.0.0: nothing here uses vi.mock hoisting, sequential, bench
  or the removed entry points; unit and integration suites pass.
- @google-cloud/kms 6.1.0: only raises the Node floor to 22.
- oxfmt 0.67.0: every file already formats identically.

Left as-is: @changesets/cli 3 (changes private-package versioning and
the version command's exit code, which the release workflow relies on),
@noble/curves 2 (viem/ox pin 1.9.1, and the DER API used by the KMS
signer was renamed), typescript 7 (needs the 6.0 step first).
@changeset-bot

changeset-bot Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c9dabf5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@3flabs/guardian Minor
@3flabs/guardian-defaults Minor
@3flabs/guardian-coordinator Minor
@3flabs/guardian-test-fixtures Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

foundryup is now a compiled binary and the v1.8.0 action still invoked
it through bash, so every run failed at the install step with
"cannot execute binary file" (foundry-rs/foundry-toolchain#170).
packageManager, the coordinator image base tags, and the README pin.
Frozen install, typecheck, lint, build, unit and integration suites all
pass under 1.4.2 with the lockfile unchanged.
@maxencerb
maxencerb merged commit dabc2dd into main Sep 16, 2026
1 check passed
@maxencerb
maxencerb deleted the feat/retargetter-request-path branch September 16, 2026 12:19
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.

2 participants