Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dependency-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@3flabs/guardian": minor
"@3flabs/guardian-defaults": patch
"@3flabs/guardian-coordinator": patch
---

Update runtime dependencies. `better-result` moves to 3.x: the exported error classes (`UnauthenticatedError`, `ValidationFailedError`, …) still extend its `TaggedError`, so hosts that call `isTaggedError` / `matchError` on Guardian errors with their own copy of `better-result` should be on 3.x too. Also picks up viem 2.56, zod 4.6, elysia 1.4.30, `@noble/hashes` 2.4, `@aws-sdk/client-kms` 3.1130, and `@google-cloud/kms` 6.1 (Node ≥ 22 only; the coordinator runs on Bun).
7 changes: 7 additions & 0 deletions .changeset/retargetter-request-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@3flabs/guardian-defaults": minor
"@3flabs/guardian-coordinator": minor
"@3flabs/guardian-test-fixtures": minor
---

Add an opt-in retargetter path to the §A.1 checks. When the request contract's `owner()` is on the new `acceptedRetargetters` policy set (`GUARDIAN_ACCEPTED_RETARGETTERS`), the factory, owner and puller / consumer role checks are skipped in favour of the retargetter's live `operation()`: the contract must be the retargetter's attached operation request and the operation's repayment deadline must be at least `minRetargetterRepaymentBufferSeconds` ahead (`GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS`, default 80 days — the contract's `MIN_DEADLINE_BUFFER`). §A.4 whitelist ops inherit it per request contract. Every other request contract keeps the classic path. Ships `retargetterAbi` and a `MockRetargetter` test fixture.
18 changes: 10 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,17 @@ jobs:
# `packages/guardian-test-fixtures/artifacts/`. Pinned to v1.7.1
# — the latest foundry release, matching local dev and the
# bundled fixtures — rather than `stable` so runs are
# reproducible. Known tradeoff: the action fetches the foundryup
# installer from foundry HEAD at run time, so the action SHA pin
# does not pin the installer script, and foundryup's attestation
# check is best-effort (it skips rather than fails when `gh` /
# the attestation API is unavailable) — the binaries are NOT
# cryptographically verified end-to-end. Accepted for now; the
# alternative is vendoring a checksummed tarball download.
# reproducible. Known tradeoff: the action fetches the standalone
# foundryup installer (foundry-rs/foundryup) at run time, so the
# action SHA pin does not pin the installer, and foundryup's
# attestation check is best-effort (it skips rather than fails
# when `gh` / the attestation API is unavailable) — the binaries
# are NOT cryptographically verified end-to-end. Accepted for now;
# the alternative is vendoring a checksummed tarball download.
# v1.9+ is required: foundryup is now a compiled binary, and the
# v1.8.0 action ran it through `bash` (foundry-toolchain#170).
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@c7450ba673e133f5ee30098b3b54f444d3a2ca2d # v1.8.0
uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1
with:
version: v1.7.1

Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.guardian-coordinator
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM oven/bun:1.3.14-alpine AS build
FROM oven/bun:1.4.2-alpine AS build
WORKDIR /app

COPY package.json bun.lock tsconfig.base.json ./
Expand All @@ -15,7 +15,7 @@ RUN bun run --cwd packages/guardian build \
&& bun run --cwd packages/guardian-defaults build \
&& bun run --cwd packages/guardian-coordinator build

FROM oven/bun:1.3.14-alpine AS runtime
FROM oven/bun:1.4.2-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ bun run build # tsc emit .js + .d.ts per package

The root `build` / `test` / `test:integration` / `typecheck` scripts invoke each package
explicitly via `bun run --cwd packages/<pkg> …`, so a missing package script fails loudly.
Bun itself is pinned through the root `packageManager` field (`bun@1.3.14`); CI reads it
Bun itself is pinned through the root `packageManager` field (`bun@1.4.2`); CI reads it
via setup-bun's `bun-version-file`, so bumping Bun is a single-line change.

Per-package work:
Expand Down
332 changes: 130 additions & 202 deletions bun.lock

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.2",
"workspaces": ["packages/*"],
"overrides": {
"brace-expansion": "5.0.8",
"elysia": "1.4.29",
"elysia": "1.4.30",
"tar": "7.5.19",
"ws": "8.21.1"
},
Expand All @@ -28,11 +28,11 @@
"changeset:publish": "bun run scripts/rewrite-workspace-deps.ts && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@types/bun": "^1.3.14",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0",
"@changesets/cli": "^2.31.1",
"@types/bun": "^1.4.2",
"oxfmt": "^0.67.0",
"oxlint": "^1.82.0",
"typescript": "^5.9.3",
"vitest": "^4.1.8"
"vitest": "^5.0.0"
}
}
13 changes: 13 additions & 0 deletions packages/guardian-coordinator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ Optional env:
whatever its owner and role holders turn out to be, including grants made after it was
listed, so keep it to contracts whose configuration is under the same control as the
Guardian's own key material. Every bypass is logged at warn level.
- `GUARDIAN_ACCEPTED_RETARGETTERS` optional `chainId=addr,addr;chainId=addr`. Request
contracts whose `owner()` is a listed Retargetter take the §A.1 retargetter path for both
`set_request` and `request_whitelisting`: factory-provenance, owner, and puller / consumer
role verification are skipped (no role-events scan) and the retargetter's live
`operation()` is checked instead — the request contract must be its attached operation
request, with a repayment deadline at least
`GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS` ahead. Unset (the default) keeps
every request contract on the classic path. The path trusts the retargetter's code for
provenance and role configuration, so list only retargetters deployed under the same
control as the accepted factories.
- `GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS` default `6912000` (80 days, the
Retargetter's on-chain `MIN_DEADLINE_BUFFER`); minimum runway the retargetter operation's
repayment deadline must have for the retargetter path to pass.
- `GUARDIAN_SIGN_TIMEOUT_MS` default `6000`; budget for one whole validate-and-sign
call, including the on-chain reads. Raise it when role-events scans over a wide
`GUARDIAN_EVENT_SCAN_MAX_LOOKBACK_BLOCKS` — or `request_whitelisting` batches, which
Expand Down
12 changes: 6 additions & 6 deletions packages/guardian-coordinator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@
"dependencies": {
"@3flabs/guardian": "workspace:*",
"@3flabs/guardian-defaults": "workspace:*",
"@aws-sdk/client-kms": "^3.1075.0",
"@google-cloud/kms": "^5.5.1",
"@aws-sdk/client-kms": "^3.1130.0",
"@google-cloud/kms": "^6.1.0",
"@noble/curves": "1.9.1",
"better-result": "^2.9.2",
"viem": "^2.52.2",
"zod": "^4.0.0"
"better-result": "^3.0.1",
"viem": "^2.56.3",
"zod": "^4.6.2"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.8"
"vitest": "^5.0.0"
},
"repository": {
"type": "git",
Expand Down
11 changes: 11 additions & 0 deletions packages/guardian-coordinator/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const DEFAULT_MAX_DEADLINE_SECONDS_AHEAD = 600;
const DEFAULT_EVENT_SCAN_BLOCK_RANGE = 10_000n;
const DEFAULT_EVENT_SCAN_MAX_LOOKBACK_BLOCKS = 1_000_000n;
const DEFAULT_MAX_NONCE_ABOVE_FLOOR = 100n;
const DEFAULT_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS = 80 * 24 * 60 * 60;
const DEFAULT_REMOTE_SIGNER_TIMEOUT_MS = 6_000;
const DEFAULT_SWAP_PRICE_TOLERANCE_BPS = 1;
const MULTICALL3_ADDRESS = "0xca11bde05977b3631167028862be2a173976ca11";
Expand All @@ -73,6 +74,16 @@ export function buildGuardianFromEnv(
"GUARDIAN_TRUSTED_REQUEST_CONTRACTS",
)
: undefined,
// Retargetters whose own Requests skip factory / owner / role
// verification in favour of the retargetter's live operation state.
// Unset = every request contract takes the classic path.
acceptedRetargetters: env.GUARDIAN_ACCEPTED_RETARGETTERS?.trim()
? parseAddressMap(env.GUARDIAN_ACCEPTED_RETARGETTERS, "GUARDIAN_ACCEPTED_RETARGETTERS")
: undefined,
minRetargetterRepaymentBufferSeconds: nonNegativeInt(
env.GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS,
DEFAULT_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS,
),
acceptedRequestFactories: parseAddressMap(
required(env, "GUARDIAN_REQUEST_FACTORIES"),
"GUARDIAN_REQUEST_FACTORIES",
Expand Down
34 changes: 34 additions & 0 deletions packages/guardian-coordinator/tests/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,40 @@ describe("guardian coordinator", () => {
).toThrow(/GUARDIAN_TRUSTED_REQUEST_CONTRACTS contains invalid address/);
});

it("parses the retargetter policy from env", () => {
const env = { ...CLI_ENV, GUARDIAN_SIGNER_KEY: `0x${"11".repeat(32)}` };

// Optional set; a checksum-invalid address is rejected at construction.
expect(() =>
buildCliGuardianFromEnv({
...env,
GUARDIAN_ACCEPTED_RETARGETTERS: "1=0x52908400098527886E0F7030069857D2E4169Ee7",
}),
).toThrow(/GUARDIAN_ACCEPTED_RETARGETTERS contains invalid address/);
expect(
buildCliGuardianFromEnv({
...env,
GUARDIAN_ACCEPTED_RETARGETTERS: "1=0x95026A338084241E739250f4F9d2F5745dE81bDd",
}).metadata.supportedChains,
).toEqual([1]);

// The buffer defaults to the on-chain 80-day floor and rejects
// negative / fractional values (they would fail every retargetter
// request) at construction rather than per request.
expect(
buildCliGuardianFromEnv({
...env,
GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS: "0",
}).metadata.supportedChains,
).toEqual([1]);
expect(() =>
buildCliGuardianFromEnv({ ...env, GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS: "-1" }),
).toThrow(/non-negative integer/);
expect(() =>
buildCliGuardianFromEnv({ ...env, GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS: "1.5" }),
).toThrow(/non-negative integer/);
});

it("makes the validate-and-sign budget configurable", () => {
const env = { ...CLI_ENV, GUARDIAN_SIGNER_KEY: `0x${"11".repeat(32)}` };

Expand Down
20 changes: 20 additions & 0 deletions packages/guardian-defaults/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,23 @@ limit equal to the configured range never reject a chunk.
delegates provenance, ownership, and role configuration to whoever controls the listed
contract — including grants made after it was listed — so treat the set as an extension
of the Guardian's own trust boundary; every bypass is logged at warn level.
- **Retargetter path** — `acceptedRetargetters` (optional) switches §A.1 to a second
verification path for request contracts whose `owner()` is a listed Retargetter
(`grunt/src/manager/rebalancer/Retargetter.sol`). A Retargetter deploys its own Request
in `startRetargetting` — through its bound factory, with itself as owner, puller and
consumer — so the factory, owner and puller / consumer role checks are emitted as skipped
(no role-events scan) and one further read, the retargetter's `operation()`, backs the
two checks that replace them: the request contract must be the retargetter's currently
attached operation request, and that operation's `repaymentDeadline` must be at least
`minRetargetterRepaymentBufferSeconds` ahead of now (default 80 days, the contract's
`MIN_DEADLINE_BUFFER` — below it the retargetter refuses to start its loan clock, so a
signature could never be consumed; the Request is deployed with 90 days). The deadline
check still applies, and §A.4 whitelist ops inherit the path per request contract.
Nothing is cached on this path: attachment is live operation state that flips at
operation boundaries, and a previously cached classic-path entry whose owner is now a
listed retargetter is bypassed rather than replayed. A listed retargetter that does not
answer `operation()` is an operator misconfiguration and fails `503`, never a cached
client-blamed `422`. Every other request contract takes the classic path unchanged.
- **Scan budget** — `eventScanDeadlineMs` (optional) bounds a single scan's wall clock;
when exceeded the request fails `503 upstream_unavailable` instead of holding the
handler indefinitely.
Expand Down Expand Up @@ -581,6 +598,8 @@ case-insensitive.
type IntentRequestBindingPolicy = {
maxDeadlineSecondsAhead: number;
trustedRequestContracts?: ReadonlyMap<number, ReadonlySet<string>>; // listed ⇒ skip every on-chain check
acceptedRetargetters?: ReadonlyMap<number, ReadonlySet<string>>; // owner listed ⇒ retargetter path
minRetargetterRepaymentBufferSeconds?: number; // default 80 days (DEFAULT_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS)
acceptedRequestFactories: ReadonlyMap<number, ReadonlySet<string>>;
acceptedOwners: ReadonlyMap<number, ReadonlySet<string>>;
acceptedPullers: ReadonlyMap<number, ReadonlySet<string>>;
Expand Down Expand Up @@ -633,6 +652,7 @@ to implement adjacent flows the Guardian doesn't sign for):
| `fundAbi` | Fund contract — `Ownable.owner()` plus role / order-state views. |
| `requestAbi` | Request contract — `Ownable.owner()` plus the `RolesUpdated` event scanned in §A.1. |
| `requestFactoryAbi` | Request factory — `isRequest(addr)` for §A.1, `RequestCreated` event for the deployment-block lookup. |
| `retargetterAbi` | Retargetter — `operation()` (attached `request` + `repaymentDeadline`) for the §A.1 retargetter path. |
| `positionManagerAbi` | Position manager — `owner / assets / pendingFees / virtualShareOffset` for §A.3. |
| `positionManagerFactoryAbi` | Position-manager factory — `isPositionManager(addr)` for §A.3. |
| `whitelistBookAbi` | Whitelist book — `validatorNonceFloor` and `isNonceConsumed` for §A.4. |
Expand Down
8 changes: 4 additions & 4 deletions packages/guardian-defaults/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,16 @@
},
"dependencies": {
"@3flabs/guardian": "workspace:*",
"better-result": "^2.9.2",
"better-result": "^3.0.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"viem": "^2.52.2",
"zod": "^4.0.0"
"viem": "^2.56.3",
"zod": "^4.6.2"
},
"devDependencies": {
"@3flabs/guardian-test-fixtures": "workspace:*",
"typescript": "^5.9.3",
"vitest": "^4.1.8"
"vitest": "^5.0.0"
},
"repository": {
"type": "git",
Expand Down
2 changes: 2 additions & 0 deletions packages/guardian-defaults/src/abi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export { requestAbi, ROLE_PULLER, ROLE_CONSUMER } from "./request.js";

export { requestFactoryAbi } from "./request-factory.js";

export { retargetterAbi } from "./retargetter.js";

export { positionManagerAbi, VIRTUAL_ASSETS } from "./position-manager.js";

export { positionManagerFactoryAbi } from "./position-manager-factory.js";
Expand Down
50 changes: 50 additions & 0 deletions packages/guardian-defaults/src/abi/retargetter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Minimal viem ABI for `Retargetter` (3F protocol). Only the surface the
* §A.1 retargetter path reads is included.
*
* `operation()` returns the in-flight retargetting operation. The
* Guardian consumes two members:
*
* - `request` — the Request contract the retargetter deployed in
* `startRetargetting` and still holds as its active operation
* (`address(0)` when idle or inside a SYNC flash-loan window).
* - `repaymentDeadline` — that Request's repayment deadline, mirrored
* into the operation at start (`REPAYMENT_DEADLINE_OFFSET`, 90 days).
* On chain the loan clock can only start while at least
* `MIN_DEADLINE_BUFFER` (80 days) remains before it.
*
* Mirrors `grunt/src/manager/rebalancer/Retargetter.sol` (`operation()`)
* and `grunt/src/libs/funds/Order.sol` (the `Order` tuple).
*/
export const retargetterAbi = [
{
type: "function",
stateMutability: "view",
name: "operation",
inputs: [],
outputs: [
{ name: "positionManager", type: "address" },
{ name: "request", type: "address" },
{ name: "fund", type: "address" },
{ name: "startedAt", type: "uint40" },
{ name: "repaymentDeadline", type: "uint40" },
{ name: "operationMaxYieldBps", type: "uint16" },
{ name: "horizon", type: "uint32" },
{ name: "tickDuration", type: "uint24" },
{ name: "tickThreshold", type: "uint24" },
{
name: "order",
type: "tuple",
components: [
{ name: "mode", type: "uint8" },
{ name: "owner", type: "address" },
{ name: "receiver", type: "address" },
{ name: "input", type: "uint256" },
{ name: "output", type: "uint256" },
{ name: "salt", type: "bytes32" },
],
},
{ name: "orderLive", type: "bool" },
],
},
] as const;
1 change: 1 addition & 0 deletions packages/guardian-defaults/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {

export {
buildIntentRequestBindingChecks,
DEFAULT_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDS,
zA1OnChainData,
type A1Deps,
type A1OnChainData,
Expand Down
Loading