diff --git a/.changeset/common-wombats-cheat.md b/.changeset/common-wombats-cheat.md
deleted file mode 100644
index a845151cc8..0000000000
--- a/.changeset/common-wombats-cheat.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
diff --git a/.changeset/gateful-relay-enactment-paths.md b/.changeset/gateful-relay-enactment-paths.md
new file mode 100644
index 0000000000..6c2f51f1dc
--- /dev/null
+++ b/.changeset/gateful-relay-enactment-paths.md
@@ -0,0 +1,7 @@
+---
+"@anticapture/gateful": minor
+---
+
+The public API surface gains POST /{dao}/relay/queue and
+POST /{dao}/relay/execute, proxied to the relayer's new proposal enactment
+endpoints and merged into the Gateful OpenAPI spec.
diff --git a/.changeset/gitcoin-whitelabel-governance.md b/.changeset/gitcoin-whitelabel-governance.md
new file mode 100644
index 0000000000..d6097b1d35
--- /dev/null
+++ b/.changeset/gitcoin-whitelabel-governance.md
@@ -0,0 +1,5 @@
+---
+"@anticapture/dashboard": minor
+---
+
+Enable the Gitcoin whitelabel with full governance (create, vote, queue and execute proposals).
diff --git a/.changeset/panel-v21-services-row.md b/.changeset/panel-v21-services-row.md
new file mode 100644
index 0000000000..33e1188080
--- /dev/null
+++ b/.changeset/panel-v21-services-row.md
@@ -0,0 +1,5 @@
+---
+"@anticapture/dashboard": minor
+---
+
+Add the Blockful services row at the foot of the panel, listing the service lines with a link to the contact page.
diff --git a/.changeset/relayer-proposal-enactment.md b/.changeset/relayer-proposal-enactment.md
new file mode 100644
index 0000000000..c4994f9732
--- /dev/null
+++ b/.changeset/relayer-proposal-enactment.md
@@ -0,0 +1,8 @@
+---
+"@anticapture/relayer": minor
+---
+
+Add POST /relay/queue and /relay/execute endpoints that sponsor the
+permissionless Governor lifecycle transactions. Proposal args are fetched from
+the Anticapture API by proposal id and verified trustlessly against the
+governor's hashProposal before anything is signed.
diff --git a/apps/dashboard/CHANGELOG.md b/apps/dashboard/CHANGELOG.md
index e4c88dac36..3b4d06335f 100644
--- a/apps/dashboard/CHANGELOG.md
+++ b/apps/dashboard/CHANGELOG.md
@@ -1,5 +1,11 @@
# @anticapture/dashboard
+## 2.14.1
+
+### Patch Changes
+
+- [#2112](https://github.com/blockful/anticapture/pull/2112) [`2bceb82`](https://github.com/blockful/anticapture/commit/2bceb8236d85654972fdfbba9167e2c0fd3c319e) Thanks [@brunod-e](https://github.com/brunod-e)! - Fix WalletConnect pairing by giving the provider an absolute RPC proxy URL.
+
## 2.14.0
### Minor Changes
diff --git a/apps/dashboard/features/create-proposal/constants.ts b/apps/dashboard/features/create-proposal/constants.ts
index 71e39919ae..91260bbe41 100644
--- a/apps/dashboard/features/create-proposal/constants.ts
+++ b/apps/dashboard/features/create-proposal/constants.ts
@@ -6,7 +6,9 @@ export const BODY_CHAR_LIMIT = 100_000;
export const BODY_WARNING_THRESHOLD = 95_000;
export const canCreateProposalForDao = (daoId: DaoIdEnum | null | undefined) =>
- daoId === DaoIdEnum.ENS || daoId === DaoIdEnum.SHU;
+ daoId === DaoIdEnum.ENS ||
+ daoId === DaoIdEnum.SHU ||
+ daoId === DaoIdEnum.GITCOIN;
export interface SuggestedTransferToken {
symbol: string;
@@ -36,6 +38,13 @@ export const SUGGESTED_TRANSFER_TOKENS: Partial<
trustWalletToken("WETH", "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"),
trustWalletToken("ENS", "0xc18360217d8f7ab5e7c516566761ea12ce7f9d72"),
],
+ [DaoIdEnum.GITCOIN]: [
+ trustWalletToken("USDT", "0xdac17f958d2ee523a2206206994597c13d831ec7"),
+ trustWalletToken("USDC", "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"),
+ trustWalletToken("DAI", "0x6b175474e89094c44da98b954eedeac495271d0f"),
+ trustWalletToken("WETH", "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"),
+ trustWalletToken("GTC", "0xde30da39c46104798bb5aa3fe8b9e0e1f348163f"),
+ ],
};
export const PROPOSAL_JSON_PLACEHOLDER = `{
diff --git a/apps/dashboard/features/panel/PanelSection.tsx b/apps/dashboard/features/panel/PanelSection.tsx
index 8740037d37..895cd2a61d 100644
--- a/apps/dashboard/features/panel/PanelSection.tsx
+++ b/apps/dashboard/features/panel/PanelSection.tsx
@@ -1,6 +1,7 @@
import { LatestFindingTicker } from "@/features/panel/components/LatestFindingTicker";
import { PanelHero } from "@/features/panel/components/PanelHero";
import { PanelTable } from "@/features/panel/components/PanelTable";
+import { ServicesRow } from "@/features/panel/components/ServicesRow";
import { TrackRecordSection } from "@/features/panel/components/TrackRecordSection";
import { UseItNowSection } from "@/features/panel/components/UseItNowSection";
import {
@@ -10,24 +11,33 @@ import {
export const PanelSection = () => {
return (
-
-
+ // Figma spaces the three blocks 32px apart and everything inside them 8px
+ // apart, so the inner wrappers hold the tight gap while the page holds the
+ // section rhythm.
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
+
-
+
+
+
+
+
);
};
diff --git a/apps/dashboard/features/panel/components/ServicesRow.tsx b/apps/dashboard/features/panel/components/ServicesRow.tsx
new file mode 100644
index 0000000000..cea6a334fa
--- /dev/null
+++ b/apps/dashboard/features/panel/components/ServicesRow.tsx
@@ -0,0 +1,54 @@
+import { ChevronRight, ShieldCheck } from "lucide-react";
+
+import { BadgeIcon } from "@/shared/components/design-system/badges/badge-icon/BadgeIcon";
+import { DefaultLink } from "@/shared/components/design-system/links/default-link/DefaultLink";
+import { BulletDivider } from "@/shared/components/design-system/section/bullet-divider/BulletDivider";
+
+const SERVICES = [
+ "Security audits",
+ "Calldata review",
+ "Front-end package",
+ "Consulting & research",
+];
+
+export const ServicesRow = () => {
+ return (
+
+
+
+
+
+ Services by Blockful
+
+
+
+ {/* Every label carries a leading bullet and the list is pulled left by
+ * exactly that lead-in (4px bullet + 7px gap) inside an overflow-hidden
+ * wrapper, so a bullet landing at a wrapped line start is clipped away
+ * instead of reading as a list marker. */}
+
+
+ {SERVICES.map((service) => (
+
+
+
+ {service}
+
+
+ ))}
+
+
+
+
+
+ Talk to us
+
+
+
+ );
+};
diff --git a/apps/dashboard/features/panel/components/index.ts b/apps/dashboard/features/panel/components/index.ts
index f0678d27bf..0cfaabd67b 100644
--- a/apps/dashboard/features/panel/components/index.ts
+++ b/apps/dashboard/features/panel/components/index.ts
@@ -3,5 +3,6 @@ export * from "@/features/panel/components/PanelHero";
export * from "@/features/panel/components/LatestFindingTicker";
export * from "@/features/panel/components/TrackRecordSection";
export * from "@/features/panel/components/UseItNowSection";
+export * from "@/features/panel/components/ServicesRow";
export * from "@/features/panel/components/DaoProtectionLevels";
export * from "@/features/panel/components/TooltipCell";
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 94520bb471..f5119cf815 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -1,6 +1,6 @@
{
"name": "@anticapture/dashboard",
- "version": "2.14.0",
+ "version": "2.14.1",
"private": true,
"scripts": {
"dev": "next dev --turbo",
diff --git a/apps/dashboard/shared/dao-config/gtc.ts b/apps/dashboard/shared/dao-config/gtc.ts
index 736149c047..fde788432e 100644
--- a/apps/dashboard/shared/dao-config/gtc.ts
+++ b/apps/dashboard/shared/dao-config/gtc.ts
@@ -7,6 +7,7 @@ import { QUORUM_CALCULATION_TYPES } from "@/shared/constants/labels";
import { RECOMMENDED_SETTINGS } from "@/shared/constants/recommended-settings";
import type { DaoConfiguration } from "@/shared/dao-config/types";
import { GitcoinOgIcon } from "@/shared/og/dao-og-icons";
+import { toAbsoluteUrl } from "@/shared/seo/site";
import {
RiskLevel,
GovernanceImplementationEnum,
@@ -23,6 +24,8 @@ export const GTC: DaoConfiguration = {
forumLink: "https://gov.gitcoin.co/",
icon: GitcoinIcon,
ogIcon: GitcoinOgIcon,
+ hostnames: ["gitcoin.gov.blockful.io"],
+ whitelabel: {},
daoOverview: {
token: "ERC20",
chain: { ...mainnet, icon: MainnetIcon },
@@ -33,8 +36,8 @@ export const GTC: DaoConfiguration = {
timelock: "0x57a8865cfB1eCEf7253c27da6B4BC3dAEE5Be518",
},
govPlatform: {
- name: "Tally",
- url: "https://tally.xyz/gov/gitcoin/proposal/",
+ name: "Anticapture",
+ url: toAbsoluteUrl("/gtc/proposals/"),
},
cancelFunction:
"https://etherscan.io/address/0x57a8865cfB1eCEf7253c27da6B4BC3dAEE5Be518#writeContract#F2",
diff --git a/apps/dashboard/shared/services/wallet/wallet.ts b/apps/dashboard/shared/services/wallet/wallet.ts
index 1ad1284da9..c0dfe9f1b5 100644
--- a/apps/dashboard/shared/services/wallet/wallet.ts
+++ b/apps/dashboard/shared/services/wallet/wallet.ts
@@ -10,7 +10,14 @@ import { createWalletClient } from "viem";
import { createConfig, http } from "wagmi";
import { mainnet, optimism, scroll } from "wagmi/chains";
-const rpcTransport = (chainId: number) => http(`/api/rpc/${chainId}`);
+// The RPC proxy path must be absolute in the browser: wagmi hands these
+// transport URLs to the WalletConnect provider's rpcMap, whose HTTP
+// connection rejects relative URLs (breaking every WalletConnect pairing
+// right after session approval). During SSR the transports are never
+// called, so the relative path is fine there.
+const origin = typeof window === "undefined" ? "" : window.location.origin;
+
+const rpcTransport = (chainId: number) => http(`${origin}/api/rpc/${chainId}`);
export const walletClient = createWalletClient({
chain: mainnet,
diff --git a/apps/relayer/.env.example b/apps/relayer/.env.example
index 1c0efde03d..fe18ef15a5 100644
--- a/apps/relayer/.env.example
+++ b/apps/relayer/.env.example
@@ -25,3 +25,6 @@ MAX_DELEGATIONS_PER_ADDRESS_PER_MONTH=3
# Server
PORT=3002
+
+ANTICAPTURE_API_URL=https://dev-gateful.up.railway.app
+ANTICAPTURE_API_KEY=...
diff --git a/apps/relayer/e2e/fixtures/executed-proposal.ts b/apps/relayer/e2e/fixtures/executed-proposal.ts
new file mode 100644
index 0000000000..878c7aef42
--- /dev/null
+++ b/apps/relayer/e2e/fixtures/executed-proposal.ts
@@ -0,0 +1,24 @@
+/**
+ * Real ENS proposal (executed on mainnet), extracted from ProposalCreated
+ * logs. The e2e forks just before its ProposalQueued block, where the
+ * proposal sits in Succeeded state, so queue()/execute() can be exercised
+ * without mining a voting period.
+ */
+export const EXECUTED_PROPOSAL = {
+ proposalId:
+ "80619211450810140112687536515944199882433060764177806587986222097717655810120",
+ createdBlock: "25667108",
+ endBlock: "25712927",
+ queuedBlock: "25713937",
+ targets: [
+ "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72",
+ "0x4F2083f5fBede34C2714aFfb3105539775f7FE64",
+ ],
+ values: ["0", "0"],
+ calldatas: [
+ "0xa9059cbb0000000000000000000000009c7db6b1085ec4d07f75c0bd91ad3fcd368fa19e00000000000000000000000000000000000000000000d3c21bcecceda1000000",
+ "0x6a7612020000000000000000000000004f2083f5fbede34c2714affb3105539775f7fe640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000064e318b52b0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000fe89cc7abb2c4183683ab71653c4cdc9b02d44b70000000000000000000000000bcc3da6ad796f59288c0961602675e88a2b406c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041000000000000000000000000fe89cc7abb2c4183683ab71653c4cdc9b02d44b700000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000",
+ ],
+ description:
+ "# [Executable] Next Era of ENS DAO: Empowering the ENS Foundation\n## **Abstract**\n\nThis executable proposal establishes the ENS Foundation as an operating foundation, led by a full-time Executive Director and staff, and governed by a five-seat board ratified by the DAO, as set out in the [temp check](https://discuss.ens.domains/t/temp-check-next-era-of-ens-dao-empowering-the-ens-foundation/22175) posted June 19. It follows public discussion of that temp check and tightens the scope of the Foundation’s mandate in several areas in response to community feedback: the DAO's ENS tokens stay under tokenholder control, the operational wallet stays where it is, and Endowment transactions gain a timelock with Security Council oversight.\n\nMain changes from Temp Check in detail:\n\n(1) ENS tokens stay exactly where they sit today, under the same onchain mechanism and the same tokenholder control they are subject to now, and this proposal transfers no general authority over them. There is one exception: a single transfer of 1,000,000 ENS, restricted to Foundation employee compensation to support the Foundation in funding future employee compensation as the Foundation matures. Any future use of DAO-held tokens beyond this transfer, including for ecosystem programs, will be decided by the DAO as its own proposal through the standard governance process.\n\n(2) The operational wallet (wallet.ensdao.eth, approximately $16 million in ETH and stablecoins as of July 2026), which the temp check proposed delegating, stays with the DAO in its current place, with existing streams continuing to draw from it.\n\n(3) Treasury management is implemented with a dedicated security layer: the Foundation Board, acting through approved signers, assumes administrative control of the Endowment Safe (endowment.ensdao.eth, [approximately $65 million](https://app.syncrone.fi/public/ens?period=jun_2026) in ETH and stablecoins as of July 2026), with all Endowment transactions passing through a 9-day timelock by default, and an ability for the Security Council to cancel any timelocked transaction.\n\nTokenholders will retain protocol control along with the authority over appointment and removal of Foundation directors. Every modification made from the original temp check was done in consideration of maintaining tokenholder control and security.\n\n\n\nThis proposal addresses Foundation governance and operational stewardship only. It does not transfer protocol control. For clarity, Foundation governance means the Cayman foundation company's board, Executive Director, staffing, grants administration, budget process, and Endowment stewardship. DAO governance means the public forum process, temp checks, Snapshot votes, executable proposals, delegation, and tokenholder voting. Protocol control means smart contract upgrades, ENS pricing and fee structures, root key and registry control, DAO-held ENS tokens, constitutional amendments, and director appointment, renewal, and removal. Protocol control remains exclusively with ENS tokenholders.\n\n## **Motivation**\n\nThe full problem statement, the research on comparable open-source foundations, and the reasoning behind this structure are in the temp check and its discussion thread. Onchain tokenholder governance is best suited for protocol-level decisions, constitutional authority, director appointment and removal, and other matters requiring broad legitimacy. Day-to-day operations, budgeting, staff management, grants administration, policy engagement, and vendor oversight require accountable execution within a legal entity that remains answerable to tokenholders. This proposal maintains protocol control, ENS tokens, and director appointment and removal with tokenholders, and moves operational and budgetary stewardship into a Foundation that remains structurally accountable to the DAO.\n\nFor the sake of clarity, ENS Labs remains a separate Singapore-based entity with its own leadership and board. Participation by ENS Labs personnel in developing this proposal does not grant ENS Labs any governance right over the Foundation, the Endowment, DAO-held ENS tokens, protocol upgrades, fee structures, or director appointment and removal.\n\n## **The Foundation Structure**\n\nThe ENS Foundation becomes a fully operational foundation, led by a full-time Executive Director and staff, stewarding the ENS mission and values. The Foundation will hold the ENS trademarks, brand assets, and other intellectual property. ENS Labs continues to operate independently under its own leadership and its own board, with the Foundation licensing the ENS trademarks to Labs and funding Labs through the existing grant relationship. The Foundation is responsible for its mission, grants program, and stewardship of the protocol's revenue and Endowment.\n\nThe Foundation represents ENS where policy and standards are made: active participation in ICANN, IETF, W3C, and adjacent forums, pursuit of recognition and stewardship of the .ens TLD at ICANN, regulatory engagement on policy questions affecting ENS and decentralized naming, the institutional and legal-process counterparty role for the protocol layer, and trademark and brand enforcement. The full advocacy mandate is described in the temp check. Staffing needs will be projected once the Foundation is stood up, and job applications will be made public.\n\n## **Protocol Control Remains with Tokenholders**\n\nProtocol control such as smart contract upgrades, ENS pricing and fee structures, root key and registry control, the DAO’s ENS tokens, and constitutional amendments remain exclusively with tokenholders. The Foundation has no role in protocol governance decisions. Director appointment, term renewal, and removal also remain with tokenholders, under the Foundation's Articles of Association, unchanged by this proposal.\n\n## **Foundation Leadership and Board**\n\nThe Foundation Board has five voting seats:\n\n* One voting seat for the Founder of ENS, Nick Johnson, with succession to a designated ENS Labs representative in the event of the Founder's resignation or departure from the Board.\n* One voting seat for the Foundation's Executive Director.\n* Three voting seats for independent directors.\n\nThe Executive Director (ED) is a full-time Foundation employee and voting Board member who leads Foundation operations and the grants program, with day-to-day authority subject to Board oversight, approved budgets, conflict-of-interest requirements, and the onchain controls in this proposal. The ED cannot unilaterally transfer Endowment assets, alter protocol control or ENS token permissions, or bind the Foundation outside approved authority. The Board holds exclusive authority over the ED's employment, with compensation set annually by the three independent directors, the ED and Founder recused. Independent directors serve two-year terms renewable by the DAO and are compensated at 40,000 USDC per year, donated to a non-profit or public good of their choosing if declined.\n\nFollowing a search process led by the existing ENS Foundation Board with input from ENS Labs leadership, the proposed inaugural slate is:\n\n* Executive Director: Alexander Urbelis\n* Director: Nick Johnson\n* Independent Director: Kartik Talwar\n* Independent Director: Brett Sun\n* Independent Director: Anthony Leutenegger\n\nBios for each nominee, including baseline disclosure of material affiliations requiring recusal, are in the temp check.\n\nFurthermore, this proposal adds a documented process for exercising Director removal authority so that removal is legible and follows a clear process. The Foundation's bylaws will set out a standard process for tokenholder removal petitions: (i) a petition states the grounds for removal with supporting evidence; (ii) the petition is filed with the Foundation Board, which has a defined window to respond before a tokenholder vote is called; (iii) a 30-day period applies between petition and vote; and (iv) the director under petition may publish a written defense alongside the petition. These steps are designed to create notice, a documented record, and a fair opportunity to respond. They do not condition or limit tokenholders' removal authority under the Foundation's Articles: a removal vote validly conducted under the Articles is effective whether or not this process was followed, and no Board action or inaction under this process can delay or prevent it.\n\n## **Conflicts of Interest**\n\nThe Foundation will adopt, as Exhibit A to this proposal, an interim Conflict of Interest Policy effective upon adoption of this proposal. The interim policy requires written disclosure of actual and potential conflicts, recusal from deliberations and votes where appropriate, public recording of disclosures and recusals, and independent-director approval for ENS Labs funding. Within 90 days, the Executive Director will present a refined policy for Board approval and public publication.\n\n## **Revenue and Treasury**\n\nStewardship of the DAO's operating capital, meaning the Endowment and the Endowment Manager relationship, is delegated to Foundation governance consistent with Article III of the DAO Constitution. This proposal changes administrative control of the existing [Endowment Safe](https://app.syncrone.fi/public/ens) so the Foundation Board, acting through approved signers and subject to the timelock and Security Council cancellation right described below, oversees execution of Endowment transactions. This means no ETH or stablecoins are transferred by this proposal; the assets remain in the existing Endowment Safe at the same address.\n\nAlongside this change, a 9-day timelock is added to all Endowment transactions by default, and the Security Council can cancel any timelocked transaction. The Security Council cancellation right is included as a technical safeguard against unauthorized, erroneous, malicious, or mandate-inconsistent Endowment transactions. It is not a general governance veto over Foundation policy, Board judgment, approved budgets, or ordinary implementation of a ratified DAO proposal. The Security Council's cancellation function over Endowment transactions is conferred by this proposal and the Safe configuration, and does not amend the Security Council Charter.\n\nThe Endowment Manager’s existing investment-management permissions remain unchanged. Any permission to transfer funds from the Endowment Safe to the Foundation multisig without timelock will be limited to approved budget funding and subject to recipient restrictions, Board approval, public reporting, and any technical controls specified in the Transactions section. The Foundation will not receive any general, uncapped, or discretionary non-timelocked withdrawal authority. The DAO operational wallet remains in its current place, with existing streams continuing to draw from it.\n\nThe Foundation will receive no operating funding under this proposal until the Executive Director has presented a projected budget to the Board and a high-level version has been published to the DAO forum. Pending that publication, aggregate transfers from the Endowment to the Foundation may not exceed USD 500,000, covering standup costs only. The first annual budget will be published within 60 days of adoption, and annual Foundation spending thereafter is bounded by the published budget.\n\nThe ~54.6 million ENS tokens the DAO owns stay under the existing onchain mechanism and tokenholder control. The single exception is the 1,000,000 ENS transfer for Foundation employee compensation, administered under a Board-approved compensation framework published before any grant is made: multi-year vesting, independent-director approval for any grant to the ED or a director, annual public reporting of aggregate token compensation, and reversion to the DAO treasury of any tokens ungranted at wind-down or recalled by DAO vote. Pending grants, the tokens will not be voted, delegated, lent, pledged, transferred to ENS Labs, or used to compensate ENS Labs personnel. Any other use of DAO-held tokens comes to the DAO as its own proposal through the ordinary governance process.\n\nFor the avoidance of doubt, this proposal does not transfer beneficial ownership of ETH, stablecoins, or DAO-held ENS tokens to any director, officer, employee, ENS Labs, or other private party. The Endowment remains dedicated to the ENS mission and subject to the Foundation’s obligations, approved budgets, reporting commitments, technical controls, and tokenholder-retained protocol authority.\n\n## **Grants & Working Group Transition**\n\nAny grant-making is consolidated under the Foundation's Grants program, focused on public goods and core infrastructure benefiting the ENS protocol and the broader Ethereum ecosystem. The Foundation works alongside the SPP Committee on SPP3, and going forward, SPP is absorbed into the Grants program, and reporting requirements for SPP recipients do not change. Existing stewards, active streams, and current-term commitments are honored through their natural conclusion, with a transition plan covering SPP3 disbursements, Endowment Manager continuity, and the working group wind-down developed collaboratively with all parties once the Foundation is stood up. No existing stream, award, or current-term commitment may be reduced, paused, or re-conditioned except per its own terms or with the recipient's consent.\n\n## **Specification**\n\nThe only ENS token transaction in this proposal is a single one-time transfer of 1,000,000 ENS to the Foundation, restricted to Foundation employee compensation. No other transaction moves, delegates, or re-permissions ENS tokens held by the DAO.\n\n***\n\n## **Exhibit A:**\n\n**Interim Conflict of Interest Policy**\n\n**1. Purpose and status**\n\nThis interim policy takes effect on adoption of this proposal and governs the ENS Foundation Board and Executive Director until the Board approves the detailed Conflict of Interest Policy contemplated within the first 90 days. That later policy refines this one; it does not replace it with anything weaker. Any material change to this policy is published before it takes effect.\n\n**2. What counts as a conflict**\n\nA conflict exists when a director's or the ED's personal, financial, or professional interests could reasonably appear to influence their judgment on a Foundation matter. This includes, without limitation: a current role, employment, or compensation at an entity that receives Foundation funding; a financial interest in a grant applicant, service provider, or counterparty; a family or close personal relationship with any of the foregoing; and any matter concerning the person's own compensation, employment, or removal. Appearance matters: if a reasonable community member would question the person's impartiality, it is treated as a conflict.\n\n**3. Disclosure**\n\nEach director and the ED discloses actual and potential conflicts in writing: (a) on appointment, as a baseline disclosure of material affiliations; (b) at each Board meeting, as to any agenda item; and (c) promptly when a new conflict arises. Disclosures are recorded in the Board minutes and published with them.\n\n**4. Recusal**\n\nA conflicted person does not vote on the matter and does not participate in the Board's deliberation of it, beyond answering questions the non-conflicted directors ask. Recusals are recorded in the minutes and published. The following recusals are standing and automatic:\n\n· Any director or the ED, on any matter involving an entity in which they currently hold a role, employment, compensation, equity, or other financial interest.\n\n· The Founder seat, on any matter concerning ENS Labs funding, for so long as the seat carries succession to an ENS Labs representative.\n\n· The ED on all matters concerning the ED's own employment, compensation, or performance.\n\n· Any director on their own compensation, renewal, or removal.\n\nPrior roles or employment that have ended are disclosed in the public register under Section 6 but do not by themselves require recusal.\n\n**4A. Related-party decisions**\n\nAny decision concerning ENS Labs funding, in addition to any Board majority, requires the affirmative vote of a majority of the independent directors voting on the matter.\n\n**5. Quorum and decision on conflicted matters**\n\nA matter on which one or more members are recused is decided by majority of the non-recused directors, provided at least two non-recused directors participate. If recusals leave fewer than two, the matter is deferred until the Board has obtained independent advice on the matter, and the advice and the ultimate decision are recorded in the published minutes.\n\n**6. Public record**\n\nAll disclosures, recusals, and votes on conflicted matters are recorded in the minutes and published. The Foundation maintains a public register of each director's and the ED's material affiliations, updated at least quarterly.\n\n**7. Gifts and personal benefit**\n\nNo director or the ED may accept any gift, payment, or benefit offered to influence a Foundation decision, or use Foundation position, information, or assets for personal benefit. Gifts above a nominal value connected to Foundation business are disclosed and declined or surrendered to the Foundation.\n\n**8. Attestation**\n\nEach director and the ED signs an annual written attestation of compliance with this policy, published with the Foundation's annual reporting.",
+} as const;
diff --git a/apps/relayer/e2e/helpers/anvil.ts b/apps/relayer/e2e/helpers/anvil.ts
index 043d38b8a1..c98e64b915 100644
--- a/apps/relayer/e2e/helpers/anvil.ts
+++ b/apps/relayer/e2e/helpers/anvil.ts
@@ -18,6 +18,8 @@ export async function startAnvil(options?: {
port?: number;
logs?: boolean;
forkUrl?: string;
+ /** Overrides the shared FORK_BLOCK for suites pinned to specific state. */
+ forkBlockNumber?: number;
}): Promise
{
const forkUrl = options?.forkUrl ?? process.env["RPC_URL"];
if (!forkUrl) {
@@ -29,7 +31,7 @@ export async function startAnvil(options?: {
anvilInstance = Instance.anvil(
{
forkUrl,
- forkBlockNumber: FORK_BLOCK,
+ forkBlockNumber: options?.forkBlockNumber ?? FORK_BLOCK,
...(options?.port !== undefined ? { port: options.port } : {}),
},
{ timeout: 30_000 },
diff --git a/apps/relayer/e2e/relay-proposal.test.ts b/apps/relayer/e2e/relay-proposal.test.ts
new file mode 100644
index 0000000000..2175f6d37c
--- /dev/null
+++ b/apps/relayer/e2e/relay-proposal.test.ts
@@ -0,0 +1,200 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { OpenAPIHono as Hono } from "@hono/zod-openapi";
+import {
+ type Address,
+ type Hex,
+ createPublicClient,
+ http,
+ parseEther,
+} from "viem";
+import { mainnet } from "viem/chains";
+
+import { governorAbi, ProposalState } from "@/abi/governor";
+import { createLogger } from "@anticapture/observability";
+import { relayProposal } from "@/controllers/relay-proposal";
+import { RelayError } from "@/errors";
+import { ViemGovernorGateway } from "@/services/chain/governor-gateway";
+import { ProposalEnactmentService } from "@/services/proposals/proposal-enactment";
+import type {
+ ProposalArgs,
+ ProposalSource,
+} from "@/services/proposals/proposal-source";
+import { createLocalSigner } from "@/signer/local-signer";
+
+import {
+ GOVERNOR_ADDRESS,
+ RELAYER_ADDRESS,
+ RELAYER_KEY,
+ startAnvil,
+ stopAnvil,
+ createClients,
+} from "./helpers";
+import { EXECUTED_PROPOSAL } from "./fixtures/executed-proposal";
+
+const silentLogger = createLogger("relay-proposal-e2e");
+silentLogger.level = "silent";
+
+// Fork one block before the proposal's real ProposalQueued tx: voting is
+// over, nobody has queued yet, so the governor reports Succeeded and the
+// endpoints can replay the queue -> execute lifecycle from scratch.
+const FORK_BLOCK_SUCCEEDED = Number(EXECUTED_PROPOSAL.queuedBlock) - 1;
+
+const PROPOSAL_ID = EXECUTED_PROPOSAL.proposalId;
+
+// The stub serves the fixture the way the Anticapture API would; the
+// service's hashProposal verification proves the args are genuine.
+const PROPOSAL_ARGS: ProposalArgs = {
+ targets: [...EXECUTED_PROPOSAL.targets] as Address[],
+ values: EXECUTED_PROPOSAL.values.map(BigInt),
+ calldatas: [...EXECUTED_PROPOSAL.calldatas] as Hex[],
+ description: EXECUTED_PROPOSAL.description,
+};
+
+class StubProposalSource implements ProposalSource {
+ proposals = new Map([[PROPOSAL_ID, PROPOSAL_ARGS]]);
+ async getProposal(proposalId: string): Promise {
+ return this.proposals.get(proposalId) ?? null;
+ }
+}
+
+type TestClient = ReturnType["testClient"];
+
+function createProposalApp(rpcUrl: string, source?: ProposalSource) {
+ const publicClient = createPublicClient({
+ chain: mainnet,
+ transport: http(rpcUrl, { timeout: 30_000 }),
+ });
+ const signer = createLocalSigner(RELAYER_KEY, mainnet, rpcUrl);
+
+ const service = new ProposalEnactmentService(
+ new ViemGovernorGateway(publicClient, GOVERNOR_ADDRESS),
+ signer,
+ source ?? new StubProposalSource(),
+ { minBalanceWei: parseEther("0.1").valueOf() },
+ silentLogger,
+ );
+
+ const app = new Hono();
+ app.onError((err, c) => {
+ if (err instanceof RelayError) {
+ return c.json({ error: err.message, code: err.code }, err.status as 400);
+ }
+ return c.json({ error: "Internal server error", code: "INTERNAL" }, 500);
+ });
+ relayProposal(app, service);
+ return app;
+}
+
+async function post(
+ app: ReturnType,
+ path: string,
+ proposalId: string,
+) {
+ const response = await app.request(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ proposalId }),
+ });
+ const body = (await response.json()) as {
+ transactionHash?: string;
+ code?: string;
+ error?: string;
+ };
+ return { status: response.status, body };
+}
+
+describe("POST /relay/queue + /relay/execute", () => {
+ let rpcUrl: string;
+ let testClient: TestClient;
+ let app: ReturnType;
+
+ beforeAll(async () => {
+ rpcUrl = await startAnvil({ forkBlockNumber: FORK_BLOCK_SUCCEEDED });
+ testClient = createClients(rpcUrl).testClient;
+
+ await testClient.setBalance({
+ address: RELAYER_ADDRESS,
+ value: parseEther("10"),
+ });
+
+ expect(await proposalState()).toBe(ProposalState.Succeeded);
+ }, 120_000);
+
+ afterAll(async () => {
+ await stopAnvil();
+ });
+
+ async function proposalState(): Promise {
+ return testClient.readContract({
+ address: GOVERNOR_ADDRESS,
+ abi: governorAbi,
+ functionName: "state",
+ args: [BigInt(PROPOSAL_ID)],
+ });
+ }
+
+ it("returns 404 for a proposal the governor does not know", async () => {
+ app = createProposalApp(rpcUrl);
+
+ const { status, body } = await post(app, "/relay/queue", "999");
+
+ expect(status).toBe(404);
+ expect(body).toMatchObject({ code: "PROPOSAL_NOT_FOUND" });
+ });
+
+ it("rejects tampered API data with 422 via the real hashProposal", async () => {
+ const tampered = new StubProposalSource();
+ tampered.proposals.set(PROPOSAL_ID, {
+ ...PROPOSAL_ARGS,
+ description: `${PROPOSAL_ARGS.description} (tampered)`,
+ });
+ app = createProposalApp(rpcUrl, tampered);
+
+ const { status, body } = await post(app, "/relay/queue", PROPOSAL_ID);
+
+ expect(status).toBe(422);
+ expect(body).toMatchObject({ code: "PROPOSAL_DATA_MISMATCH" });
+ });
+
+ it("queues a succeeded proposal, then executes it after the timelock", async () => {
+ app = createProposalApp(rpcUrl);
+
+ // Execute before queue: wrong state.
+ const early = await post(app, "/relay/execute", PROPOSAL_ID);
+ expect(early.status).toBe(409);
+ expect(early.body).toMatchObject({ code: "INVALID_PROPOSAL_STATE" });
+
+ // Queue.
+ const queued = await post(app, "/relay/queue", PROPOSAL_ID);
+ expect(queued.status).toBe(200);
+ expect(queued.body.transactionHash).toMatch(/^0x[0-9a-f]{64}$/);
+ expect(await proposalState()).toBe(ProposalState.Queued);
+
+ // Queue again: no longer Succeeded.
+ const requeued = await post(app, "/relay/queue", PROPOSAL_ID);
+ expect(requeued.status).toBe(409);
+ expect(requeued.body).toMatchObject({ code: "INVALID_PROPOSAL_STATE" });
+
+ // Execute before the eta: timelock not ready.
+ const premature = await post(app, "/relay/execute", PROPOSAL_ID);
+ expect(premature.status).toBe(409);
+ expect(premature.body).toMatchObject({ code: "TIMELOCK_NOT_READY" });
+
+ // Cross the timelock eta on-chain.
+ const eta = await testClient.readContract({
+ address: GOVERNOR_ADDRESS,
+ abi: governorAbi,
+ functionName: "proposalEta",
+ args: [BigInt(PROPOSAL_ID)],
+ });
+ const { timestamp } = await testClient.getBlock();
+ await testClient.increaseTime({ seconds: Number(eta - timestamp) + 60 });
+ await testClient.mine({ blocks: 1 });
+
+ // Execute.
+ const executed = await post(app, "/relay/execute", PROPOSAL_ID);
+ expect(executed.status).toBe(200);
+ expect(executed.body.transactionHash).toMatch(/^0x[0-9a-f]{64}$/);
+ expect(await proposalState()).toBe(ProposalState.Executed);
+ }, 120_000);
+});
diff --git a/apps/relayer/package.json b/apps/relayer/package.json
index 2076b88544..28813cba99 100644
--- a/apps/relayer/package.json
+++ b/apps/relayer/package.json
@@ -17,6 +17,7 @@
"clean": "rm -rf node_modules dist *.tsbuildinfo"
},
"dependencies": {
+ "@anticapture/client": "workspace:*",
"@anticapture/observability": "workspace:*",
"@hono/node-server": "^1.19.9",
"@hono/zod-openapi": "^1.2.2",
diff --git a/apps/relayer/src/controllers/relay-proposal.ts b/apps/relayer/src/controllers/relay-proposal.ts
new file mode 100644
index 0000000000..a9d733a7af
--- /dev/null
+++ b/apps/relayer/src/controllers/relay-proposal.ts
@@ -0,0 +1,100 @@
+import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi";
+
+import {
+ RelayProposalRequestSchema,
+ RelayProposalResponseSchema,
+} from "@/schemas/relay-proposal";
+import { ErrorResponseSchema } from "@/errors";
+import { ProposalEnactmentService } from "@/services/proposals/proposal-enactment";
+
+const errorResponses = {
+ 400: {
+ description: "Validation error",
+ content: { "application/json": { schema: ErrorResponseSchema } },
+ },
+ 404: {
+ description: "Proposal not found",
+ content: { "application/json": { schema: ErrorResponseSchema } },
+ },
+ 409: {
+ description:
+ "Proposal is not in an actionable state, the simulation reverted, or the broadcast transaction reverted on-chain",
+ content: { "application/json": { schema: ErrorResponseSchema } },
+ },
+ 422: {
+ description: "Proposal data failed on-chain verification",
+ content: { "application/json": { schema: ErrorResponseSchema } },
+ },
+ 503: {
+ description: "Relayer unavailable (low balance)",
+ content: { "application/json": { schema: ErrorResponseSchema } },
+ },
+} as const;
+
+export function relayProposal(app: Hono, service: ProposalEnactmentService) {
+ app.openapi(
+ createRoute({
+ method: "post",
+ operationId: "relayQueue",
+ path: "/relay/queue",
+ summary: "Queue a succeeded proposal",
+ description:
+ "Broadcast the permissionless Governor queue() for a proposal in Succeeded state. The relayer pays gas. Proposal args are fetched from the Anticapture API and verified on-chain via hashProposal.",
+ tags: ["relay"],
+ request: {
+ body: {
+ content: {
+ "application/json": { schema: RelayProposalRequestSchema },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: "Queue transaction submitted",
+ content: {
+ "application/json": { schema: RelayProposalResponseSchema },
+ },
+ },
+ ...errorResponses,
+ },
+ }),
+ async (c) => {
+ const { proposalId } = c.req.valid("json");
+ const { txHash } = await service.queue(proposalId.toString());
+ return c.json({ transactionHash: txHash }, 200);
+ },
+ );
+
+ app.openapi(
+ createRoute({
+ method: "post",
+ operationId: "relayExecute",
+ path: "/relay/execute",
+ summary: "Execute a queued proposal",
+ description:
+ "Broadcast the permissionless Governor execute() for a proposal in Queued state whose timelock eta has passed. The relayer pays gas. Proposal args are fetched from the Anticapture API and verified on-chain via hashProposal.",
+ tags: ["relay"],
+ request: {
+ body: {
+ content: {
+ "application/json": { schema: RelayProposalRequestSchema },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: "Execute transaction submitted",
+ content: {
+ "application/json": { schema: RelayProposalResponseSchema },
+ },
+ },
+ ...errorResponses,
+ },
+ }),
+ async (c) => {
+ const { proposalId } = c.req.valid("json");
+ const { txHash } = await service.execute(proposalId.toString());
+ return c.json({ transactionHash: txHash }, 200);
+ },
+ );
+}
diff --git a/apps/relayer/src/env.ts b/apps/relayer/src/env.ts
index 84717a99a2..76ec3f8d59 100644
--- a/apps/relayer/src/env.ts
+++ b/apps/relayer/src/env.ts
@@ -50,6 +50,11 @@ const envSchema = z.object({
.positive()
.optional(),
+ // Gateful gateway backing POST /relay/queue and /relay/execute, which
+ // fetch proposal execution args from GET /{dao}/proposals/{id}.
+ ANTICAPTURE_API_URL: z.url(),
+ ANTICAPTURE_API_KEY: z.string().min(1),
+
PORT: z.coerce.number().default(3002),
// Injected by Railway. Reported on /health so gateful — which merges this
diff --git a/apps/relayer/src/errors.ts b/apps/relayer/src/errors.ts
index 4f060f7e32..057c16f676 100644
--- a/apps/relayer/src/errors.ts
+++ b/apps/relayer/src/errors.ts
@@ -50,4 +50,40 @@ export const Errors = {
"RATE_LIMITER_UNAVAILABLE",
503,
),
+ PROPOSAL_NOT_FOUND: (proposalId: string) =>
+ new RelayError(
+ `Proposal ${proposalId} was not found`,
+ "PROPOSAL_NOT_FOUND",
+ 404,
+ ),
+ PROPOSAL_DATA_MISMATCH: (proposalId: string) =>
+ new RelayError(
+ `Proposal data does not hash to the requested proposal id ${proposalId}`,
+ "PROPOSAL_DATA_MISMATCH",
+ 422,
+ ),
+ INVALID_PROPOSAL_STATE: (action: string, state: string) =>
+ new RelayError(
+ `Proposal cannot be ${action}d while in state ${state}`,
+ "INVALID_PROPOSAL_STATE",
+ 409,
+ ),
+ TIMELOCK_NOT_READY: (eta: bigint) =>
+ new RelayError(
+ `Proposal timelock is not ready; executable at ${eta.toString()} (unix seconds)`,
+ "TIMELOCK_NOT_READY",
+ 409,
+ ),
+ SIMULATION_FAILED: (action: string, reason: string) =>
+ new RelayError(
+ `Simulation of ${action}() reverted: ${reason}`,
+ "SIMULATION_FAILED",
+ 409,
+ ),
+ TRANSACTION_REVERTED: (txHash: string) =>
+ new RelayError(
+ `Transaction ${txHash} was mined but reverted on-chain`,
+ "TRANSACTION_REVERTED",
+ 409,
+ ),
} as const;
diff --git a/apps/relayer/src/index.ts b/apps/relayer/src/index.ts
index ef8bd73d10..14ccd8dcad 100644
--- a/apps/relayer/src/index.ts
+++ b/apps/relayer/src/index.ts
@@ -17,6 +17,7 @@ import { config } from "@/controllers/config";
import { health } from "@/controllers/health";
import { rateLimit } from "@/controllers/rate-limit";
import { relayDelegate } from "@/controllers/relay-delegate";
+import { relayProposal } from "@/controllers/relay-proposal";
import { relayVote } from "@/controllers/relay-vote";
import { env } from "@/env";
import { RelayError } from "@/errors";
@@ -27,6 +28,9 @@ import {
resolveRelayLimits,
} from "@/services/guards/rate-limiter";
import { ChainStateService } from "@/services/chain/chain-state";
+import { ViemGovernorGateway } from "@/services/chain/governor-gateway";
+import { ProposalEnactmentService } from "@/services/proposals/proposal-enactment";
+import { AnticaptureProposalSource } from "@/services/proposals/proposal-source";
import { RelayService } from "@/services/relay";
import { SignatureVerifier } from "@/services/guards/signature-verifier";
import { createLocalSigner } from "@/signer/local-signer";
@@ -159,6 +163,23 @@ async function main() {
// --- Routes ---
relayVote(app, relayService);
relayDelegate(app, relayService);
+ relayProposal(
+ app,
+ wrapWithTracing(
+ new ProposalEnactmentService(
+ wrapWithTracing(new ViemGovernorGateway(publicClient, governorAddress)),
+ signer,
+ wrapWithTracing(
+ new AnticaptureProposalSource(
+ env.ANTICAPTURE_API_URL,
+ env.DAO_NAME,
+ env.ANTICAPTURE_API_KEY,
+ ),
+ ),
+ { minBalanceWei: BigInt(env.MIN_RELAYER_BALANCE_WEI) },
+ ),
+ ),
+ );
health(app);
config(app, {
minVotingPower: env.MIN_VOTING_POWER,
diff --git a/apps/relayer/src/schemas/evm-primitives.test.ts b/apps/relayer/src/schemas/evm-primitives.test.ts
new file mode 100644
index 0000000000..4397ff2c83
--- /dev/null
+++ b/apps/relayer/src/schemas/evm-primitives.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { maxUint256 } from "viem";
+
+import { DecimalUint256Schema } from "./evm-primitives";
+
+describe("DecimalUint256Schema", () => {
+ it("parses a canonical decimal string", () => {
+ expect(DecimalUint256Schema.parse("42")).toBe(42n);
+ });
+
+ it("accepts the uint256 maximum", () => {
+ expect(DecimalUint256Schema.parse(maxUint256.toString())).toBe(maxUint256);
+ });
+
+ it("rejects values above the uint256 maximum", () => {
+ expect(() =>
+ DecimalUint256Schema.parse((maxUint256 + 1n).toString()),
+ ).toThrow();
+ });
+
+ it("rejects digit strings longer than a uint256 can hold", () => {
+ expect(() => DecimalUint256Schema.parse("1".repeat(79))).toThrow();
+ });
+
+ it("rejects non-decimal input", () => {
+ expect(() => DecimalUint256Schema.parse("0x2a")).toThrow();
+ });
+});
diff --git a/apps/relayer/src/schemas/evm-primitives.ts b/apps/relayer/src/schemas/evm-primitives.ts
index f941411d56..604297a9da 100644
--- a/apps/relayer/src/schemas/evm-primitives.ts
+++ b/apps/relayer/src/schemas/evm-primitives.ts
@@ -1,4 +1,4 @@
-import { getAddress, isAddress, isHex } from "viem";
+import { getAddress, isAddress, isHex, maxUint256 } from "viem";
import { z } from "@hono/zod-openapi";
export const HexSchema = z
@@ -20,5 +20,9 @@ export const TxHashSchema = HexSchema.refine(
export const DecimalUint256Schema = z
.string()
+ // uint256 fits in 78 decimal digits; without a cap, BigInt() on an
+ // arbitrarily long digit string is superlinear CPU on anonymous routes.
+ .max(78, "too long for a uint256")
.regex(/^\d+$/, "must be a non-negative decimal integer")
- .transform((v) => BigInt(v));
+ .transform((v) => BigInt(v))
+ .refine((v) => v <= maxUint256, "exceeds the uint256 maximum");
diff --git a/apps/relayer/src/schemas/relay-proposal.ts b/apps/relayer/src/schemas/relay-proposal.ts
new file mode 100644
index 0000000000..c6b2659bcc
--- /dev/null
+++ b/apps/relayer/src/schemas/relay-proposal.ts
@@ -0,0 +1,21 @@
+import { z } from "@hono/zod-openapi";
+
+import { DecimalUint256Schema, TxHashSchema } from "./evm-primitives";
+
+export const RelayProposalRequestSchema = z
+ .object({
+ proposalId: DecimalUint256Schema.openapi({
+ type: "string",
+ description: "Proposal ID as decimal string",
+ example: "42",
+ }),
+ })
+ .openapi("RelayerProposalRequest");
+
+export const RelayProposalResponseSchema = z
+ .object({
+ transactionHash: TxHashSchema,
+ })
+ .openapi("RelayerProposalResponse");
+
+export type RelayProposalRequest = z.input;
diff --git a/apps/relayer/src/services/chain/governor-gateway.ts b/apps/relayer/src/services/chain/governor-gateway.ts
new file mode 100644
index 0000000000..1c553ccd3b
--- /dev/null
+++ b/apps/relayer/src/services/chain/governor-gateway.ts
@@ -0,0 +1,183 @@
+import {
+ Address,
+ BaseError,
+ ContractFunctionRevertedError,
+ ExecutionRevertedError,
+ Hash,
+ Hex,
+ WaitForTransactionReceiptTimeoutError,
+ type PublicActions,
+} from "viem";
+
+import { governorAbi, ProposalState } from "@/abi/governor";
+
+import type { ChainReader } from "./chain-reader";
+
+/** queue()/execute() argument tuple, with the description already hashed. */
+export interface EnactmentCall {
+ targets: Address[];
+ values: bigint[];
+ calldatas: Hex[];
+ descriptionHash: Hex;
+}
+
+/**
+ * A simulation that reverted (as opposed to failing to run at all). Carries
+ * only the short revert reason: full RPC error messages can embed the
+ * provider URL, which may hold an API key, so they must never reach callers.
+ */
+export class SimulationRevertError extends Error {
+ constructor(
+ public readonly reason: string,
+ options?: ErrorOptions,
+ ) {
+ super(`simulation reverted: ${reason}`, options);
+ this.name = "SimulationRevertError";
+ }
+}
+
+/**
+ * "timeout" means the transaction was broadcast but no receipt arrived in
+ * time — the caller still holds a valid tx hash, not a failure.
+ */
+export type ReceiptOutcome = "success" | "reverted" | "timeout";
+
+/**
+ * Domain-level view of the Governor for proposal enactment, so consumers
+ * (and their tests) never deal with ABI plumbing or viem generics.
+ */
+export interface GovernorGateway {
+ /** The governor contract address transactions must be sent to. */
+ readonly address: Address;
+ hashProposal(call: EnactmentCall): Promise;
+ /** Resolves to null when the governor does not know the proposal id. */
+ state(proposalId: bigint): Promise;
+ proposalEta(proposalId: bigint): Promise;
+ /** Timestamp of the latest block, in unix seconds. */
+ blockTimestamp(): Promise;
+ ethBalance(address: Address): Promise;
+ /** Dry-runs the call; throws SimulationRevertError when it would revert. */
+ simulate(
+ functionName: "queue" | "execute",
+ call: EnactmentCall,
+ from: Address,
+ ): Promise;
+ waitForReceipt(txHash: Hash): Promise;
+}
+
+/** The shared ChainReader plus the extra viem actions the gateway needs. */
+export type GovernorChainReader = ChainReader &
+ Pick;
+
+// Don't hold the HTTP response hostage to viem's 180s default: mainnet
+// inclusion is ~12s, and anything slower is reported as "timeout" (the hash
+// is still returned to the caller). Must also stay comfortably below
+// Gateful's 30s proxy budget (apps/gateful/src/proxy/relayer.ts), which
+// covers the whole request — otherwise the gateway aborts and records a
+// circuit-breaker failure for a transaction that was already broadcast.
+const RECEIPT_TIMEOUT_MS = 15_000;
+
+function findRevert(err: unknown) {
+ if (!(err instanceof BaseError)) return null;
+ return err.walk(
+ (e) =>
+ e instanceof ContractFunctionRevertedError ||
+ e instanceof ExecutionRevertedError,
+ ) as ContractFunctionRevertedError | ExecutionRevertedError | null;
+}
+
+function revertReason(
+ revert: ContractFunctionRevertedError | ExecutionRevertedError,
+): string {
+ if (revert instanceof ContractFunctionRevertedError) {
+ return revert.reason ?? revert.data?.errorName ?? revert.shortMessage;
+ }
+ return revert.shortMessage;
+}
+
+export class ViemGovernorGateway implements GovernorGateway {
+ constructor(
+ private client: GovernorChainReader,
+ readonly address: Address,
+ ) {}
+
+ async hashProposal(call: EnactmentCall): Promise {
+ return this.client.readContract({
+ address: this.address,
+ abi: governorAbi,
+ functionName: "hashProposal",
+ args: [call.targets, call.values, call.calldatas, call.descriptionHash],
+ });
+ }
+
+ async state(proposalId: bigint): Promise {
+ try {
+ return await this.client.readContract({
+ address: this.address,
+ abi: governorAbi,
+ functionName: "state",
+ args: [proposalId],
+ });
+ } catch (err) {
+ // The governor reverts on unknown proposal ids; anything else (RPC
+ // outage, bad ABI) must not masquerade as "not found".
+ if (findRevert(err)) return null;
+ throw err;
+ }
+ }
+
+ async proposalEta(proposalId: bigint): Promise {
+ return this.client.readContract({
+ address: this.address,
+ abi: governorAbi,
+ functionName: "proposalEta",
+ args: [proposalId],
+ });
+ }
+
+ async blockTimestamp(): Promise {
+ const { timestamp } = await this.client.getBlock();
+ return timestamp;
+ }
+
+ async ethBalance(address: Address): Promise {
+ return this.client.getBalance({ address });
+ }
+
+ async simulate(
+ functionName: "queue" | "execute",
+ call: EnactmentCall,
+ from: Address,
+ ): Promise {
+ try {
+ await this.client.simulateContract({
+ address: this.address,
+ abi: governorAbi,
+ functionName,
+ args: [call.targets, call.values, call.calldatas, call.descriptionHash],
+ account: from,
+ });
+ } catch (err) {
+ const revert = findRevert(err);
+ if (revert) {
+ throw new SimulationRevertError(revertReason(revert), { cause: err });
+ }
+ throw err;
+ }
+ }
+
+ async waitForReceipt(txHash: Hash): Promise {
+ try {
+ const receipt = await this.client.waitForTransactionReceipt({
+ hash: txHash,
+ timeout: RECEIPT_TIMEOUT_MS,
+ });
+ return receipt.status === "success" ? "success" : "reverted";
+ } catch (err) {
+ if (err instanceof WaitForTransactionReceiptTimeoutError) {
+ return "timeout";
+ }
+ throw err;
+ }
+ }
+}
diff --git a/apps/relayer/src/services/proposals/proposal-enactment.test.ts b/apps/relayer/src/services/proposals/proposal-enactment.test.ts
new file mode 100644
index 0000000000..46d3f47fd0
--- /dev/null
+++ b/apps/relayer/src/services/proposals/proposal-enactment.test.ts
@@ -0,0 +1,445 @@
+import { describe, it, expect } from "vitest";
+import {
+ Address,
+ Hash,
+ Hex,
+ InsufficientFundsError,
+ encodeFunctionData,
+ keccak256,
+ parseEther,
+ stringToBytes,
+} from "viem";
+
+import { governorAbi, ProposalState } from "@/abi/governor";
+import { createLogger } from "@anticapture/observability";
+import { RelayError } from "@/errors";
+import type { GovernorGateway } from "@/services/chain/governor-gateway";
+import { SimulationRevertError } from "@/services/chain/governor-gateway";
+import { RelayerSigner } from "@/signer/types";
+
+import { ProposalEnactmentService } from "./proposal-enactment";
+import type { ProposalArgs, ProposalSource } from "./proposal-source";
+
+const silentLogger = createLogger("proposal-enactment-test");
+silentLogger.level = "silent";
+
+const GOVERNOR: Address = "0x323A76393544d5ecca80cd6ef2A560C6a395b7E3";
+const RELAYER: Address = "0x1111111111111111111111111111111111111111";
+const TX_HASH: Hash = `0x${"ab".repeat(32)}`;
+const NOW = 1_700_000_000n;
+
+const ARGS: ProposalArgs = {
+ targets: ["0x0000000000000000000000000000000000000001"],
+ values: [0n],
+ calldatas: ["0xdeadbeef"],
+ description: "# Do the thing",
+};
+const DESCRIPTION_HASH = keccak256(stringToBytes(ARGS.description));
+const PROPOSAL_ID = 42n;
+
+function createStubSource(
+ proposals: Record = {
+ [PROPOSAL_ID.toString()]: { ...ARGS },
+ },
+) {
+ const fetched: string[] = [];
+ const source: ProposalSource = {
+ getProposal: async (proposalId) => {
+ fetched.push(proposalId);
+ return proposals[proposalId] ?? null;
+ },
+ };
+ return { source, fetched };
+}
+
+function createStubSigner() {
+ const sent: { to: Address; data: Hex; value?: bigint }[] = [];
+ const signer: RelayerSigner = {
+ getAddress: async () => RELAYER,
+ sendTransaction: async (tx) => {
+ sent.push(tx);
+ return TX_HASH;
+ },
+ };
+ return { signer, sent };
+}
+
+function createStubGovernor(overrides: Partial = {}) {
+ const simulated: { functionName: string }[] = [];
+ const governor: GovernorGateway = {
+ address: GOVERNOR,
+ hashProposal: async () => PROPOSAL_ID,
+ state: async () => ProposalState.Succeeded,
+ proposalEta: async () => 0n,
+ blockTimestamp: async () => NOW,
+ ethBalance: async () => parseEther("1"),
+ simulate: async (functionName) => {
+ simulated.push({ functionName });
+ },
+ waitForReceipt: async () => "success",
+ ...overrides,
+ };
+ return { governor, simulated };
+}
+
+function createService(
+ overrides: {
+ governor?: Partial;
+ source?: ProposalSource;
+ } = {},
+) {
+ const { governor, simulated } = createStubGovernor(overrides.governor);
+ const { signer, sent } = createStubSigner();
+ const service = new ProposalEnactmentService(
+ governor,
+ signer,
+ overrides.source ?? createStubSource().source,
+ { minBalanceWei: parseEther("0.1").valueOf() },
+ silentLogger,
+ );
+ return { service, sent, simulated };
+}
+
+async function expectRelayError(
+ promise: Promise,
+ code: string,
+ status: number,
+): Promise {
+ const error = await promise.then(
+ () => null,
+ (e) => e as RelayError,
+ );
+ expect(error, `expected ${code} to be thrown`).toBeInstanceOf(RelayError);
+ expect(error?.code).toBe(code);
+ expect(error?.status).toBe(status);
+}
+
+describe("ProposalEnactmentService.queue", () => {
+ it("broadcasts queue() for a succeeded proposal", async () => {
+ const { service, sent, simulated } = createService({
+ governor: { state: async () => ProposalState.Succeeded },
+ });
+
+ const result = await service.queue(PROPOSAL_ID.toString());
+
+ expect(result).toEqual({ txHash: TX_HASH });
+ expect(sent).toEqual([
+ {
+ to: GOVERNOR,
+ data: encodeFunctionData({
+ abi: governorAbi,
+ functionName: "queue",
+ args: [ARGS.targets, ARGS.values, ARGS.calldatas, DESCRIPTION_HASH],
+ }),
+ },
+ ]);
+ expect(simulated).toEqual([{ functionName: "queue" }]);
+ });
+
+ it("rejects when the proposal is not in Succeeded state", async () => {
+ const { service, sent } = createService({
+ governor: { state: async () => ProposalState.Active },
+ });
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "INVALID_PROPOSAL_STATE",
+ 409,
+ );
+ expect(sent).toEqual([]);
+ });
+});
+
+describe("ProposalEnactmentService.execute", () => {
+ it("broadcasts execute() for a queued proposal past its eta", async () => {
+ const { service, sent } = createService({
+ governor: {
+ state: async () => ProposalState.Queued,
+ proposalEta: async () => NOW - 1n,
+ },
+ });
+
+ const result = await service.execute(PROPOSAL_ID.toString());
+
+ expect(result).toEqual({ txHash: TX_HASH });
+ expect(sent).toEqual([
+ {
+ to: GOVERNOR,
+ data: encodeFunctionData({
+ abi: governorAbi,
+ functionName: "execute",
+ args: [ARGS.targets, ARGS.values, ARGS.calldatas, DESCRIPTION_HASH],
+ }),
+ },
+ ]);
+ });
+
+ it("executes exactly at the eta", async () => {
+ const { service } = createService({
+ governor: {
+ state: async () => ProposalState.Queued,
+ proposalEta: async () => NOW,
+ },
+ });
+
+ await expect(service.execute(PROPOSAL_ID.toString())).resolves.toEqual({
+ txHash: TX_HASH,
+ });
+ });
+
+ it("rejects before the timelock eta", async () => {
+ const { service, sent } = createService({
+ governor: {
+ state: async () => ProposalState.Queued,
+ proposalEta: async () => NOW + 1n,
+ },
+ });
+
+ await expectRelayError(
+ service.execute(PROPOSAL_ID.toString()),
+ "TIMELOCK_NOT_READY",
+ 409,
+ );
+ expect(sent).toEqual([]);
+ });
+
+ it("rejects when the proposal is not in Queued state", async () => {
+ const { service, sent } = createService({
+ governor: { state: async () => ProposalState.Succeeded },
+ });
+
+ await expectRelayError(
+ service.execute(PROPOSAL_ID.toString()),
+ "INVALID_PROPOSAL_STATE",
+ 409,
+ );
+ expect(sent).toEqual([]);
+ });
+});
+
+describe("ProposalEnactmentService guards", () => {
+ it("rejects proposals the governor does not know with 404, before touching the API", async () => {
+ const { source, fetched } = createStubSource();
+ const { service } = createService({
+ governor: { state: async () => null },
+ source,
+ });
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "PROPOSAL_NOT_FOUND",
+ 404,
+ );
+ expect(fetched).toEqual([]);
+ });
+
+ it("checks the on-chain state before fetching from the API", async () => {
+ const { source, fetched } = createStubSource();
+ const { service } = createService({
+ governor: { state: async () => ProposalState.Defeated },
+ source,
+ });
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "INVALID_PROPOSAL_STATE",
+ 409,
+ );
+ expect(fetched).toEqual([]);
+ });
+
+ it("rejects proposals the API does not know with 404", async () => {
+ const { service } = createService({
+ source: createStubSource({}).source,
+ });
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "PROPOSAL_NOT_FOUND",
+ 404,
+ );
+ });
+
+ it("rejects when API data does not hash to the requested proposal id", async () => {
+ const { service, sent } = createService({
+ governor: { hashProposal: async () => PROPOSAL_ID + 1n },
+ });
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "PROPOSAL_DATA_MISMATCH",
+ 422,
+ );
+ expect(sent).toEqual([]);
+ });
+
+ it("hashes a description that looks like hex as UTF-8 text", async () => {
+ const hexLikeDescription = "0xdeadbeef";
+ const { service, sent } = createService({
+ source: createStubSource({
+ [PROPOSAL_ID.toString()]: { ...ARGS, description: hexLikeDescription },
+ }).source,
+ });
+
+ await service.queue(PROPOSAL_ID.toString());
+
+ expect(sent[0]?.data).toBe(
+ encodeFunctionData({
+ abi: governorAbi,
+ functionName: "queue",
+ args: [
+ ARGS.targets,
+ ARGS.values,
+ ARGS.calldatas,
+ keccak256(stringToBytes(hexLikeDescription)),
+ ],
+ }),
+ );
+ });
+
+ it("maps insufficient-funds send failures to RELAYER_LOW_BALANCE", async () => {
+ const { governor } = createStubGovernor();
+ const signer: RelayerSigner = {
+ getAddress: async () => RELAYER,
+ sendTransaction: async () => {
+ throw new InsufficientFundsError();
+ },
+ };
+ const service = new ProposalEnactmentService(
+ governor,
+ signer,
+ createStubSource().source,
+ { minBalanceWei: parseEther("0.1").valueOf() },
+ silentLogger,
+ );
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "RELAYER_LOW_BALANCE",
+ 503,
+ );
+ });
+
+ it("rejects when the relayer balance is below the minimum", async () => {
+ const { service, sent } = createService({
+ governor: { ethBalance: async () => parseEther("0.01") },
+ });
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "RELAYER_LOW_BALANCE",
+ 503,
+ );
+ expect(sent).toEqual([]);
+ });
+});
+
+describe("ProposalEnactmentService broadcast outcomes", () => {
+ it("maps simulation reverts to SIMULATION_FAILED without broadcasting", async () => {
+ const { service, sent } = createService({
+ governor: {
+ simulate: async () => {
+ throw new SimulationRevertError(
+ "TimelockController: insufficient balance",
+ );
+ },
+ },
+ });
+
+ const error = await service.queue(PROPOSAL_ID.toString()).then(
+ () => null,
+ (e) => e as RelayError,
+ );
+
+ expect(error).toBeInstanceOf(RelayError);
+ expect(error?.code).toBe("SIMULATION_FAILED");
+ expect(error?.status).toBe(409);
+ expect(error?.message).toContain(
+ "TimelockController: insufficient balance",
+ );
+ expect(sent).toEqual([]);
+ });
+
+ it("propagates non-revert simulation failures untouched", async () => {
+ const { service, sent } = createService({
+ governor: {
+ simulate: async () => {
+ throw new Error("rpc unreachable");
+ },
+ },
+ });
+
+ await expect(service.queue(PROPOSAL_ID.toString())).rejects.toThrow(
+ /rpc unreachable/,
+ );
+ expect(sent).toEqual([]);
+ });
+
+ it("rejects with TRANSACTION_REVERTED when the mined transaction reverted", async () => {
+ const { service, sent } = createService({
+ governor: { waitForReceipt: async () => "reverted" },
+ });
+
+ await expectRelayError(
+ service.queue(PROPOSAL_ID.toString()),
+ "TRANSACTION_REVERTED",
+ 409,
+ );
+ expect(sent).toHaveLength(1);
+ });
+
+ it("still returns the hash when the receipt wait times out", async () => {
+ const { service } = createService({
+ governor: { waitForReceipt: async () => "timeout" },
+ });
+
+ await expect(service.queue(PROPOSAL_ID.toString())).resolves.toEqual({
+ txHash: TX_HASH,
+ });
+ });
+});
+
+describe("ProposalEnactmentService in-flight dedup", () => {
+ it("joins concurrent duplicate requests into a single broadcast", async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const { service, sent } = createService({
+ governor: {
+ simulate: async () => {
+ await gate;
+ },
+ },
+ });
+
+ const first = service.queue(PROPOSAL_ID.toString());
+ const second = service.queue(PROPOSAL_ID.toString());
+ release();
+
+ await expect(first).resolves.toEqual({ txHash: TX_HASH });
+ await expect(second).resolves.toEqual({ txHash: TX_HASH });
+ expect(sent).toHaveLength(1);
+ });
+
+ it("releases the in-flight lock once the request settles", async () => {
+ const { service, sent } = createService();
+
+ await service.queue(PROPOSAL_ID.toString());
+ await service.queue(PROPOSAL_ID.toString());
+
+ expect(sent).toHaveLength(2);
+ });
+
+ it("does not join a queue request with an execute request", async () => {
+ const { service } = createService({
+ governor: { state: async () => ProposalState.Succeeded },
+ });
+
+ const queued = service.queue(PROPOSAL_ID.toString());
+ const executed = service.execute(PROPOSAL_ID.toString());
+
+ await expect(queued).resolves.toEqual({ txHash: TX_HASH });
+ await expectRelayError(executed, "INVALID_PROPOSAL_STATE", 409);
+ });
+});
diff --git a/apps/relayer/src/services/proposals/proposal-enactment.ts b/apps/relayer/src/services/proposals/proposal-enactment.ts
new file mode 100644
index 0000000000..7e7f035ac7
--- /dev/null
+++ b/apps/relayer/src/services/proposals/proposal-enactment.ts
@@ -0,0 +1,200 @@
+import {
+ BaseError,
+ Hash,
+ InsufficientFundsError,
+ encodeFunctionData,
+ keccak256,
+ stringToBytes,
+} from "viem";
+
+import { createLogger, type Logger } from "@anticapture/observability";
+
+import { governorAbi, ProposalState } from "@/abi/governor";
+import { Errors } from "@/errors";
+import {
+ SimulationRevertError,
+ type EnactmentCall,
+ type GovernorGateway,
+} from "@/services/chain/governor-gateway";
+import { RelayerSigner } from "@/signer/types";
+
+import type { ProposalSource } from "./proposal-source";
+
+export interface ProposalEnactmentConfig {
+ /** Below this relayer balance the service refuses to broadcast. */
+ minBalanceWei: bigint;
+}
+
+type EnactmentAction = "queue" | "execute";
+
+const REQUIRED_STATE: Record = {
+ queue: ProposalState.Succeeded,
+ execute: ProposalState.Queued,
+};
+
+/**
+ * Sponsors the permissionless Governor lifecycle transactions nobody else
+ * pays for: queue() once a proposal succeeds and execute() once its
+ * timelock eta passes. Proposal args come from an untrusted source and are
+ * verified against the governor's hashProposal before anything is signed.
+ */
+export class ProposalEnactmentService {
+ // Between sendTransaction and inclusion the chain still reports the
+ // proposal as actionable, so concurrent duplicates would each pass every
+ // guard and burn relayer gas on reverts. Identical requests join the
+ // in-flight one instead and share its result.
+ private inflight = new Map>();
+
+ constructor(
+ private governor: GovernorGateway,
+ private signer: RelayerSigner,
+ private source: ProposalSource,
+ private config: ProposalEnactmentConfig,
+ private logger: Logger = createLogger("relayer-proposal-enactment"),
+ ) {}
+
+ async queue(proposalId: string): Promise<{ txHash: Hash }> {
+ return this.enact("queue", proposalId);
+ }
+
+ async execute(proposalId: string): Promise<{ txHash: Hash }> {
+ return this.enact("execute", proposalId);
+ }
+
+ private enact(
+ action: EnactmentAction,
+ proposalId: string,
+ ): Promise<{ txHash: Hash }> {
+ const key = `${action}:${proposalId}`;
+ const pending = this.inflight.get(key);
+ if (pending) return pending;
+
+ const run = this.runEnactment(action, proposalId).finally(() => {
+ this.inflight.delete(key);
+ });
+ this.inflight.set(key, run);
+ return run;
+ }
+
+ private async runEnactment(
+ action: EnactmentAction,
+ proposalId: string,
+ ): Promise<{ txHash: Hash }> {
+ // Cheap on-chain guards first: spam against unknown or settled proposals
+ // must not amplify into Anticapture API calls.
+ const state = await this.governor.state(BigInt(proposalId));
+ if (state === null) {
+ throw Errors.PROPOSAL_NOT_FOUND(proposalId);
+ }
+ if (state !== REQUIRED_STATE[action]) {
+ throw Errors.INVALID_PROPOSAL_STATE(action, ProposalState[state]);
+ }
+
+ if (action === "execute") {
+ const eta = await this.governor.proposalEta(BigInt(proposalId));
+ if ((await this.governor.blockTimestamp()) < eta) {
+ throw Errors.TIMELOCK_NOT_READY(eta);
+ }
+ }
+
+ const call = await this.loadVerifiedCall(proposalId);
+ return this.broadcast(proposalId, action, call);
+ }
+
+ /**
+ * Fetches the proposal args and proves they belong to the requested
+ * proposal id via the governor's hashProposal. This keeps the API as an
+ * untrusted convenience: wrong or tampered data can never be broadcast.
+ */
+ private async loadVerifiedCall(proposalId: string): Promise {
+ const args = await this.source.getProposal(proposalId);
+ if (!args) {
+ throw Errors.PROPOSAL_NOT_FOUND(proposalId);
+ }
+
+ const call: EnactmentCall = {
+ targets: args.targets,
+ values: args.values,
+ calldatas: args.calldatas,
+ // stringToBytes, not toBytes: a description that happens to be a valid
+ // hex string must still be hashed as the UTF-8 text the proposer signed.
+ descriptionHash: keccak256(stringToBytes(args.description)),
+ };
+
+ if ((await this.governor.hashProposal(call)) !== BigInt(proposalId)) {
+ throw Errors.PROPOSAL_DATA_MISMATCH(proposalId);
+ }
+
+ return call;
+ }
+
+ private async broadcast(
+ proposalId: string,
+ functionName: EnactmentAction,
+ call: EnactmentCall,
+ ): Promise<{ txHash: Hash }> {
+ const relayerAddress = await this.signer.getAddress();
+
+ const balance = await this.governor.ethBalance(relayerAddress);
+ if (balance < this.config.minBalanceWei) {
+ throw Errors.RELAYER_LOW_BALANCE();
+ }
+
+ try {
+ await this.governor.simulate(functionName, call, relayerAddress);
+ } catch (err) {
+ if (err instanceof SimulationRevertError) {
+ throw Errors.SIMULATION_FAILED(functionName, err.reason);
+ }
+ throw err;
+ }
+
+ const txHash = await this.sendTransaction({
+ to: this.governor.address,
+ data: encodeFunctionData({
+ abi: governorAbi,
+ functionName,
+ args: [call.targets, call.values, call.calldatas, call.descriptionHash],
+ }),
+ });
+
+ const outcome = await this.governor.waitForReceipt(txHash);
+ if (outcome === "reverted") {
+ throw Errors.TRANSACTION_REVERTED(txHash);
+ }
+ if (outcome === "timeout") {
+ this.logger.warn(
+ { proposalId, action: functionName, txHash },
+ "receipt wait timed out; transaction was broadcast",
+ );
+ }
+
+ this.logger.info(
+ { proposalId, action: functionName, txHash },
+ `proposal ${functionName} broadcast`,
+ );
+ return { txHash };
+ }
+
+ /**
+ * Wraps the signer so viem's InsufficientFundsError surfaces as a clean
+ * 503 RELAYER_LOW_BALANCE instead of bubbling up as a 500 — the
+ * minBalanceWei guard cannot see the actual gas estimate.
+ */
+ private async sendTransaction(tx: {
+ to: `0x${string}`;
+ data: `0x${string}`;
+ }): Promise {
+ try {
+ return await this.signer.sendTransaction(tx);
+ } catch (err) {
+ if (
+ err instanceof BaseError &&
+ err.walk((e) => e instanceof InsufficientFundsError)
+ ) {
+ throw Errors.RELAYER_LOW_BALANCE();
+ }
+ throw err;
+ }
+ }
+}
diff --git a/apps/relayer/src/services/proposals/proposal-source.test.ts b/apps/relayer/src/services/proposals/proposal-source.test.ts
new file mode 100644
index 0000000000..328bb4ad0e
--- /dev/null
+++ b/apps/relayer/src/services/proposals/proposal-source.test.ts
@@ -0,0 +1,108 @@
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
+
+import { AnticaptureProposalSource } from "./proposal-source";
+
+const BASE_URL = "https://gateful.test";
+const DAO = "ens";
+const API_KEY = "test-anticapture-api-key";
+const PROPOSAL_ID =
+ "31309365093913580207991288430108338667724061355449265288906484597789511363394";
+
+const FULL_PROPOSAL_RESPONSE = {
+ id: PROPOSAL_ID,
+ status: "SUCCEEDED",
+ variant: "full",
+ description: "# Fund the thing\nSend it.",
+ targets: ["0x0000000000000000000000000000000000000001"],
+ values: ["1000000000000000000"],
+ calldatas: ["0xdeadbeef"],
+};
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+function respondWith(status: number, body: unknown) {
+ const captured: { authorization: string | null } = { authorization: null };
+ server.use(
+ http.get(`${BASE_URL}/${DAO}/proposals/${PROPOSAL_ID}`, ({ request }) => {
+ captured.authorization = request.headers.get("authorization");
+ return HttpResponse.json(body as Record, { status });
+ }),
+ );
+ return captured;
+}
+
+function createSource(baseUrl = BASE_URL) {
+ return new AnticaptureProposalSource(baseUrl, DAO, API_KEY);
+}
+
+describe("AnticaptureProposalSource", () => {
+ it("fetches a proposal through the gateway and returns its execution args", async () => {
+ const captured = respondWith(200, FULL_PROPOSAL_RESPONSE);
+
+ const proposal = await createSource().getProposal(PROPOSAL_ID);
+
+ expect(captured.authorization).toBe(`Bearer ${API_KEY}`);
+ expect(proposal).toEqual({
+ targets: ["0x0000000000000000000000000000000000000001"],
+ values: [1000000000000000000n],
+ calldatas: ["0xdeadbeef"],
+ description: "# Fund the thing\nSend it.",
+ });
+ });
+
+ it("rejects DAOs outside the API contract at construction", () => {
+ expect(
+ () => new AnticaptureProposalSource(BASE_URL, "NOT_A_DAO", API_KEY),
+ ).toThrow(/not part of the Anticapture API contract/i);
+ });
+
+ it("lowercases the DAO id to match the gateway's typed contract", async () => {
+ // onUnhandledRequest: "error" makes this strict — an ENS-uppercase path
+ // would not match the handler and fail the request.
+ respondWith(200, FULL_PROPOSAL_RESPONSE);
+ const source = new AnticaptureProposalSource(BASE_URL, "ENS", API_KEY);
+
+ expect(await source.getProposal(PROPOSAL_ID)).not.toBeNull();
+ });
+
+ it("tolerates a trailing slash in the base URL", async () => {
+ respondWith(200, FULL_PROPOSAL_RESPONSE);
+
+ const proposal = await createSource(`${BASE_URL}/`).getProposal(
+ PROPOSAL_ID,
+ );
+
+ expect(proposal).not.toBeNull();
+ });
+
+ it("returns null when the gateway responds 404", async () => {
+ respondWith(404, { error: "Proposal not found" });
+
+ expect(await createSource().getProposal(PROPOSAL_ID)).toBeNull();
+ });
+
+ it("throws on other non-OK responses", async () => {
+ respondWith(500, { error: "boom" });
+
+ await expect(createSource().getProposal(PROPOSAL_ID)).rejects.toThrow(
+ /proposal fetch failed .* with status 500/i,
+ );
+ });
+
+ it("throws when the response is missing execution args (lean variant)", async () => {
+ respondWith(200, {
+ id: PROPOSAL_ID,
+ variant: "lean",
+ status: "SUCCEEDED",
+ });
+
+ await expect(createSource().getProposal(PROPOSAL_ID)).rejects.toThrow(
+ /execution args/i,
+ );
+ });
+});
diff --git a/apps/relayer/src/services/proposals/proposal-source.ts b/apps/relayer/src/services/proposals/proposal-source.ts
new file mode 100644
index 0000000000..bd3cba8fa8
--- /dev/null
+++ b/apps/relayer/src/services/proposals/proposal-source.ts
@@ -0,0 +1,80 @@
+import { Address, Hex } from "viem";
+import {
+ proposal,
+ proposalPathParamsDaoEnum,
+ type ProposalPathParams,
+ type ResponseErrorConfig,
+} from "@anticapture/client";
+
+/** Execution payload of a Governor proposal, as passed to queue()/execute(). */
+export interface ProposalArgs {
+ targets: Address[];
+ values: bigint[];
+ calldatas: Hex[];
+ description: string;
+}
+
+export interface ProposalSource {
+ /** Resolves to null when the proposal is unknown. */
+ getProposal(proposalId: string): Promise;
+}
+
+/** Gateful's typed contract is the lowercase DAO id (e.g. "ens"). */
+function isSupportedDao(dao: string): dao is ProposalPathParams["dao"] {
+ return Object.hasOwn(proposalPathParamsDaoEnum, dao);
+}
+
+/**
+ * Reads proposal execution args from the Anticapture API through the Gateful
+ * gateway (GET /{dao}/proposals/{id}, @anticapture/client SDK). The data is
+ * treated as untrusted: callers must verify it against the governor's
+ * hashProposal before broadcasting.
+ */
+export class AnticaptureProposalSource implements ProposalSource {
+ private dao: ProposalPathParams["dao"];
+
+ constructor(
+ private baseUrl: string,
+ daoId: string,
+ private apiKey: string,
+ ) {
+ const dao = daoId.toLowerCase();
+ if (!isSupportedDao(dao)) {
+ throw new Error(
+ `DAO "${daoId}" is not part of the Anticapture API contract`,
+ );
+ }
+ this.dao = dao;
+ }
+
+ async getProposal(proposalId: string): Promise {
+ let response;
+ try {
+ response = await proposal(this.dao, proposalId, undefined, {
+ baseURL: this.baseUrl,
+ headers: { Authorization: `Bearer ${this.apiKey}` },
+ });
+ } catch (err) {
+ const status = (err as ResponseErrorConfig).status;
+ if (status === 404) return null;
+ throw new Error(
+ `Anticapture proposal fetch failed for proposal ${proposalId}` +
+ (typeof status === "number" ? ` with status ${status}` : ""),
+ { cause: err },
+ );
+ }
+
+ if (response.variant !== "full") {
+ throw new Error(
+ `Anticapture proposal response is missing execution args for proposal ${proposalId}`,
+ );
+ }
+
+ return {
+ targets: [...response.targets],
+ values: response.values.map((v) => BigInt(String(v))),
+ calldatas: response.calldatas as Hex[],
+ description: response.description,
+ };
+ }
+}
diff --git a/infra/relayer/Dockerfile b/infra/relayer/Dockerfile
index 6a5f53bfb8..d0539f1f10 100644
--- a/infra/relayer/Dockerfile
+++ b/infra/relayer/Dockerfile
@@ -16,6 +16,16 @@ COPY --from=builder /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=builder /app/out/full/ .
COPY --from=builder /app/turbo.json turbo.json
+# Codegen fetches the Gateful OpenAPI spec (there is no committed spec) and
+# must use a PUBLIC gateful domain: Railway builders run outside the private
+# network, so the runtime ANTICAPTURE_API_URL (a railway.internal host) is
+# unreachable here. Deliberately NOT the same environment's gateful either
+# (as infra/mcp-server interpolates): gateful's health gate requires this
+# relayer to be up before its own /docs/json is served, so fresh PR previews
+# would deadlock. Override via an ANTICAPTURE_SPEC_URL service variable.
+# See packages/anticapture-client/src/gateful-openapi-spec.ts.
+ARG ANTICAPTURE_SPEC_URL=https://dev-gateful.up.railway.app
+RUN ANTICAPTURE_API_URL=$ANTICAPTURE_SPEC_URL pnpm --filter @anticapture/client codegen
RUN pnpm --filter @anticapture/relayer... build
FROM base AS runner
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80621a30e0..42b4a30ff1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -734,6 +734,9 @@ importers:
apps/relayer:
dependencies:
+ "@anticapture/client":
+ specifier: workspace:*
+ version: link:../../packages/anticapture-client
"@anticapture/observability":
specifier: workspace:*
version: link:../../packages/observability