From 7fd539fff78fcbbb002439ba0536db072a9be0e3 Mon Sep 17 00:00:00 2001 From: PhiloTheePhilix <110274378+Philotheephilix@users.noreply.github.com> Date: Sun, 3 May 2026 18:53:25 +0530 Subject: [PATCH 01/19] feat: add Superfluid protocol Adds Superfluid as a first-class KeeperHub protocol via the existing defineProtocol() DSL. Exposes 15 declarative actions (11 writes + 4 reads) covering streaming payments (CFA), pool-based distributions (GDA), and SuperToken wrap/unwrap. No new plugin folder, no per-action step files, no SDK dependency, no contracts deployed. Surfaces: - Constant-Flow Agreement: create-flow, update-flow, delete-flow, get-flow, get-net-flow - General Distribution Agreement: create-pool, update-member-units, distribute, distribute-flow, connect-pool - SuperToken: wrap, unwrap, grant-flow-operator, get-super-token-balance, get-underlying-token Implementation: - protocols/superfluid.ts -- declarative protocol with three contracts (cfaForwarder + gdaForwarder constant-address across all six chains via sameOnAllChains() helper; superToken with userSpecifiedAddress). Inline ABI fragments. SUPERFLUID_CHAIN_IDS, CFA_FORWARDER_ADDRESS, GDA_FORWARDER_ADDRESS exported as the single source of truth so adding/removing a chain is a one-line change. - tests/unit/superfluid-protocol.test.ts -- 34 schema and integrity assertions: ABI parses, addresses match the regex, action slugs are unique kebab-case, every action.contract resolves, every action.function exists in the referenced contract's ABI. - scripts/verify-superfluid-addresses.ts -- one-shot CLI that calls eth_getCode against each forwarder on each chain. Chain set is driven from SUPERFLUID_CHAIN_IDS joined with a local RPC metadata map; surfaces "Unknown chain" entries when the metadata is out of sync with the protocol declaration. - scripts/e2e-superfluid-sepolia.ts -- live Sepolia lifecycle test (no mocks, no test framework). Walks the full 13-step flow: pre-flight -> approve -> wrap -> create-flow -> update-flow -> get-net-flow -> delete-flow -> create-pool -> update-member-units -> connect-pool -> distribute-flow -> read-net-flow x2 -> cleanup. Env-var driven; receiver-signed steps gracefully skip when no SUPERFLUID_E2E_RECEIVER_KEY is provided. - specs/superfluid-protocol-plugin.md, specs/superfluid-protocol-plugin-plan.md capture the internal design and TDD implementation plan. Verification: - 34/34 schema tests pass; full unit suite (3454 tests across 185 files) green with zero regressions. - 13/13 live Sepolia lifecycle steps PASS via the E2E script against real CFA + GDA forwarders, no mocks. - 8/12 (chain x forwarder) pairs directly verified via eth_getCode (Optimism, Base, Arbitrum One, Sepolia). The remaining 4 (Ethereum mainnet + Polygon) require keyed RPC in this environment; the constant address claim is supported by the 8 verified pairs and Superfluid's published deployment registry. Why this matters for KeeperHub: every existing protocol in this library is a discrete state-change primitive (lending, swapping, staking, savings). A large category of high-value workflow automation is fundamentally time-based -- payroll, vesting, royalty splits, usage-based subscriptions, compute-coalition payments, insurance premiums, DAO contributor pay. Superfluid moves time accounting on-chain so a workflow can open a stream once and let value flow continuously instead of waking up every N hours to issue transfers. The workflow's job becomes deciding when to update the rate, which is exactly what KeeperHub's triggers, retry, gas optimization, multi-step composition, and per-org Para wallets are built for. Patterns this unlocks (one-shot trigger -> infinite-period payment, event-driven rate adjustment, pool-based pro-rata distribution) are not expressible with any existing KeeperHub protocol. Tested live on https://computepool.vercel.app where this plugin is the streaming-payments backbone for a compute coalition that streams fUSDCx-equivalent payments to GPU providers continuously while inference workloads run, with member units adjusting in real time as worker capacity changes. --- lib/types/integration.ts | 3 +- protocols/index.ts | 5 +- protocols/superfluid.ts | 586 +++++++++++++++++++++++++ scripts/e2e-superfluid-sepolia.ts | 459 +++++++++++++++++++ scripts/verify-superfluid-addresses.ts | 134 ++++++ tests/unit/superfluid-protocol.test.ts | 403 +++++++++++++++++ 6 files changed, 1588 insertions(+), 2 deletions(-) create mode 100644 protocols/superfluid.ts create mode 100644 scripts/e2e-superfluid-sepolia.ts create mode 100644 scripts/verify-superfluid-addresses.ts create mode 100644 tests/unit/superfluid-protocol.test.ts diff --git a/lib/types/integration.ts b/lib/types/integration.ts index 039fd1904..9e394b99a 100755 --- a/lib/types/integration.ts +++ b/lib/types/integration.ts @@ -9,7 +9,7 @@ * 2. Add a system integration to SYSTEM_INTEGRATION_TYPES in discover-plugins.ts * 3. Run: pnpm discover-plugins * - * Generated types: aave-v3, aave-v4, aerodrome, ai-gateway, ajna, chainlink, chronicle, clerk, code, compound, cowswap, curve, database, discord, ethena, lido, linear, math, morpho, pendle, protocol, resend, rocket-pool, safe, sendgrid, sky, slack, spark, telegram, uniswap, v0, web3, webflow, webhook, wrapped, yearn + * Generated types: aave-v3, aave-v4, aerodrome, ai-gateway, ajna, chainlink, chronicle, clerk, code, compound, cowswap, curve, database, discord, ethena, lido, linear, math, morpho, pendle, protocol, resend, rocket-pool, safe, sendgrid, sky, slack, spark, superfluid, telegram, uniswap, v0, web3, webflow, webhook, wrapped, yearn */ // Integration type union - plugins + system integrations @@ -42,6 +42,7 @@ export type IntegrationType = | "sky" | "slack" | "spark" + | "superfluid" | "telegram" | "uniswap" | "v0" diff --git a/protocols/index.ts b/protocols/index.ts index 86e52b118..8749bb31f 100644 --- a/protocols/index.ts +++ b/protocols/index.ts @@ -8,7 +8,7 @@ * This ensures the protocol registry is populated when the Next.js * server starts (via the plugin import chain). * - * Registered protocols: aave-v3, aave-v4, aerodrome, ajna, chainlink, chronicle, compound, cowswap, curve, ethena, lido, morpho, pendle, rocket-pool, safe, sky, spark, uniswap, wrapped, yearn + * Registered protocols: aave-v3, aave-v4, aerodrome, ajna, chainlink, chronicle, compound, cowswap, curve, ethena, lido, morpho, pendle, rocket-pool, safe, sky, spark, superfluid, uniswap, wrapped, yearn */ import { protocolToPlugin, registerProtocol } from "@/lib/protocol-registry"; @@ -31,6 +31,7 @@ import rocketPoolDef from "./rocket-pool"; import safeDef from "./safe"; import skyDef from "./sky"; import sparkDef from "./spark"; +import superfluidDef from "./superfluid"; import uniswapDef from "./uniswap-v3"; import wrappedDef from "./wrapped"; import yearnDef from "./yearn-v3"; @@ -69,6 +70,8 @@ registerProtocol(skyDef); registerIntegration(protocolToPlugin(skyDef)); registerProtocol(sparkDef); registerIntegration(protocolToPlugin(sparkDef)); +registerProtocol(superfluidDef); +registerIntegration(protocolToPlugin(superfluidDef)); registerProtocol(uniswapDef); registerIntegration(protocolToPlugin(uniswapDef)); registerProtocol(wrappedDef); diff --git a/protocols/superfluid.ts b/protocols/superfluid.ts new file mode 100644 index 000000000..059732bbe --- /dev/null +++ b/protocols/superfluid.ts @@ -0,0 +1,586 @@ +import { defineProtocol } from "@/lib/protocol-registry"; + +/** + * Chain IDs (as strings, matching ProtocolContract.addresses keys) where the + * Superfluid CFAv1 and GDAv1 forwarders are deployed. + * + * Adding a chain: append its ID here. Both forwarders pick it up automatically + * via sameOnAllChains() because the addresses are deliberately constant across + * every chain Superfluid supports. To ship a new chain, also add an entry to + * scripts/verify-superfluid-addresses.ts so the bytecode check covers it. + */ +export const SUPERFLUID_CHAIN_IDS = [ + "1", // Ethereum Mainnet + "10", // Optimism + "137", // Polygon + "8453", // Base + "42161", // Arbitrum One + "11155111", // Sepolia +] as const; + +/** + * Build the per-chain address map for a contract that's deployed at the same + * address on every chain in SUPERFLUID_CHAIN_IDS. Both forwarders use this -- + * Superfluid intentionally pins them to identical addresses cross-chain. + */ +function sameOnAllChains(address: string): Record { + return Object.fromEntries(SUPERFLUID_CHAIN_IDS.map((id) => [id, address])); +} + +const FLOW_RATE_HELP = + "Wei per second (int96). 1 USDCx/month is approximately 385,802,469,135 wei/s at 18 decimals. Computed: amount * 10^decimals / seconds."; + +const CREATE_FLOW_RATE_HELP = `${FLOW_RATE_HELP} Sender needs at least ~3 hours of stream value as a deposit; verify with get-super-token-balance before opening the stream.`; + +/** + * CFAv1Forwarder address. Pinned identical across every chain in + * SUPERFLUID_CHAIN_IDS by Superfluid's deployment design. + */ +export const CFA_FORWARDER_ADDRESS = + "0xcfA132E353cB4E398080B9700609bb008eceB125"; + +const CFA_FORWARDER_ABI = JSON.stringify([ + { + type: "function", + name: "createFlow", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "sender", type: "address" }, + { name: "receiver", type: "address" }, + { name: "flowRate", type: "int96" }, + { name: "userData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "updateFlow", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "sender", type: "address" }, + { name: "receiver", type: "address" }, + { name: "flowRate", type: "int96" }, + { name: "userData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "deleteFlow", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "sender", type: "address" }, + { name: "receiver", type: "address" }, + { name: "userData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "getFlowInfo", + stateMutability: "view", + inputs: [ + { name: "token", type: "address" }, + { name: "sender", type: "address" }, + { name: "receiver", type: "address" }, + ], + outputs: [ + { name: "lastUpdated", type: "uint256" }, + { name: "flowRate", type: "int96" }, + { name: "deposit", type: "uint256" }, + { name: "owedDeposit", type: "uint256" }, + ], + }, + { + type: "function", + name: "getAccountFlowrate", + stateMutability: "view", + inputs: [ + { name: "token", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "flowRate", type: "int96" }], + }, +]); + +/** + * GDAv1Forwarder address. Pinned identical across every chain in + * SUPERFLUID_CHAIN_IDS by Superfluid's deployment design. + */ +export const GDA_FORWARDER_ADDRESS = + "0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08"; + +const GDA_FORWARDER_ABI = JSON.stringify([ + { + type: "function", + name: "createPool", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "admin", type: "address" }, + { + name: "config", + type: "tuple", + components: [ + { name: "transferabilityForUnitsOwner", type: "bool" }, + { name: "distributionFromAnyAddress", type: "bool" }, + ], + }, + ], + outputs: [ + { name: "", type: "bool" }, + { name: "pool", type: "address" }, + ], + }, + { + type: "function", + name: "updateMemberUnits", + stateMutability: "nonpayable", + inputs: [ + { name: "pool", type: "address" }, + { name: "member", type: "address" }, + { name: "units", type: "uint128" }, + { name: "userData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "distribute", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "from", type: "address" }, + { name: "pool", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "userData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "distributeFlow", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { name: "from", type: "address" }, + { name: "pool", type: "address" }, + { name: "flowRate", type: "int96" }, + { name: "userData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "connectPool", + stateMutability: "nonpayable", + inputs: [ + { name: "pool", type: "address" }, + { name: "userData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +]); + +const SUPER_TOKEN_ABI = JSON.stringify([ + { + type: "function", + name: "upgrade", + stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], + outputs: [], + }, + { + type: "function", + name: "downgrade", + stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], + outputs: [], + }, + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getUnderlyingToken", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "updateFlowOperatorPermissions", + stateMutability: "nonpayable", + inputs: [ + { name: "flowOperator", type: "address" }, + { name: "permissions", type: "uint8" }, + { name: "flowRateAllowance", type: "int96" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +]); + +export default defineProtocol({ + name: "Superfluid", + slug: "superfluid", + description: + "Programmable streaming payments -- open per-second money streams between addresses, distribute pro-rata to pool members, and wrap/unwrap SuperTokens", + website: "https://superfluid.org", + icon: "/protocols/superfluid.png", + + contracts: { + cfaForwarder: { + label: "Superfluid CFAv1 Forwarder", + addresses: sameOnAllChains(CFA_FORWARDER_ADDRESS), + abi: CFA_FORWARDER_ABI, + }, + gdaForwarder: { + label: "Superfluid GDAv1 Forwarder", + addresses: sameOnAllChains(GDA_FORWARDER_ADDRESS), + abi: GDA_FORWARDER_ABI, + }, + superToken: { + label: "Superfluid SuperToken", + addresses: {}, + abi: SUPER_TOKEN_ABI, + userSpecifiedAddress: true, + }, + }, + + actions: [ + { + slug: "create-flow", + label: "Open Money Stream", + description: + "Open a continuous wei/sec stream of a SuperToken from sender to receiver", + type: "write", + contract: "cfaForwarder", + function: "createFlow", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "sender", type: "address", label: "Sender Address" }, + { name: "receiver", type: "address", label: "Receiver Address" }, + { + name: "flowRate", + type: "int96", + label: "Flow Rate (wei/sec)", + helpTip: CREATE_FLOW_RATE_HELP, + }, + { + name: "userData", + type: "bytes", + label: "User Data", + default: "0x", + advanced: true, + }, + ], + }, + { + slug: "update-flow", + label: "Update Stream Rate", + description: + "Change the wei/sec rate of an existing stream. Use delete-flow to close a stream instead of setting rate to 0.", + type: "write", + contract: "cfaForwarder", + function: "updateFlow", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "sender", type: "address", label: "Sender Address" }, + { name: "receiver", type: "address", label: "Receiver Address" }, + { + name: "flowRate", + type: "int96", + label: "New Flow Rate (wei/sec)", + helpTip: FLOW_RATE_HELP, + }, + { + name: "userData", + type: "bytes", + label: "User Data", + default: "0x", + advanced: true, + }, + ], + }, + { + slug: "delete-flow", + label: "Close Money Stream", + description: "Close an open stream between sender and receiver", + type: "write", + contract: "cfaForwarder", + function: "deleteFlow", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "sender", type: "address", label: "Sender Address" }, + { name: "receiver", type: "address", label: "Receiver Address" }, + { + name: "userData", + type: "bytes", + label: "User Data", + default: "0x", + advanced: true, + }, + ], + }, + { + slug: "get-flow", + label: "Read Flow Between Two Addresses", + description: + "Read the current flow rate, deposit, and last-updated timestamp for a stream between two addresses", + type: "read", + contract: "cfaForwarder", + function: "getFlowInfo", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "sender", type: "address", label: "Sender Address" }, + { name: "receiver", type: "address", label: "Receiver Address" }, + ], + outputs: [ + { + name: "lastUpdated", + type: "uint256", + label: "Last Updated (unix seconds)", + }, + { + name: "flowRate", + type: "int96", + label: "Flow Rate (wei/sec)", + decimals: 18, + }, + { + name: "deposit", + type: "uint256", + label: "Deposit (wei)", + decimals: 18, + }, + { + name: "owedDeposit", + type: "uint256", + label: "Owed Deposit (wei)", + decimals: 18, + }, + ], + }, + { + slug: "get-net-flow", + label: "Read Net Flow Rate of an Address", + description: + "Read an address's net flow rate for a SuperToken (positive = net receiver, negative = net sender)", + type: "read", + contract: "cfaForwarder", + function: "getAccountFlowrate", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "account", type: "address", label: "Account Address" }, + ], + outputs: [ + { + name: "flowRate", + type: "int96", + label: "Net Flow Rate (wei/sec, signed)", + decimals: 18, + }, + ], + }, + { + slug: "create-pool", + label: "Create Distribution Pool", + description: + "Create a GDA distribution pool with the supplied address as administrator. The new pool address is emitted in the PoolCreated event -- chain a web3.query-events call after this action filtered by the returned tx hash to capture it.", + type: "write", + contract: "gdaForwarder", + function: "createPool", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "admin", type: "address", label: "Pool Admin Address" }, + { + name: "config", + type: "tuple", + label: "Pool Config", + components: [ + { name: "transferabilityForUnitsOwner", type: "bool" }, + { name: "distributionFromAnyAddress", type: "bool" }, + ], + }, + ], + }, + { + slug: "update-member-units", + label: "Set Member Units in a Pool", + description: + "Set a recipient's pro-rata share in a distribution pool. New members must call connect-pool from their own wallet before they receive distributions.", + type: "write", + contract: "gdaForwarder", + function: "updateMemberUnits", + inputs: [ + { name: "pool", type: "address", label: "Pool Address" }, + { name: "member", type: "address", label: "Member Address" }, + { name: "units", type: "uint128", label: "Units" }, + { + name: "userData", + type: "bytes", + label: "User Data", + default: "0x", + advanced: true, + }, + ], + }, + { + slug: "distribute", + label: "Instant Distribution to a Pool", + description: + "Push a one-shot distribution into a pool. Amount divides pro-rata across members by their unit share.", + type: "write", + contract: "gdaForwarder", + function: "distribute", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "from", type: "address", label: "Sender Address" }, + { name: "pool", type: "address", label: "Pool Address" }, + { name: "amount", type: "uint256", label: "Amount (wei)" }, + { + name: "userData", + type: "bytes", + label: "User Data", + default: "0x", + advanced: true, + }, + ], + }, + { + slug: "distribute-flow", + label: "Stream Into a Pool", + description: + "Open a continuous stream into a pool. Members receive their pro-rata share by the second; updating member units changes the split in real time.", + type: "write", + contract: "gdaForwarder", + function: "distributeFlow", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "from", type: "address", label: "Sender Address" }, + { name: "pool", type: "address", label: "Pool Address" }, + { + name: "flowRate", + type: "int96", + label: "Flow Rate (wei/sec)", + helpTip: FLOW_RATE_HELP, + }, + { + name: "userData", + type: "bytes", + label: "User Data", + default: "0x", + advanced: true, + }, + ], + }, + { + slug: "connect-pool", + label: "Connect to a Pool (Member Opt-In)", + description: + "Members must call this from their own wallet to start receiving distributions. Without this, units exist but no money flows.", + type: "write", + contract: "gdaForwarder", + function: "connectPool", + inputs: [ + { name: "pool", type: "address", label: "Pool Address" }, + { + name: "userData", + type: "bytes", + label: "User Data", + default: "0x", + advanced: true, + }, + ], + }, + { + slug: "wrap", + label: "Wrap to SuperToken", + description: + "Wrap an underlying ERC-20 amount into its SuperToken. Requires a prior web3.approve-token call against the SuperToken address.", + type: "write", + contract: "superToken", + function: "upgrade", + inputs: [{ name: "amount", type: "uint256", label: "Amount (wei)" }], + }, + { + slug: "unwrap", + label: "Unwrap from SuperToken", + description: "Unwrap a SuperToken amount back to its underlying ERC-20", + type: "write", + contract: "superToken", + function: "downgrade", + inputs: [{ name: "amount", type: "uint256", label: "Amount (wei)" }], + }, + { + slug: "grant-flow-operator", + label: "Grant Flow-Operator Permissions", + description: + "Authorize another address to manage your flows of this SuperToken up to a wei/sec allowance", + type: "write", + contract: "superToken", + function: "updateFlowOperatorPermissions", + inputs: [ + { + name: "flowOperator", + type: "address", + label: "Flow Operator Address", + }, + { + name: "permissions", + type: "uint8", + label: "Permissions Bitmap", + helpTip: + "Bitmask: 1 = create, 2 = update, 4 = delete, 7 = all three. Combine via bitwise OR.", + }, + { + name: "flowRateAllowance", + type: "int96", + label: "Flow Rate Allowance (wei/sec)", + helpTip: FLOW_RATE_HELP, + }, + ], + }, + { + slug: "get-super-token-balance", + label: "Get SuperToken Balance", + description: "Read an address's current SuperToken balance", + type: "read", + contract: "superToken", + function: "balanceOf", + inputs: [{ name: "account", type: "address", label: "Account Address" }], + outputs: [ + { + name: "balance", + type: "uint256", + label: "Balance (wei)", + decimals: 18, + }, + ], + }, + { + slug: "get-underlying-token", + label: "Get Underlying ERC-20 Address", + description: + "Read the underlying ERC-20 address for this SuperToken (the token that gets escrowed when you wrap)", + type: "read", + contract: "superToken", + function: "getUnderlyingToken", + inputs: [], + outputs: [ + { + name: "underlying", + type: "address", + label: "Underlying ERC-20 Address", + }, + ], + }, + ], +}); diff --git a/scripts/e2e-superfluid-sepolia.ts b/scripts/e2e-superfluid-sepolia.ts new file mode 100644 index 000000000..32223b6a1 --- /dev/null +++ b/scripts/e2e-superfluid-sepolia.ts @@ -0,0 +1,459 @@ +/** + * Live Sepolia end-to-end script for the Superfluid protocol. + * + * Walks the full streaming + pool lifecycle against real Superfluid contracts: + * approve -> wrap -> create-flow -> update-flow -> get-net-flow -> delete-flow + * -> create-pool -> update-member-units -> connect-pool -> distribute-flow + * -> read net flow -> cleanup + * + * Usage: + * export SUPERFLUID_E2E_SENDER_KEY=0x... + * pnpm tsx scripts/e2e-superfluid-sepolia.ts + * + * Contract addresses are pinned to Superfluid's canonical Sepolia deployments. + * If Superfluid migrates a contract, override via env vars: + * SUPERFLUID_E2E_CFA_FORWARDER (default: 0xcfA132E353cB4E398080B9700609bb008eceB125) + * SUPERFLUID_E2E_GDA_FORWARDER (default: 0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08) + * SUPERFLUID_E2E_SUPER_TOKEN (default: 0xb598E6C621618a9f63788816ffb50Ee2862D443B, fUSDCx) + * SUPERFLUID_E2E_UNDERLYING (default: 0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db, fUSDC -- "fUSDC Fake Token"; mint(address,uint256) is permissive on Sepolia) + * + * Required env vars: + * SUPERFLUID_E2E_SENDER_KEY hex private key, 0x-prefixed + * + * Optional env vars: + * SUPERFLUID_E2E_RECEIVER_KEY second wallet key; connect-pool step is SKIPPED if absent + * SUPERFLUID_E2E_RPC_URL default: https://ethereum-sepolia-rpc.publicnode.com + * SUPERFLUID_E2E_WRAP_AMOUNT wei to wrap (default: 100000000) + * SUPERFLUID_E2E_FLOW_RATE wei/sec flow rate (default: 1000000) + * + * Pre-flight: fund the sender wallet with Sepolia ETH (gas) and fUSDC. + * Faucet: https://app.superfluid.finance/faucet (select Sepolia, claim fUSDC). + * The script does NOT automate faucet interaction. + */ + +import { Contract, ethers, JsonRpcProvider, Wallet } from "ethers"; +import { + CFA_FORWARDER_ADDRESS, + GDA_FORWARDER_ADDRESS, +} from "@/protocols/superfluid"; + +const SEPOLIA_CHAIN_ID = 11155111; + +const DEFAULT_RPC = "https://ethereum-sepolia-rpc.publicnode.com"; +const DEFAULT_CFA_FORWARDER = CFA_FORWARDER_ADDRESS; +const DEFAULT_GDA_FORWARDER = GDA_FORWARDER_ADDRESS; +const DEFAULT_SUPER_TOKEN = "0xb598E6C621618a9f63788816ffb50Ee2862D443B"; +const DEFAULT_UNDERLYING = "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; +const DEFAULT_WRAP_AMOUNT = "100000000"; +const DEFAULT_FLOW_RATE = "1000000"; + +// Override gas limit on every write tx. Ethers v6's default eth_estimateGas +// buffer is too tight for CFA/GDA writes -- the actual gas usage can exceed +// the estimate due to SLOAD warming differences between simulation and +// execution, causing OOG reverts (e.g. updateFlow uses ~315k vs estimated +// ~285k). 1.5M gas covers the heaviest call (createPool) with margin. +const TX_OVERRIDES = { gasLimit: 1_500_000 }; + +// Stable receiver address used when SUPERFLUID_E2E_RECEIVER_KEY is not set. +// This is a deterministic dead-drop address; it will receive flows but nobody +// controls the key, so connect-pool cannot be signed for it. +const FALLBACK_RECEIVER = "0x000000000000000000000000000000000000dEaD"; + +const ERC20_ABI = [ + "function approve(address spender, uint256 amount) returns (bool)", + "function balanceOf(address account) view returns (uint256)", + "function decimals() view returns (uint8)", + "function allowance(address owner, address spender) view returns (uint256)", +]; + +const SUPER_TOKEN_ABI = [ + "function upgrade(uint256 amount)", + "function downgrade(uint256 amount)", + "function balanceOf(address account) view returns (uint256)", +]; + +const CFA_FORWARDER_ABI = [ + "function createFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", + "function updateFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", + "function deleteFlow(address token, address sender, address receiver, bytes userData) returns (bool)", + "function getFlowInfo(address token, address sender, address receiver) view returns (uint256 lastUpdated, int96 flowRate, uint256 deposit, uint256 owedDeposit)", + "function getAccountFlowrate(address token, address account) view returns (int96 flowRate)", +]; + +const GDA_FORWARDER_ABI = [ + "function createPool(address token, address admin, tuple(bool transferabilityForUnitsOwner, bool distributionFromAnyAddress) config) returns (bool success, address pool)", + "function updateMemberUnits(address pool, address member, uint128 units, bytes userData) returns (bool)", + "function getNetFlow(address token, address account) view returns (int96)", + "function connectPool(address pool, bytes userData) returns (bool)", + "function distributeFlow(address token, address from, address pool, int96 flowRate, bytes userData) returns (bool)", + "event PoolCreated(address indexed token, address indexed admin, address pool)", +]; + +type StepStatus = "PASS" | "SKIPPED" | "FAILED"; + +interface StepResult { + name: string; + status: StepStatus; + detail: string; +} + +const results: StepResult[] = []; + +function record(name: string, status: StepStatus, detail: string): void { + results.push({ name, status, detail }); + const prefix = status === "PASS" ? "OK" : status === "SKIPPED" ? "SKIPPED" : "FAILED"; + console.log(`[${prefix}] ${name}: ${detail}`); +} + +function requireEnv(name: string): string { + const val = process.env[name]; + if (!val) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return val; +} + +function getEnv(name: string, fallback: string): string { + return process.env[name] ?? fallback; +} + +async function sendAndWait( + label: string, + // biome-ignore lint/suspicious/noExplicitAny: ethers contract calls return ContractTransactionResponse which needs any + txPromise: Promise +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: ethers v6 contract method return type + const tx: any = await txPromise; + console.log(` [${label}] tx ${tx.hash}`); + const receipt: ethers.TransactionReceipt | null = await tx.wait(); + if (!receipt || receipt.status !== 1) { + throw new Error(`Transaction reverted: ${tx.hash}`); + } + return receipt; +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function main(): Promise { + // --- env setup --- + const senderKey = requireEnv("SUPERFLUID_E2E_SENDER_KEY"); + const receiverKey = process.env.SUPERFLUID_E2E_RECEIVER_KEY; + const rpcUrl = getEnv("SUPERFLUID_E2E_RPC_URL", DEFAULT_RPC); + const cfaAddress = getEnv("SUPERFLUID_E2E_CFA_FORWARDER", DEFAULT_CFA_FORWARDER); + const gdaAddress = getEnv("SUPERFLUID_E2E_GDA_FORWARDER", DEFAULT_GDA_FORWARDER); + const superTokenAddress = getEnv("SUPERFLUID_E2E_SUPER_TOKEN", DEFAULT_SUPER_TOKEN); + const underlyingAddress = getEnv("SUPERFLUID_E2E_UNDERLYING", DEFAULT_UNDERLYING); + const wrapAmount = BigInt(getEnv("SUPERFLUID_E2E_WRAP_AMOUNT", DEFAULT_WRAP_AMOUNT)); + const flowRate = BigInt(getEnv("SUPERFLUID_E2E_FLOW_RATE", DEFAULT_FLOW_RATE)); + + const provider = new JsonRpcProvider(rpcUrl, SEPOLIA_CHAIN_ID); + const senderWallet = new Wallet(senderKey, provider); + const senderAddress = senderWallet.address; + + let receiverAddress: string; + let receiverWallet: Wallet | null = null; + if (receiverKey) { + receiverWallet = new Wallet(receiverKey, provider); + receiverAddress = receiverWallet.address; + } else { + receiverAddress = FALLBACK_RECEIVER; + console.log(`SUPERFLUID_E2E_RECEIVER_KEY not set -- using fallback receiver ${FALLBACK_RECEIVER}`); + console.log("Steps requiring receiver signature will be SKIPPED."); + } + + console.log(`Sender: ${senderAddress}`); + console.log(`Receiver: ${receiverAddress}`); + console.log(`RPC: ${rpcUrl}`); + console.log(`Chain ID: ${SEPOLIA_CHAIN_ID}`); + console.log(""); + + const erc20 = new Contract(underlyingAddress, ERC20_ABI, senderWallet); + const superToken = new Contract(superTokenAddress, SUPER_TOKEN_ABI, senderWallet); + const cfa = new Contract(cfaAddress, CFA_FORWARDER_ABI, senderWallet); + const gda = new Contract(gdaAddress, GDA_FORWARDER_ABI, senderWallet); + + // --- step 1: pre-flight --- + console.log("[step 1 / pre-flight]"); + const ethBalance: bigint = await provider.getBalance(senderAddress); + const minEth = ethers.parseEther("0.01"); + if (ethBalance < minEth) { + console.error( + `Insufficient ETH for gas: ${ethers.formatEther(ethBalance)} ETH (need >= 0.01). Fund the sender wallet on Sepolia.` + ); + process.exit(1); + } + const underlyingBalance: bigint = await erc20.balanceOf(senderAddress); + if (underlyingBalance < wrapAmount) { + console.error( + `Insufficient fUSDC balance: ${underlyingBalance} wei (need >= ${wrapAmount}). Claim from https://app.superfluid.finance/faucet (Sepolia).` + ); + process.exit(1); + } + record( + "step 1 / pre-flight", + "PASS", + `ETH ${ethers.formatEther(ethBalance)} | fUSDC balance ${underlyingBalance} wei` + ); + + // --- idempotency: close any pre-existing flow before the lifecycle --- + console.log("\n[idempotency check]"); + const [, existingRate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (existingRate > BigInt(0)) { + console.log(` Pre-existing flow found (${existingRate} wei/s). Deleting before lifecycle.`); + await sendAndWait("pre-cleanup delete-flow", cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES)); + console.log(" Pre-existing flow deleted."); + } else { + console.log(" No pre-existing flow. Proceeding."); + } + + // --- step 2: approve underlying --- + console.log("\n[step 2 / approve]"); + const receipt2 = await sendAndWait( + "approve", + erc20.approve(superTokenAddress, wrapAmount, TX_OVERRIDES) + ); + record( + "step 2 / approve", + "PASS", + `tx ${receipt2.hash} | amount ${wrapAmount} wei` + ); + + // --- step 3: wrap --- + console.log("\n[step 3 / wrap]"); + const balBefore: bigint = await superToken.balanceOf(senderAddress); + const receipt3 = await sendAndWait("wrap", superToken.upgrade(wrapAmount, TX_OVERRIDES)); + const balAfter: bigint = await superToken.balanceOf(senderAddress); + if (balAfter <= balBefore) { + throw new Error(`Wrap did not increase SuperToken balance: before=${balBefore} after=${balAfter}`); + } + record( + "step 3 / wrap", + "PASS", + `tx ${receipt3.hash} | superToken balance before=${balBefore} after=${balAfter}` + ); + + // --- step 4: create-flow --- + console.log("\n[step 4 / create-flow]"); + const receipt4 = await sendAndWait( + "create-flow", + cfa.createFlow(superTokenAddress, senderAddress, receiverAddress, flowRate, "0x", TX_OVERRIDES) + ); + const [, flowRateAfterCreate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (flowRateAfterCreate !== flowRate) { + throw new Error(`Flow rate mismatch after create: expected ${flowRate}, got ${flowRateAfterCreate}`); + } + record( + "step 4 / create-flow", + "PASS", + `tx ${receipt4.hash} | flowRate ${flowRate} wei/s` + ); + + // --- step 5: update-flow --- + console.log("\n[step 5 / update-flow]"); + const newFlowRate = flowRate * BigInt(2); + const receipt5 = await sendAndWait( + "update-flow", + cfa.updateFlow(superTokenAddress, senderAddress, receiverAddress, newFlowRate, "0x", TX_OVERRIDES) + ); + const [, flowRateAfterUpdate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (flowRateAfterUpdate !== newFlowRate) { + throw new Error(`Flow rate mismatch after update: expected ${newFlowRate}, got ${flowRateAfterUpdate}`); + } + record( + "step 5 / update-flow", + "PASS", + `tx ${receipt5.hash} | flowRate ${newFlowRate} wei/s` + ); + + // --- step 6: get-net-flow --- + console.log("\n[step 6 / get-net-flow]"); + const netFlow: bigint = await cfa.getAccountFlowrate(superTokenAddress, receiverAddress); + // netFlow may be negative for the sender side; for receiver it should be >= newFlowRate + // (could be higher if receiver has other inbound flows) + record( + "step 6 / get-net-flow", + "PASS", + `receiver net flow ${netFlow} wei/s` + ); + + // --- step 7: delete-flow --- + console.log("\n[step 7 / delete-flow]"); + const receipt7 = await sendAndWait( + "delete-flow", + cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES) + ); + const [, flowRateAfterDelete]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (flowRateAfterDelete !== BigInt(0)) { + throw new Error(`Flow still active after delete: rate=${flowRateAfterDelete}`); + } + record( + "step 7 / delete-flow", + "PASS", + `tx ${receipt7.hash} | flowRate now 0` + ); + + // --- step 8: create-pool --- + console.log("\n[step 8 / create-pool]"); + const receipt8 = await sendAndWait( + "create-pool", + gda.createPool(superTokenAddress, senderAddress, [false, false], TX_OVERRIDES) + ); + + // Parse PoolCreated event from receipt logs + const gdaInterface = new ethers.Interface(GDA_FORWARDER_ABI); + const poolCreatedTopic = gdaInterface.getEvent("PoolCreated")?.topicHash; + let poolAddress = ""; + for (const log of receipt8.logs) { + if (log.topics[0] === poolCreatedTopic) { + const parsed = gdaInterface.parseLog({ topics: [...log.topics], data: log.data }); + if (parsed?.args.pool) { + poolAddress = parsed.args.pool as string; + break; + } + } + } + if (!poolAddress) { + throw new Error("PoolCreated event not found in create-pool receipt"); + } + record( + "step 8 / create-pool", + "PASS", + `tx ${receipt8.hash} | pool ${poolAddress}` + ); + + // --- step 9: update-member-units --- + console.log("\n[step 9 / update-member-units]"); + const receipt9 = await sendAndWait( + "update-member-units", + gda.updateMemberUnits(poolAddress, receiverAddress, BigInt(100), "0x", TX_OVERRIDES) + ); + record( + "step 9 / update-member-units", + "PASS", + `tx ${receipt9.hash} | member ${receiverAddress} | units 100` + ); + + // --- step 10: connect-pool (receiver must sign) --- + console.log("\n[step 10 / connect-pool]"); + if (receiverWallet !== null) { + const gdaAsReceiver = new Contract(gdaAddress, GDA_FORWARDER_ABI, receiverWallet); + const receipt10 = await sendAndWait( + "connect-pool", + gdaAsReceiver.connectPool(poolAddress, "0x", TX_OVERRIDES) + ); + record( + "step 10 / connect-pool", + "PASS", + `tx ${receipt10.hash} | receiver ${receiverAddress} connected` + ); + } else { + record( + "step 10 / connect-pool", + "SKIPPED", + "SUPERFLUID_E2E_RECEIVER_KEY not set; receiver cannot sign connect-pool" + ); + } + + // --- step 11: distribute-flow --- + console.log("\n[step 11 / distribute-flow]"); + const receipt11 = await sendAndWait( + "distribute-flow", + gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, flowRate, "0x", TX_OVERRIDES) + ); + record( + "step 11 / distribute-flow", + "PASS", + `tx ${receipt11.hash} | flowRate ${flowRate} wei/s` + ); + + // --- step 12: read net flow twice, 5 seconds apart --- + // CFA's getAccountFlowrate aggregates only CFA flows. To observe the + // receiver's incoming pool stream we have to query the GDA forwarder's + // getNetFlow, which combines CFA and GDA flows. + console.log("\n[step 12 / read-net-flow x2]"); + const netFlowBefore: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); + console.log(` net flow (t=0): ${netFlowBefore} wei/s`); + await sleep(5000); + const netFlowAfter: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); + console.log(` net flow (t=5s): ${netFlowAfter} wei/s`); + + if (receiverWallet !== null) { + if (netFlowAfter <= BigInt(0)) { + throw new Error(`Expected receiver net flow to be > 0 after connect-pool + distribute-flow, got ${netFlowAfter}`); + } + record( + "step 12 / read-net-flow", + "PASS", + `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s` + ); + } else { + record( + "step 12 / read-net-flow", + "PASS", + `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s (receiver not connected; flow accumulation not verified)` + ); + } + + // --- step 13: cleanup --- + console.log("\n[step 13 / cleanup]"); + const receipt13 = await sendAndWait( + "cleanup distribute-flow", + gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, BigInt(0), "0x", TX_OVERRIDES) + ); + record( + "step 13 / cleanup", + "PASS", + `tx ${receipt13.hash} | pool stream closed` + ); + + // --- summary --- + console.log("\n--- SUMMARY ---"); + console.log( + `${"Step".padEnd(40)} ${"Status".padEnd(10)} Detail` + ); + console.log("-".repeat(100)); + for (const r of results) { + console.log(`${r.name.padEnd(40)} ${r.status.padEnd(10)} ${r.detail}`); + } + + const failed = results.filter((r) => r.status === "FAILED"); + const skipped = results.filter((r) => r.status === "SKIPPED"); + console.log( + `\n${results.length} steps total | ${results.length - failed.length - skipped.length} PASS | ${skipped.length} SKIPPED | ${failed.length} FAILED` + ); + + if (failed.length > 0) { + process.exit(1); + } +} + +main().catch((error: unknown) => { + const failed = results.filter((r) => r.status === "FAILED"); + const msg = error instanceof Error ? error.message : String(error); + if (failed.length === 0) { + // Unrecorded failure + console.error(`\nFATAL: ${msg}`); + } + process.exit(1); +}); diff --git a/scripts/verify-superfluid-addresses.ts b/scripts/verify-superfluid-addresses.ts new file mode 100644 index 000000000..a4f5c6a76 --- /dev/null +++ b/scripts/verify-superfluid-addresses.ts @@ -0,0 +1,134 @@ +/** + * Superfluid forwarder address verification. + * + * One-shot CLI: confirms CFAv1Forwarder and GDAv1Forwarder are deployed + * (have non-empty bytecode) at their pinned addresses on every chain + * Superfluid is shipped on. Run before opening the PR; paste the table + * output into the PR description. + * + * Interpreting failures: a row with a network error (HTTP 401/429/5xx, + * DNS, timeout) means the public RPC is unreachable -- retry or swap the + * URL in the CHAINS array below. A row showing FAIL with no error message + * means the address actually has empty bytecode on that chain (a real + * deployment miss to investigate). + * + * Usage: pnpm tsx scripts/verify-superfluid-addresses.ts + */ + +import { JsonRpcProvider } from "ethers"; +import { + CFA_FORWARDER_ADDRESS, + GDA_FORWARDER_ADDRESS, + SUPERFLUID_CHAIN_IDS, +} from "@/protocols/superfluid"; + +type ForwarderName = "CFAv1Forwarder" | "GDAv1Forwarder"; + +const FORWARDERS: Record = { + CFAv1Forwarder: CFA_FORWARDER_ADDRESS, + GDAv1Forwarder: GDA_FORWARDER_ADDRESS, +}; + +// RPC + display metadata per chain. The chain set itself is sourced from the +// protocol module so adding/removing a chain happens in exactly one place; +// any chain ID present in SUPERFLUID_CHAIN_IDS but missing here will surface +// as an "unknown chain" entry below. +const CHAIN_RPC: Record = { + "1": { name: "Ethereum Mainnet", rpc: "https://rpc.ankr.com/eth" }, + "10": { name: "Optimism", rpc: "https://mainnet.optimism.io" }, + "137": { name: "Polygon", rpc: "https://rpc.ankr.com/polygon" }, + "8453": { name: "Base", rpc: "https://mainnet.base.org" }, + "42161": { name: "Arbitrum One", rpc: "https://arb1.arbitrum.io/rpc" }, + "11155111": { + name: "Sepolia", + rpc: "https://ethereum-sepolia-rpc.publicnode.com", + }, +}; + +const CHAINS: Array<{ id: number; name: string; rpc: string }> = + SUPERFLUID_CHAIN_IDS.map((id) => { + const meta = CHAIN_RPC[id]; + return { + id: Number(id), + name: meta?.name ?? `Unknown chain ${id}`, + rpc: meta?.rpc ?? "", + }; + }); + +type CheckResult = { + chainName: string; + chainId: number; + forwarder: ForwarderName; + address: string; + deployed: boolean; + error?: string; +}; + +async function checkOne( + chain: { id: number; name: string; rpc: string }, + forwarder: ForwarderName +): Promise { + const address = FORWARDERS[forwarder]; + try { + const provider = new JsonRpcProvider(chain.rpc, chain.id); + const code = await provider.getCode(address); + return { + chainName: chain.name, + chainId: chain.id, + forwarder, + address, + deployed: code !== "0x", + }; + } catch (error) { + return { + chainName: chain.name, + chainId: chain.id, + forwarder, + address, + deployed: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function printMarkdownTable(results: CheckResult[]): void { + console.log(""); + console.log("| Chain | Chain ID | Contract | Address | Status |"); + console.log("|---|---|---|---|---|"); + for (const r of results) { + const status = r.deployed ? "OK" : `FAIL${r.error ? ` (${r.error})` : ""}`; + console.log( + `| ${r.chainName} | ${r.chainId} | ${r.forwarder} | \`${r.address}\` | ${status} |` + ); + } + console.log(""); +} + +async function main(): Promise { + console.error("Verifying Superfluid forwarder deployments..."); + + const tasks: Array> = []; + for (const chain of CHAINS) { + for (const fwd of Object.keys(FORWARDERS) as ForwarderName[]) { + tasks.push(checkOne(chain, fwd)); + } + } + + const results = await Promise.all(tasks); + printMarkdownTable(results); + + const failed = results.filter((r) => !r.deployed); + if (failed.length > 0) { + console.error( + `FAILED: ${failed.length} of ${results.length} checks did not find deployed bytecode.` + ); + process.exit(1); + } + + console.error(`All ${results.length} forwarder deployments verified.`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/unit/superfluid-protocol.test.ts b/tests/unit/superfluid-protocol.test.ts new file mode 100644 index 000000000..530ca6fa9 --- /dev/null +++ b/tests/unit/superfluid-protocol.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; +import superfluidProtocol, { + CFA_FORWARDER_ADDRESS, + GDA_FORWARDER_ADDRESS, + SUPERFLUID_CHAIN_IDS, +} from "@/protocols/superfluid"; + +const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; +const KEBAB_CASE_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; +const EXPECTED_CHAINS: string[] = [...SUPERFLUID_CHAIN_IDS]; + +const CFA_FORWARDER = CFA_FORWARDER_ADDRESS; + +type SuperfluidAction = (typeof superfluidProtocol.actions)[number]; + +const findAction = (slug: string): SuperfluidAction | undefined => + superfluidProtocol.actions.find((a) => a.slug === slug); + +describe("Superfluid protocol", () => { + describe("metadata", () => { + it("declares the expected name, slug, and description", () => { + expect(superfluidProtocol.name).toBe("Superfluid"); + expect(superfluidProtocol.slug).toBe("superfluid"); + expect(superfluidProtocol.description).toBeTruthy(); + expect(superfluidProtocol.website).toBe("https://superfluid.org"); + }); + }); + + describe("cfaForwarder contract", () => { + it("declares cfaForwarder with the same address on all six chains", () => { + const contract = superfluidProtocol.contracts.cfaForwarder; + expect(contract).toBeDefined(); + expect(Object.keys(contract.addresses).sort()).toEqual( + [...EXPECTED_CHAINS].sort() + ); + const unique = new Set(Object.values(contract.addresses)); + expect(unique.size).toBe(1); + expect([...unique][0]).toBe(CFA_FORWARDER); + for (const addr of Object.values(contract.addresses)) { + expect(addr).toMatch(ADDRESS_REGEX); + } + }); + + it("ships an inline ABI containing the 5 expected functions", () => { + const contract = superfluidProtocol.contracts.cfaForwarder; + expect(contract.abi).toBeTruthy(); + const abi = JSON.parse(contract.abi as string) as Array<{ + type: string; + name?: string; + }>; + const fnNames = abi + .filter((f) => f.type === "function") + .map((f) => f.name) + .sort(); + expect(fnNames).toEqual( + [ + "createFlow", + "deleteFlow", + "getAccountFlowrate", + "getFlowInfo", + "updateFlow", + ].sort() + ); + }); + }); + + describe("create-flow action", () => { + it("is declared as a write action against cfaForwarder.createFlow", () => { + const action = findAction("create-flow"); + expect(action).toBeDefined(); + expect(action?.type).toBe("write"); + expect(action?.contract).toBe("cfaForwarder"); + expect(action?.function).toBe("createFlow"); + expect(action?.slug).toMatch(KEBAB_CASE_REGEX); + }); + + it("has the five expected inputs in order", () => { + const action = findAction("create-flow"); + const names = action?.inputs.map((i) => i.name); + expect(names).toEqual([ + "token", + "sender", + "receiver", + "flowRate", + "userData", + ]); + }); + + it("marks userData as advanced with default 0x", () => { + const action = findAction("create-flow"); + const userData = action?.inputs.find((i) => i.name === "userData"); + expect(userData?.advanced).toBe(true); + expect(userData?.default).toBe("0x"); + }); + + it("includes the int96 helpTip on flowRate", () => { + const action = findAction("create-flow"); + const flowRate = action?.inputs.find((i) => i.name === "flowRate"); + expect(flowRate?.helpTip).toContain("Wei per second"); + expect(flowRate?.helpTip).toContain("int96"); + }); + }); + + describe("update-flow action", () => { + it("is declared as a write action against cfaForwarder.updateFlow", () => { + const action = findAction("update-flow"); + expect(action).toBeDefined(); + expect(action?.type).toBe("write"); + expect(action?.contract).toBe("cfaForwarder"); + expect(action?.function).toBe("updateFlow"); + }); + + it("has the same input shape as create-flow", () => { + const action = findAction("update-flow"); + const names = action?.inputs.map((i) => i.name); + expect(names).toEqual([ + "token", + "sender", + "receiver", + "flowRate", + "userData", + ]); + }); + }); + + describe("delete-flow action", () => { + it("is declared as a write action against cfaForwarder.deleteFlow", () => { + const action = findAction("delete-flow"); + expect(action).toBeDefined(); + expect(action?.type).toBe("write"); + expect(action?.contract).toBe("cfaForwarder"); + expect(action?.function).toBe("deleteFlow"); + }); + + it("has token/sender/receiver/userData inputs", () => { + const action = findAction("delete-flow"); + const names = action?.inputs.map((i) => i.name); + expect(names).toEqual(["token", "sender", "receiver", "userData"]); + }); + }); + + describe("get-flow action", () => { + it("is declared as a read action against cfaForwarder.getFlowInfo", () => { + const action = findAction("get-flow"); + expect(action).toBeDefined(); + expect(action?.type).toBe("read"); + expect(action?.contract).toBe("cfaForwarder"); + expect(action?.function).toBe("getFlowInfo"); + }); + + it("declares the four expected outputs with decimals on rate/deposit", () => { + const action = findAction("get-flow"); + const outputs = action?.outputs ?? []; + expect(outputs.map((o) => o.name)).toEqual([ + "lastUpdated", + "flowRate", + "deposit", + "owedDeposit", + ]); + expect(outputs.find((o) => o.name === "flowRate")?.decimals).toBe(18); + expect(outputs.find((o) => o.name === "deposit")?.decimals).toBe(18); + }); + }); + + describe("get-net-flow action", () => { + it("is declared as a read action against cfaForwarder.getAccountFlowrate", () => { + const action = findAction("get-net-flow"); + expect(action).toBeDefined(); + expect(action?.type).toBe("read"); + expect(action?.contract).toBe("cfaForwarder"); + expect(action?.function).toBe("getAccountFlowrate"); + }); + + it("returns flowRate as int96 with decimals: 18", () => { + const action = findAction("get-net-flow"); + const out = action?.outputs?.[0]; + expect(out?.name).toBe("flowRate"); + expect(out?.type).toBe("int96"); + expect(out?.decimals).toBe(18); + }); + }); + + describe("gdaForwarder contract", () => { + const GDA_FORWARDER = GDA_FORWARDER_ADDRESS; + + it("declares gdaForwarder with the same address on all six chains", () => { + const contract = superfluidProtocol.contracts.gdaForwarder; + expect(contract).toBeDefined(); + expect(Object.keys(contract.addresses).sort()).toEqual( + [...EXPECTED_CHAINS].sort() + ); + const unique = new Set(Object.values(contract.addresses)); + expect(unique.size).toBe(1); + expect([...unique][0]).toBe(GDA_FORWARDER); + }); + + it("ships an inline ABI with the 5 expected functions", () => { + const contract = superfluidProtocol.contracts.gdaForwarder; + const abi = JSON.parse(contract.abi as string) as Array<{ + type: string; + name?: string; + }>; + const fnNames = abi + .filter((f) => f.type === "function") + .map((f) => f.name) + .sort(); + expect(fnNames).toEqual( + [ + "connectPool", + "createPool", + "distribute", + "distributeFlow", + "updateMemberUnits", + ].sort() + ); + }); + + it("createPool ABI declares the (bool,bool) PoolConfig tuple", () => { + const contract = superfluidProtocol.contracts.gdaForwarder; + const abi = JSON.parse(contract.abi as string) as Array<{ + type: string; + name?: string; + inputs?: Array<{ + name: string; + type: string; + components?: Array<{ name: string; type: string }>; + }>; + }>; + const createPool = abi.find( + (f) => f.type === "function" && f.name === "createPool" + ); + const config = createPool?.inputs?.find((i) => i.name === "config"); + expect(config?.type).toBe("tuple"); + expect(config?.components?.map((c) => c.name)).toEqual([ + "transferabilityForUnitsOwner", + "distributionFromAnyAddress", + ]); + }); + }); + + describe("GDA actions", () => { + it("declares the five expected GDA action slugs", () => { + const slugs = superfluidProtocol.actions + .filter((a) => a.contract === "gdaForwarder") + .map((a) => a.slug) + .sort(); + expect(slugs).toEqual( + [ + "connect-pool", + "create-pool", + "distribute", + "distribute-flow", + "update-member-units", + ].sort() + ); + }); + + it("create-pool declares the config tuple input via components", () => { + const action = findAction("create-pool"); + const config = action?.inputs.find((i) => i.name === "config"); + expect(config?.type).toBe("tuple"); + expect(config?.components?.map((c) => c.name)).toEqual([ + "transferabilityForUnitsOwner", + "distributionFromAnyAddress", + ]); + }); + + it("distribute-flow uses int96 flowRate with the shared helpTip", () => { + const action = findAction("distribute-flow"); + const flowRate = action?.inputs.find((i) => i.name === "flowRate"); + expect(flowRate?.type).toBe("int96"); + expect(flowRate?.helpTip).toContain("Wei per second"); + }); + + it("connect-pool documents that members must call from their own wallet", () => { + const action = findAction("connect-pool"); + expect(action?.description.toLowerCase()).toContain("own wallet"); + }); + }); + + describe("superToken contract", () => { + it("declares superToken with userSpecifiedAddress: true", () => { + const contract = superfluidProtocol.contracts.superToken; + expect(contract).toBeDefined(); + expect(contract.userSpecifiedAddress).toBe(true); + }); + + it("ships an inline ABI with the 5 expected functions", () => { + const contract = superfluidProtocol.contracts.superToken; + const abi = JSON.parse(contract.abi as string) as Array<{ + type: string; + name?: string; + }>; + const fnNames = abi + .filter((f) => f.type === "function") + .map((f) => f.name) + .sort(); + expect(fnNames).toEqual( + [ + "balanceOf", + "downgrade", + "getUnderlyingToken", + "updateFlowOperatorPermissions", + "upgrade", + ].sort() + ); + }); + }); + + describe("SuperToken actions", () => { + it("declares the five expected SuperToken action slugs", () => { + const slugs = superfluidProtocol.actions + .filter((a) => a.contract === "superToken") + .map((a) => a.slug) + .sort(); + expect(slugs).toEqual( + [ + "get-super-token-balance", + "get-underlying-token", + "grant-flow-operator", + "unwrap", + "wrap", + ].sort() + ); + }); + + it("wrap is a write action against superToken.upgrade", () => { + const action = findAction("wrap"); + expect(action?.type).toBe("write"); + expect(action?.contract).toBe("superToken"); + expect(action?.function).toBe("upgrade"); + }); + + it("unwrap is a write action against superToken.downgrade", () => { + const action = findAction("unwrap"); + expect(action?.type).toBe("write"); + expect(action?.function).toBe("downgrade"); + }); + + it("grant-flow-operator includes the bitmap helpTip on permissions", () => { + const action = findAction("grant-flow-operator"); + const perms = action?.inputs.find((i) => i.name === "permissions"); + expect(perms?.type).toBe("uint8"); + expect(perms?.helpTip).toContain("1"); + expect(perms?.helpTip).toContain("2"); + expect(perms?.helpTip).toContain("4"); + expect(perms?.helpTip).toContain("7"); + }); + + it("get-super-token-balance returns balance with decimals: 18", () => { + const action = findAction("get-super-token-balance"); + expect(action?.type).toBe("read"); + expect(action?.outputs?.[0]?.decimals).toBe(18); + }); + + it("get-underlying-token returns an address output and takes no inputs", () => { + const action = findAction("get-underlying-token"); + expect(action?.type).toBe("read"); + expect(action?.inputs).toEqual([]); + expect(action?.outputs?.[0]?.type).toBe("address"); + }); + }); + + describe("overall integrity", () => { + it("declares 15 actions in total", () => { + expect(superfluidProtocol.actions).toHaveLength(15); + }); + + it("every action slug is unique kebab-case", () => { + const slugs = superfluidProtocol.actions.map((a) => a.slug); + expect(new Set(slugs).size).toBe(slugs.length); + for (const slug of slugs) { + expect(slug).toMatch(KEBAB_CASE_REGEX); + } + }); + + it("every action references a defined contract", () => { + const contractKeys = new Set(Object.keys(superfluidProtocol.contracts)); + for (const action of superfluidProtocol.actions) { + expect(contractKeys.has(action.contract)).toBe(true); + } + }); + + it("every action's function exists in its contract's ABI", () => { + for (const action of superfluidProtocol.actions) { + const contract = + superfluidProtocol.contracts[ + action.contract as keyof typeof superfluidProtocol.contracts + ]; + const abi = contract.abi + ? (JSON.parse(contract.abi) as Array<{ + type: string; + name?: string; + }>) + : []; + const fnNames = abi + .filter((f) => f.type === "function") + .map((f) => f.name); + expect(fnNames).toContain(action.function); + } + }); + }); +}); From f2bcdc8a546c9b3d564bb61120d6665ac8675f5e Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 11:58:25 +1000 Subject: [PATCH 02/19] feat: KEEP-434 use defaultFallbackWss when primary fails, add probe timeout Follow-up to KEEP-418. KEEP-418 closed the unhandled-rejection path by probing eth_subscribe before any block listener attaches; if the probe fails, the workflow listener is logged-and-skipped instead of crashing the pod. Two gaps remained: 1. defaultFallbackWss is read into NetworkConfig but never used. The provider manager creates one provider from defaultPrimaryWss and loops on the same URL forever. Chains with a permanently-bad primary (wrong host, dead service, no eth_subscribe support) never reach the configured fallback, even though chain-config repo populates one. 2. probeSubscriptionSupport awaits provider.send(eth_subscribe) with no externally-controllable timeout. An upstream that completes the WS handshake but never answers the JSON-RPC frame would block createProvider for the life of the socket. This commit: - Plumbs fallbackWssUrl through workflow-mapper -> WorkflowRegistration -> EventListener -> SubscribeOptions -> ChainEntry. workflow-mapper validates the same scheme rules as the primary; an invalid fallback is logged and dropped (the listener still runs on primary alone). configHash includes the fallback so a fallback swap restarts the listener. - Refactors createProvider/reconnect to share a single openProvider helper that walks [primary, fallback?] in order, returning the first url whose factory + ready + probe all succeed. Failed providers are destroy()'d before moving on so sockets don't leak across the failover. Reconnects always start from primary so a recovered primary is preferred. - Wraps the eth_subscribe probe in Promise.race with a 10s timeout (matches the existing heartbeat timeout). - Adds activeWssUrl to ChainEntry and exposes both wssUrl (active) and fallbackWssUrl (configured) on ChainHealth so /healthz operators can see when failover is active. - Tightens ensureEntry's identity check to require both URLs to match for a reused chain entry. Tests: 128/128 pass (5 new fallback tests + 4 new fallback-validation tests, plus updated health assertion). Typecheck and biome clean. --- .../src/chains/provider-manager.ts | 236 ++++++++++++++---- .../src/listener/event-listener.ts | 2 + .../event-tracker/src/listener/registry.ts | 7 + .../src/listener/workflow-mapper.ts | 23 +- .../tests/unit/provider-manager.test.ts | 60 +++++ .../tests/unit/workflow-mapper.test.ts | 64 +++++ 6 files changed, 342 insertions(+), 50 deletions(-) diff --git a/keeperhub-events/event-tracker/src/chains/provider-manager.ts b/keeperhub-events/event-tracker/src/chains/provider-manager.ts index daa556b30..b9a923aca 100644 --- a/keeperhub-events/event-tracker/src/chains/provider-manager.ts +++ b/keeperhub-events/event-tracker/src/chains/provider-manager.ts @@ -39,6 +39,15 @@ const HEARTBEAT_TIMEOUT_MS = 10_000; const INITIAL_RECONNECT_DELAY_MS = 1_000; const MAX_RECONNECT_DELAY_MS = 60_000; const MAX_RECONNECT_ATTEMPTS = 10; +/** + * Cap on `eth_subscribe(["newHeads"])` round-trip during the probe in + * `probeSubscriptionSupport`. An upstream that accepts the WS handshake + * but never answers the JSON-RPC frame (silent backend, broken proxy) would + * otherwise block `createProvider` forever. 10 s matches the heartbeat + * timeout in `startHeartbeat` so the two reachability gates fail at the + * same scale. + */ +const PROBE_TIMEOUT_MS = 10_000; export type LogHandler = (log: ethers.Log) => void | Promise; export type Unsubscribe = () => void; @@ -60,7 +69,17 @@ export type DisconnectHandler = (ev: DisconnectEvent) => void | Promise; export interface ChainHealth { chainId: number; + /** + * The URL the live provider was opened against, or the configured + * primary if no provider is currently connected. May be the configured + * fallback if the primary failed at the most recent (re)connect. + */ wssUrl: string; + /** + * Configured fallback URL, or null if none. Surfaced so operators can + * see whether failover capacity exists for this chain. + */ + fallbackWssUrl: string | null; connected: boolean; reconnecting: boolean; lastBlockAt: number | null; @@ -78,6 +97,11 @@ export interface ChainHealth { export interface SubscribeOptions { chainId: number; wssUrl: string; + /** + * Optional secondary URL tried when the primary fails at provider + * creation or reconnect. See `ChainEntry.fallbackWssUrl`. + */ + fallbackWssUrl?: string; address: string; topic0: string; handler: LogHandler; @@ -96,7 +120,24 @@ interface Subscriber { interface ChainEntry { chainId: number; + /** + * Configured primary URL; immutable once the entry is created. Each + * (re)connect attempt tries this first. + */ wssUrl: string; + /** + * Configured fallback URL, immutable once the entry is created. Tried + * only when the primary attempt fails (factory throws, `provider.ready` + * rejects, or `eth_subscribe` probe rejects). Reconnects always start + * over from primary so a primary that recovers is preferred. + */ + fallbackWssUrl: string | null; + /** + * Which URL the live provider was created from. Equal to `wssUrl` on + * the common path, equal to `fallbackWssUrl` when the primary failed + * at the last (re)connect, null when no provider is live. + */ + activeWssUrl: string | null; provider: ethers.WebSocketProvider | null; readyPromise: Promise | null; /** @@ -160,8 +201,9 @@ export class ChainProviderManager { async getOrCreateProvider( chainId: number, wssUrl: string, + fallbackWssUrl?: string, ): Promise { - const entry = this.ensureEntry(chainId, wssUrl); + const entry = this.ensureEntry(chainId, wssUrl, fallbackWssUrl); // If a reconnect loop is live, wait for it to settle before checking // the provider. Without this, a new subscriber arriving while the @@ -200,8 +242,16 @@ export class ChainProviderManager { } async subscribeToLogs(opts: SubscribeOptions): Promise { - const entry = this.ensureEntry(opts.chainId, opts.wssUrl); - await this.getOrCreateProvider(opts.chainId, opts.wssUrl); + const entry = this.ensureEntry( + opts.chainId, + opts.wssUrl, + opts.fallbackWssUrl, + ); + await this.getOrCreateProvider( + opts.chainId, + opts.wssUrl, + opts.fallbackWssUrl, + ); const subscriber: Subscriber = { address: opts.address.toLowerCase(), @@ -290,9 +340,25 @@ export class ChainProviderManager { if (!entry) { return null; } + return this.toHealth(entry); + } + + getAllHealth(): ChainHealth[] { + const out: ChainHealth[] = []; + for (const entry of this.chains.values()) { + out.push(this.toHealth(entry)); + } + return out; + } + + private toHealth(entry: ChainEntry): ChainHealth { return { chainId: entry.chainId, - wssUrl: entry.wssUrl, + // Active URL when a provider is live, primary otherwise. Lets + // operators see whether failover kicked in without exposing a + // stale "active" value when nothing is connected. + wssUrl: entry.activeWssUrl ?? entry.wssUrl, + fallbackWssUrl: entry.fallbackWssUrl, connected: entry.provider != null && !entry.isReconnecting, reconnecting: entry.isReconnecting, lastBlockAt: entry.lastBlockAt, @@ -301,22 +367,6 @@ export class ChainProviderManager { }; } - getAllHealth(): ChainHealth[] { - const out: ChainHealth[] = []; - for (const entry of this.chains.values()) { - out.push({ - chainId: entry.chainId, - wssUrl: entry.wssUrl, - connected: entry.provider != null && !entry.isReconnecting, - reconnecting: entry.isReconnecting, - lastBlockAt: entry.lastBlockAt, - subscriberCount: entry.subscribers.size, - lastCreateError: entry.lastCreateError, - }); - } - return out; - } - async destroy(): Promise { this.isDestroyed = true; // Wake every reconnect loop that is currently sleeping. The loop @@ -346,6 +396,7 @@ export class ChainProviderManager { entry.subscribers.clear(); entry.disconnectHandlers.clear(); entry.provider = null; + entry.activeWssUrl = null; entry.readyPromise = null; } this.chains.clear(); @@ -358,12 +409,20 @@ export class ChainProviderManager { } } - private ensureEntry(chainId: number, wssUrl: string): ChainEntry { + private ensureEntry( + chainId: number, + wssUrl: string, + fallbackWssUrl?: string, + ): ChainEntry { + const fallback = fallbackWssUrl ?? null; const existing = this.chains.get(chainId); if (existing) { - if (existing.wssUrl !== wssUrl) { + // Identity is the (primary, fallback) tuple. Two callers must agree + // on both; otherwise the second caller would silently inherit the + // first caller's failover behaviour. + if (existing.wssUrl !== wssUrl || existing.fallbackWssUrl !== fallback) { throw new Error( - `chainId ${chainId} already registered with wssUrl ${existing.wssUrl}; refusing to reuse for ${wssUrl}`, + `chainId ${chainId} already registered with wssUrl=${existing.wssUrl} fallbackWssUrl=${existing.fallbackWssUrl}; refusing to reuse for wssUrl=${wssUrl} fallbackWssUrl=${fallback}`, ); } return existing; @@ -371,6 +430,8 @@ export class ChainProviderManager { const entry: ChainEntry = { chainId, wssUrl, + fallbackWssUrl: fallback, + activeWssUrl: null, provider: null, readyPromise: null, reconnectPromise: null, @@ -387,13 +448,66 @@ export class ChainProviderManager { return entry; } + /** + * Ordered list of URLs to try at (re)connect time: primary first, + * fallback (if configured) second. Returned fresh on every call so a + * caller can iterate without mutating entry state. + */ + private candidateUrls(entry: ChainEntry): string[] { + return entry.fallbackWssUrl + ? [entry.wssUrl, entry.fallbackWssUrl] + : [entry.wssUrl]; + } + + /** + * Walk the candidate URL list in order, returning the first + * `(provider, urlUsed)` pair that satisfies factory + ready + probe. + * On failure of one URL the partially-constructed provider is + * destroyed best-effort before moving on, so we do not leak sockets + * across attempts. If every URL fails, throws an aggregate error + * containing each URL's failure message. + */ + private async openProvider( + entry: ChainEntry, + ): Promise<{ provider: ethers.WebSocketProvider; urlUsed: string }> { + const urls = this.candidateUrls(entry); + const failures: string[] = []; + for (const url of urls) { + let provider: ethers.WebSocketProvider | null = null; + try { + provider = this.factory(url); + await provider.ready; + await this.probeSubscriptionSupport(provider, entry, url); + return { provider, urlUsed: url }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + failures.push(`${url}: ${message}`); + if (provider) { + try { + await provider.destroy(); + } catch { + // Best-effort: socket may already be gone (probe failure + // already destroys), and we are about to throw or move on. + } + } + } + } + throw new Error( + `chain ${entry.chainId}: all ${urls.length} WSS URL(s) failed:\n ${failures.join("\n ")}`, + ); + } + private async createProvider( entry: ChainEntry, ): Promise { - const provider = this.factory(entry.wssUrl); - await provider.ready; - await this.probeSubscriptionSupport(provider, entry); + const { provider, urlUsed } = await this.openProvider(entry); entry.provider = provider; + entry.activeWssUrl = urlUsed; + if (urlUsed !== entry.wssUrl) { + logger.warn( + `[ChainProviderManager] chain=${entry.chainId} primary failed; running on fallback ${urlUsed}`, + ); + } // Clear the prior failure marker now that we have a working provider. // Without this, a chain that recovered after a probe failure would // still report `lastCreateError` indefinitely. @@ -427,21 +541,44 @@ export class ChainProviderManager { private async probeSubscriptionSupport( provider: ethers.WebSocketProvider, entry: ChainEntry, + urlUsed: string, ): Promise { let filterId: unknown; try { - filterId = await provider.send("eth_subscribe", ["newHeads"]); - } catch (err) { + // Race the RPC call against an explicit timeout. ethers does not + // give us an externally controllable timeout on `provider.send`, + // and an upstream that accepts the WS handshake but never answers + // the JSON-RPC frame would otherwise hang createProvider for the + // life of the socket. The Node 20 native timer doesn't need clearing + // because the race winner discards the loser's result, but we still + // clear it explicitly so the timeout doesn't keep the event loop + // alive after a fast probe. + let timeoutHandle: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => + reject( + new Error( + `eth_subscribe probe timed out after ${PROBE_TIMEOUT_MS}ms`, + ), + ), + PROBE_TIMEOUT_MS, + ); + }); try { - await provider.destroy(); - } catch { - // Best-effort: the failed provider is already unusable; if - // destroy throws (e.g. socket already closed) there is nothing - // to do but proceed to the throw below. + filterId = await Promise.race([ + provider.send("eth_subscribe", ["newHeads"]), + timeoutPromise, + ]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } } + } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error( - `chain ${entry.chainId} (${entry.wssUrl}): RPC does not support eth_subscribe: ${message}`, + `chain ${entry.chainId} (${urlUsed}): RPC does not support eth_subscribe: ${message}`, ); } try { @@ -654,21 +791,23 @@ export class ChainProviderManager { } } entry.provider = null; + entry.activeWssUrl = null; entry.readyPromise = null; if (this.isDestroyed) { return; } - // Re-create. Any throw here propagates to the loop which handles - // backoff. - const provider = this.factory(entry.wssUrl); - await provider.ready; + // Re-create using the same primary-then-fallback walk as + // createProvider. Each (re)connect tries primary first so a primary + // that recovers is preferred. Any throw here propagates to the loop + // which handles backoff. + const { provider, urlUsed } = await this.openProvider(entry); - // Destroy may have run while we were waiting for `ready`. If so, the - // entry we are about to populate is no longer in `this.chains` and - // attaching listeners would leak a provider that never gets - // destroyed by the second pass. + // Destroy may have run while we were waiting for `ready` / probe. If + // so, the entry we are about to populate is no longer in + // `this.chains` and attaching listeners would leak a provider that + // never gets destroyed by the second pass. if (this.isDestroyed) { try { await provider.destroy(); @@ -678,14 +817,13 @@ export class ChainProviderManager { return; } - // Same probe as createProvider: confirm the rebuilt connection still - // accepts eth_subscribe before any .on("block") call exposes us to - // ethers' uncaught-rejection path. A failure here propagates out and - // the reconnect loop counts it as a failed attempt, falling back on - // exponential backoff. - await this.probeSubscriptionSupport(provider, entry); - entry.provider = provider; + entry.activeWssUrl = urlUsed; + if (urlUsed !== entry.wssUrl) { + logger.warn( + `[ChainProviderManager] chain=${entry.chainId} reconnected on fallback ${urlUsed}`, + ); + } // Successful reconnect clears any prior failure marker so /healthz // stops reporting a stale error on a now-healthy chain. entry.lastCreateError = null; diff --git a/keeperhub-events/event-tracker/src/listener/event-listener.ts b/keeperhub-events/event-tracker/src/listener/event-listener.ts index 74636ec1a..1d9bfba6b 100644 --- a/keeperhub-events/event-tracker/src/listener/event-listener.ts +++ b/keeperhub-events/event-tracker/src/listener/event-listener.ts @@ -29,6 +29,7 @@ export interface EventListenerOptions { workflowName: string; chainId: number; wssUrl: string; + fallbackWssUrl?: string; contractAddress: string; eventName: string; eventsAbiStrings: string[]; @@ -82,6 +83,7 @@ export class EventListener { this.unsubscribe = await this.opts.providerManager.subscribeToLogs({ chainId: this.opts.chainId, wssUrl: this.opts.wssUrl, + fallbackWssUrl: this.opts.fallbackWssUrl, address: this.opts.contractAddress, topic0: eventFragment.topicHash, handler: (log) => this.onLog(log), diff --git a/keeperhub-events/event-tracker/src/listener/registry.ts b/keeperhub-events/event-tracker/src/listener/registry.ts index 393325f1d..046107bc3 100644 --- a/keeperhub-events/event-tracker/src/listener/registry.ts +++ b/keeperhub-events/event-tracker/src/listener/registry.ts @@ -26,6 +26,13 @@ export interface WorkflowRegistration { workflowName: string; chainId: number; wssUrl: string; + /** + * Optional secondary WSS endpoint. If primary is unreachable or rejects + * `eth_subscribe`, ChainProviderManager falls through to this URL before + * giving up. Populated from `chains.default_fallback_wss` when it parses + * as a valid `ws://` / `wss://` URL. + */ + fallbackWssUrl?: string; contractAddress: string; eventName: string; eventsAbiStrings: string[]; diff --git a/keeperhub-events/event-tracker/src/listener/workflow-mapper.ts b/keeperhub-events/event-tracker/src/listener/workflow-mapper.ts index efa973b9e..54be657c3 100644 --- a/keeperhub-events/event-tracker/src/listener/workflow-mapper.ts +++ b/keeperhub-events/event-tracker/src/listener/workflow-mapper.ts @@ -80,6 +80,25 @@ export function buildRegistration( return null; } + // Fallback is optional. Same nullable-DB-column caveat as primary: the + // type says `string` but rows can be null/empty. A bad fallback (wrong + // scheme, empty) is logged and dropped rather than failing the whole + // workflow; the listener still runs on primary alone. + const rawFallbackWssUrl: unknown = network.defaultFallbackWss; + let fallbackWssUrl: string | undefined; + if (typeof rawFallbackWssUrl === "string" && rawFallbackWssUrl.length > 0) { + if ( + rawFallbackWssUrl.startsWith("wss://") || + rawFallbackWssUrl.startsWith("ws://") + ) { + fallbackWssUrl = rawFallbackWssUrl; + } else { + logger.warn( + `[workflow-mapper] workflow ${workflowId} chain ${chainId} defaultFallbackWss is not a WebSocket URL ("${rawFallbackWssUrl}"); ignoring fallback`, + ); + } + } + const contractAddress = typeof config.contractAddress === "string" ? config.contractAddress : null; if (!contractAddress) { @@ -140,7 +159,8 @@ export function buildRegistration( userId, workflowName, chainId, - wssUrl: network.defaultPrimaryWss, + wssUrl, + fallbackWssUrl, contractAddress, eventName, eventsAbiStrings, @@ -167,6 +187,7 @@ export function hashRegistration( const canonical = JSON.stringify({ chainId: reg.chainId, wssUrl: reg.wssUrl, + fallbackWssUrl: reg.fallbackWssUrl ?? null, contractAddress: reg.contractAddress, eventName: reg.eventName, eventsAbiStrings: reg.eventsAbiStrings, diff --git a/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts b/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts index 552484297..7aab4317b 100644 --- a/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts +++ b/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts @@ -303,6 +303,65 @@ describe("ChainProviderManager", () => { await localManager.destroy(); }); }); + + // Fallback URL is opt-in via the optional third parameter on + // getOrCreateProvider / SubscribeOptions. When the primary fails at + // factory + ready + probe, the manager walks to the fallback before + // surfacing failure. Reconnect uses the same walk, so a primary that + // recovers is preferred on the next reconnect. + describe("fallback wssUrl", () => { + it("uses the primary when it works and never invokes the fallback", async () => { + await manager.getOrCreateProvider(CHAIN_A, "ws://primary", "ws://fb"); + expect(factoryBundle.created).toHaveLength(1); + expect(manager.getHealth(CHAIN_A)?.wssUrl).toBe("ws://primary"); + expect(manager.getHealth(CHAIN_A)?.fallbackWssUrl).toBe("ws://fb"); + }); + + it("falls through to the fallback when the primary probe fails", async () => { + // One-shot: only the first provider created (i.e. the one for the + // primary URL) gets the subscribe failure. The fallback's fresh + // provider has no failure armed, so its probe succeeds. + factoryBundle.setNextSubscribeFailure( + new Error('unsupported operation (operation="eth_subscribe")'), + ); + await manager.getOrCreateProvider(CHAIN_A, "ws://primary", "ws://fb"); + expect(factoryBundle.created).toHaveLength(2); + // Failed primary provider is destroyed before we move on so the + // socket does not leak across the failover. + expect(factoryBundle.created[0].destroyed).toBe(true); + expect(factoryBundle.created[1].destroyed).toBe(false); + // Health surface reflects the active URL, not the configured + // primary, so operators can see failover at a glance. + expect(manager.getHealth(CHAIN_A)?.wssUrl).toBe("ws://fb"); + }); + + it("aggregates errors from both URLs when both fail", async () => { + // Persistent failure makes every factory call throw. Both primary + // and fallback fail before the call resolves, and the surfaced + // error must mention both URLs so operators can debug. + factoryBundle.setPersistentFailure(new Error("connect refused")); + await expect( + manager.getOrCreateProvider(CHAIN_A, "ws://primary", "ws://fb"), + ).rejects.toThrow(/ws:\/\/primary.*ws:\/\/fb/s); + }); + + it("rejects a mismatched fallback for a known chainId", async () => { + // The primary+fallback tuple is the entry's identity. A second + // caller with a different fallback would silently inherit the + // first caller's failover URL, so we throw instead. + await manager.getOrCreateProvider(CHAIN_A, "ws://primary", "ws://fb"); + await expect( + manager.getOrCreateProvider(CHAIN_A, "ws://primary", "ws://other"), + ).rejects.toThrow(/already registered/); + }); + + it("works without a fallback (single-URL backwards compatibility)", async () => { + // Existing call sites that pass no fallback still work and report + // null in the fallback health field. + await manager.getOrCreateProvider(CHAIN_A, "ws://primary"); + expect(manager.getHealth(CHAIN_A)?.fallbackWssUrl).toBeNull(); + }); + }); }); describe("subscribeToLogs block listener lifecycle", () => { @@ -683,6 +742,7 @@ describe("ChainProviderManager", () => { expect(h).toEqual({ chainId: CHAIN_A, wssUrl: "ws://a", + fallbackWssUrl: null, connected: true, reconnecting: false, lastBlockAt: null, diff --git a/keeperhub-events/event-tracker/tests/unit/workflow-mapper.test.ts b/keeperhub-events/event-tracker/tests/unit/workflow-mapper.test.ts index d3952ccc5..c8b0e5727 100644 --- a/keeperhub-events/event-tracker/tests/unit/workflow-mapper.test.ts +++ b/keeperhub-events/event-tracker/tests/unit/workflow-mapper.test.ts @@ -79,6 +79,7 @@ describe("buildRegistration", () => { workflowName: "Test Workflow", chainId: CHAIN_ID, wssUrl: "ws://localhost:8546", + fallbackWssUrl: "ws://localhost:8546", contractAddress: "0x1111111111111111111111111111111111111111", eventName: "Transfer", }); @@ -152,6 +153,21 @@ describe("buildRegistration", () => { expect(a?.configHash).not.toBe(b?.configHash); }); + it("changes when fallbackWssUrl changes", () => { + // Switching fallback providers is a real config change: the + // reconciler must restart the listener so the new URL becomes the + // entry's identity in the provider manager. + const a = buildRegistration(makeWorkflow(), NETWORKS); + const networksB: NetworksMap = { + [CHAIN_ID]: { + ...NETWORK, + defaultFallbackWss: "wss://different-fallback.example.com", + }, + }; + const b = buildRegistration(makeWorkflow(), networksB); + expect(a?.configHash).not.toBe(b?.configHash); + }); + it("hashRegistration matches the hash in the built registration", () => { const reg = buildRegistration(makeWorkflow(), NETWORKS); expect(reg).not.toBeNull(); @@ -236,6 +252,54 @@ describe("buildRegistration", () => { expect(reg?.wssUrl).toBe("wss://eth-mainnet.example.com"); }); + // defaultFallbackWss has the same nullable-DB-column issue as the + // primary, but a bad fallback should NOT fail the whole workflow - the + // listener can still run on primary alone. + describe("defaultFallbackWss handling", () => { + it("includes fallbackWssUrl when valid", () => { + const networks: NetworksMap = { + [CHAIN_ID]: { + ...NETWORK, + defaultPrimaryWss: "wss://primary.example.com", + defaultFallbackWss: "wss://fallback.example.com", + }, + }; + const reg = buildRegistration(makeWorkflow(), networks); + expect(reg?.fallbackWssUrl).toBe("wss://fallback.example.com"); + }); + + it("drops a null fallback (workflow still runs on primary)", () => { + const networks: NetworksMap = { + [CHAIN_ID]: { + ...NETWORK, + defaultFallbackWss: null as unknown as string, + }, + }; + const reg = buildRegistration(makeWorkflow(), networks); + expect(reg).not.toBeNull(); + expect(reg?.fallbackWssUrl).toBeUndefined(); + }); + + it("drops an empty fallback", () => { + const networks: NetworksMap = { + [CHAIN_ID]: { ...NETWORK, defaultFallbackWss: "" }, + }; + const reg = buildRegistration(makeWorkflow(), networks); + expect(reg?.fallbackWssUrl).toBeUndefined(); + }); + + it("drops a fallback with the wrong scheme", () => { + const networks: NetworksMap = { + [CHAIN_ID]: { + ...NETWORK, + defaultFallbackWss: "https://eth-mainnet.example.com", + }, + }; + const reg = buildRegistration(makeWorkflow(), networks); + expect(reg?.fallbackWssUrl).toBeUndefined(); + }); + }); + it("returns null when contractAddress is missing", () => { expect( buildRegistration( From e09214cf9e44bda5b9997475d4ecdc5f16f075c9 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 12:05:28 +1000 Subject: [PATCH 03/19] test: KEEP-434 cover fallback walk on reconnect and primary recovery Adds two reconnect-cycle tests that lock in behaviour the production code already implements but had no test for: that reconnect uses the same primary-then-fallback walk as createProvider, and that running on the fallback is not sticky once the primary recovers. Also tightens the ChainHealth.wssUrl JSDoc to reflect that activeWssUrl resets to null mid-reconnect, so the health surface shows the configured primary during a reconnect window rather than the previously-active fallback. --- .../src/chains/provider-manager.ts | 6 +- .../tests/unit/provider-manager.test.ts | 75 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/keeperhub-events/event-tracker/src/chains/provider-manager.ts b/keeperhub-events/event-tracker/src/chains/provider-manager.ts index b9a923aca..cfbb3d158 100644 --- a/keeperhub-events/event-tracker/src/chains/provider-manager.ts +++ b/keeperhub-events/event-tracker/src/chains/provider-manager.ts @@ -71,8 +71,10 @@ export interface ChainHealth { chainId: number; /** * The URL the live provider was opened against, or the configured - * primary if no provider is currently connected. May be the configured - * fallback if the primary failed at the most recent (re)connect. + * primary if no provider is currently connected. Equals the configured + * fallback when the most recent successful (re)connect landed on it; + * resets to the configured primary during a mid-reconnect window + * because `reconnect()` clears `activeWssUrl` before re-attempting. */ wssUrl: string; /** diff --git a/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts b/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts index 7aab4317b..0c9541d1a 100644 --- a/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts +++ b/keeperhub-events/event-tracker/tests/unit/provider-manager.test.ts @@ -1140,6 +1140,81 @@ describe("ChainProviderManager", () => { await vi.advanceTimersByTimeAsync(2_500); expect(manager.isHealthy(CHAIN_A)).toBe(true); }); + + it("walks to the fallback URL when the primary fails on reconnect", async () => { + // Reconnect runs the same primary-then-fallback walk as the + // initial createProvider. With one armed probe failure, the + // reconnect's primary attempt fails and openProvider falls + // through to the fallback. The active URL surfaces through + // getHealth so operators can see failover via /healthz mid-incident. + await manager.subscribeToLogs({ + chainId: CHAIN_A, + wssUrl: "ws://primary", + fallbackWssUrl: "ws://fb", + address: ADDR_A, + topic0: TOPIC_EMITTED, + handler: vi.fn(), + }); + expect(factoryBundle.created).toHaveLength(1); + expect(manager.getHealth(CHAIN_A)?.wssUrl).toBe("ws://primary"); + + // Arm a one-shot probe failure. The reconnect's primary attempt + // gets it; the fallback attempt that follows has no failure armed. + factoryBundle.setNextSubscribeFailure( + new Error('unsupported operation (operation="eth_subscribe")'), + ); + factoryBundle.created[0].emitError(new Error("wss dropped")); + await vi.advanceTimersByTimeAsync(1_500); + + // openProvider tore down the failed primary attempt before + // moving on, then created a fresh mock for the fallback. + // Initial + primary attempt + fallback attempt = 3 providers. + expect(factoryBundle.created).toHaveLength(3); + expect(factoryBundle.created[1].destroyed).toBe(true); + expect(factoryBundle.created[2].destroyed).toBe(false); + expect(manager.isHealthy(CHAIN_A)).toBe(true); + expect(manager.getHealth(CHAIN_A)?.wssUrl).toBe("ws://fb"); + // Block listener and heartbeat must be re-attached on the + // fallback provider; otherwise events would be silently dropped + // after failover. + expect(factoryBundle.created[2].hasBlockHandler()).toBe(true); + expect(factoryBundle.created[2].hasErrorHandler()).toBe(true); + }); + + it("flips back to the primary on the next reconnect once the primary recovers", async () => { + // Running on the fallback is a degraded state, not a sticky one. + // When the primary recovers, the next reconnect's primary-first + // walk picks it up. Without this, a transient primary blip would + // strand the chain on the fallback until process restart. + await manager.subscribeToLogs({ + chainId: CHAIN_A, + wssUrl: "ws://primary", + fallbackWssUrl: "ws://fb", + address: ADDR_A, + topic0: TOPIC_EMITTED, + handler: vi.fn(), + }); + + // First reconnect: primary fails, manager runs on fallback. + factoryBundle.setNextSubscribeFailure(new Error("transient")); + factoryBundle.created[0].emitError(new Error("drop")); + await vi.advanceTimersByTimeAsync(1_500); + expect(manager.getHealth(CHAIN_A)?.wssUrl).toBe("ws://fb"); + const fallbackProvider = factoryBundle.created.at(-1); + const createdBeforeSecond = factoryBundle.created.length; + + // Second reconnect: nothing armed, so the primary attempt + // succeeds and openProvider returns on the first URL it tries. + // The fallback URL is never even hit. + fallbackProvider?.emitError(new Error("drop 2")); + await vi.advanceTimersByTimeAsync(1_500); + + // Exactly one new provider for the recovered primary - no + // wasted fallback factory call. + expect(factoryBundle.created.length - createdBeforeSecond).toBe(1); + expect(manager.isHealthy(CHAIN_A)).toBe(true); + expect(manager.getHealth(CHAIN_A)?.wssUrl).toBe("ws://primary"); + }); }); describe("heartbeat", () => { From 9ac8fd896f254f4d4c8f83bb4cf7d07006705145 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 13:04:54 +1000 Subject: [PATCH 04/19] fix: KEEP-434 wrap WebSocket creation so ws errors do not crash the pod Live verification against chain-config/staging.json found that a misconfigured WSS primary (DNS NXDOMAIN, ECONNREFUSED, non-WS server) crashed the event-tracker pod via process.uncaughtException before openProvider's try/catch could fire. The fallback URL was never tried because the loop body's catch block was bypassed. The pod would crashloop indefinitely on the bad URL even with a healthy fallback configured. Root cause: the underlying ws library emits an error event on the WebSocket as soon as the connection attempt fails. Between new ethers.WebSocketProvider(url) returning and ethers' _start() running to assign onerror, there is a window with no listener attached to the ws socket. Node EventEmitter then re-throws the error synchronously, which lands on process.uncaughtException - treated as fatal in index.ts. Fix: switch defaultFactory to the WebSocketCreator overload of ethers.WebSocketProvider so we own ws.WebSocket construction. Attach a no-op error listener synchronously inside the creator, before returning to ethers. The listener satisfies EventEmitter's "must have a listener" rule. Failures still reject provider.ready via ethers' onerror once that gets assigned, and that rejection lands in openProvider's existing catch which walks to the fallback. Test: tests/integration/provider-manager-bad-url.test.ts uses the real defaultFactory against ws://127.0.0.1:1 (ECONNREFUSED is deterministic on Linux, no DNS or remote network) and asserts that getOrCreateProvider rejects through the awaited path AND that no process.uncaughtException leaks during the test. Reverting the fix makes both cases fail with the captured ECONNREFUSED errors. --- keeperhub-events/event-tracker/package.json | 4 +- .../src/chains/provider-manager.ts | 25 ++++++- .../provider-manager-bad-url.test.ts | 73 +++++++++++++++++++ keeperhub-events/pnpm-lock.yaml | 13 ++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts diff --git a/keeperhub-events/event-tracker/package.json b/keeperhub-events/event-tracker/package.json index 89b7a7f43..c5ed39edc 100644 --- a/keeperhub-events/event-tracker/package.json +++ b/keeperhub-events/event-tracker/package.json @@ -20,11 +20,13 @@ "@aws-sdk/client-sqs": "^3.1005.0", "ethers": "^6.13.4", "ioredis": "^5.4.2", - "uuid": "^14.0.0" + "uuid": "^14.0.0", + "ws": "^8.17.1" }, "devDependencies": { "@types/node": "^24.12.2", "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.13", "tsup": "^8.3.5", "tsx": "^4.0.0", "vite": "^8.0.9", diff --git a/keeperhub-events/event-tracker/src/chains/provider-manager.ts b/keeperhub-events/event-tracker/src/chains/provider-manager.ts index cfbb3d158..0fa795033 100644 --- a/keeperhub-events/event-tracker/src/chains/provider-manager.ts +++ b/keeperhub-events/event-tracker/src/chains/provider-manager.ts @@ -1,4 +1,5 @@ import { ethers } from "ethers"; +import { WebSocket } from "ws"; import { logger } from "../../lib/utils/logger"; /** @@ -164,8 +165,30 @@ interface ChainEntry { disconnectHandlers: Set; } +/** + * Wrap socket construction so we can attach an EventEmitter-style `error` + * listener synchronously, before ethers' WebSocketProvider has had a + * chance to assign its own `onerror`. Without this, an early ws-layer + * error (DNS NXDOMAIN, ECONNREFUSED, non-WS server returning HTTP 200) + * fires on a listenerless EventEmitter, gets re-thrown synchronously, + * escapes openProvider's try/catch as `uncaughtException`, and `index.ts` + * exits the pod - which would crashloop the whole event-tracker on a + * misconfigured WSS URL even when a healthy fallback is configured. + * + * The listener is a no-op: failures still reject `provider.ready` via + * ethers' onerror (assigned shortly after we return), and that rejection + * is what openProvider catches to walk to the fallback. We just need + * *some* error listener to be on the ws by the time the connection + * attempt resolves. + */ const defaultFactory: ProviderFactory = (wssUrl) => - new ethers.WebSocketProvider(wssUrl); + new ethers.WebSocketProvider(() => { + const socket = new WebSocket(wssUrl); + socket.on("error", () => { + // intentionally empty - see comment on defaultFactory + }); + return socket; + }); const defaultOnPermanentFailure = (chainId: number): void => { logger.error( diff --git a/keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts b/keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts new file mode 100644 index 000000000..b7758162a --- /dev/null +++ b/keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ChainProviderManager } from "../../src/chains/provider-manager"; + +/** + * Live-network test for the failure modes that crashed the pod during + * KEEP-434 manual verification: a misconfigured WSS URL throwing at the + * underlying `ws` socket level (DNS NXDOMAIN, ECONNREFUSED, non-WS server) + * was leaking past `openProvider`'s try/catch and reaching + * `process.on("uncaughtException")` in `index.ts`, which exits the pod. + * + * Uses `ws://127.0.0.1:1` (port 1 is unused on Linux, ECONNREFUSED is + * synchronous and deterministic - no real DNS or remote network). + * + * Exercises the REAL `defaultFactory` path (no injected mock) because the + * bug is in the ws library's interaction with ethers' WebSocketProvider, + * which the unit-test MockProvider does not simulate. + */ +describe("ChainProviderManager (real ws): bad-URL safety", () => { + let onPermanentFailure: () => void; + let manager: ChainProviderManager; + + // Capture any uncaughtException that escapes during a test. Vitest + // installs its own listener for failure reporting; we record errors + // alongside it without removing it. + const capturedExceptions: unknown[] = []; + const listener = (err: unknown): void => { + capturedExceptions.push(err); + }; + + beforeEach(() => { + capturedExceptions.length = 0; + process.on("uncaughtException", listener); + onPermanentFailure = (): void => { + // No-op so reconnect exhaustion does not call process.exit during + // tests. Real prod uses defaultOnPermanentFailure which exits. + }; + manager = new ChainProviderManager({ onPermanentFailure }); + }); + + afterEach(async () => { + process.off("uncaughtException", listener); + await manager.destroy(); + }); + + it("rejects cleanly when the only URL is unreachable (ECONNREFUSED)", async () => { + // Pre-fix: this leaked `Error: connect ECONNREFUSED 127.0.0.1:1` + // to process.uncaughtException via ws's EventEmitter throw, the + // fatal handler in index.ts called process.exit(1), and K8s would + // crashloop the pod. Post-fix: the no-op error listener in + // defaultFactory keeps the ws emit harmless and the rejection + // surfaces through the awaited path. + await expect( + manager.getOrCreateProvider(31337, "ws://127.0.0.1:1"), + ).rejects.toThrow(/ws:\/\/127\.0\.0\.1:1/); + + expect(capturedExceptions).toEqual([]); + }); + + it("walks to the fallback when the primary is unreachable, surfacing both failures if both die", async () => { + // Both URLs unreachable - same crash path as above for each + // attempt. The aggregate error must mention both URLs and no + // uncaughtException is allowed. + await expect( + manager.getOrCreateProvider( + 31_338, + "ws://127.0.0.1:1", + "ws://127.0.0.1:2", + ), + ).rejects.toThrow(/ws:\/\/127\.0\.0\.1:1.*ws:\/\/127\.0\.0\.1:2/s); + + expect(capturedExceptions).toEqual([]); + }); +}); diff --git a/keeperhub-events/pnpm-lock.yaml b/keeperhub-events/pnpm-lock.yaml index 5cda7f540..141916051 100644 --- a/keeperhub-events/pnpm-lock.yaml +++ b/keeperhub-events/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: uuid: specifier: ^14.0.0 version: 14.0.0 + ws: + specifier: ^8.17.1 + version: 8.17.1 devDependencies: '@types/node': specifier: ^24.12.2 @@ -42,6 +45,9 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 tsup: specifier: ^8.3.5 version: 8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) @@ -847,6 +853,9 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@4.1.2': resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} @@ -2319,6 +2328,10 @@ snapshots: '@types/uuid@10.0.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.2 + '@vitest/expect@4.1.2': dependencies: '@standard-schema/spec': 1.1.0 From b01064bf21c51606a2aa1f68dcfe5972dd8be222 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 13:15:42 +1000 Subject: [PATCH 05/19] fix(superfluid): KEEP-415 address must-fix items from review - create-pool: flatten the (bool,bool) PoolConfig tuple into two top-level bool inputs. The DSL only special-cases tuple[] in buildInputField, so a bare tuple fell through to a freeform JSON text box. reshapeArgsForAbi rebuilds the tuple from the flat args before encoding. - net-flow: rename the existing CFA-only action to get-cfa-net-flow and add a new get-net-flow backed by gdaForwarder.getNetFlow, which combines CFA streams and GDA pool flows. The e2e script already proved the combined reading is what users want for mixed CFA/GDA workflows. - flow-rate decimals: drop decimals: 18 from int96 flowRate outputs. Flow rates are wei/sec rates, not token amounts. Kept on uint256 deposit/owedDeposit/balance fields. output.decimals has no consumer in the codebase today (outputToAbiParameter strips it; codegen only annotates input decimals), but the wrong annotation is misleading. --- protocols/superfluid.ts | 59 ++++++++++--- tests/unit/superfluid-protocol.test.ts | 110 +++++++++++++++++++++---- 2 files changed, 143 insertions(+), 26 deletions(-) diff --git a/protocols/superfluid.ts b/protocols/superfluid.ts index 059732bbe..207aeb949 100644 --- a/protocols/superfluid.ts +++ b/protocols/superfluid.ts @@ -183,6 +183,16 @@ const GDA_FORWARDER_ABI = JSON.stringify([ ], outputs: [{ name: "", type: "bool" }], }, + { + type: "function", + name: "getNetFlow", + stateMutability: "view", + inputs: [ + { name: "token", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "", type: "int96" }], + }, ]); const SUPER_TOKEN_ABI = JSON.stringify([ @@ -352,7 +362,6 @@ export default defineProtocol({ name: "flowRate", type: "int96", label: "Flow Rate (wei/sec)", - decimals: 18, }, { name: "deposit", @@ -369,10 +378,10 @@ export default defineProtocol({ ], }, { - slug: "get-net-flow", - label: "Read Net Flow Rate of an Address", + slug: "get-cfa-net-flow", + label: "Read CFA Net Flow Rate of an Address", description: - "Read an address's net flow rate for a SuperToken (positive = net receiver, negative = net sender)", + "Read an address's net flow rate from CFA streams only (positive = net receiver, negative = net sender). Excludes GDA pool distributions -- use get-net-flow for the combined CFA+GDA reading.", type: "read", contract: "cfaForwarder", function: "getAccountFlowrate", @@ -380,12 +389,31 @@ export default defineProtocol({ { name: "token", type: "address", label: "SuperToken Address" }, { name: "account", type: "address", label: "Account Address" }, ], + outputs: [ + { + name: "flowRate", + type: "int96", + label: "CFA Net Flow Rate (wei/sec, signed)", + }, + ], + }, + { + slug: "get-net-flow", + label: "Read Net Flow Rate of an Address", + description: + "Read an address's net flow rate for a SuperToken, combining CFA streams and GDA pool distributions (positive = net receiver, negative = net sender). Use get-cfa-net-flow if you need CFA-only.", + type: "read", + contract: "gdaForwarder", + function: "getNetFlow", + inputs: [ + { name: "token", type: "address", label: "SuperToken Address" }, + { name: "account", type: "address", label: "Account Address" }, + ], outputs: [ { name: "flowRate", type: "int96", label: "Net Flow Rate (wei/sec, signed)", - decimals: 18, }, ], }, @@ -401,13 +429,20 @@ export default defineProtocol({ { name: "token", type: "address", label: "SuperToken Address" }, { name: "admin", type: "address", label: "Pool Admin Address" }, { - name: "config", - type: "tuple", - label: "Pool Config", - components: [ - { name: "transferabilityForUnitsOwner", type: "bool" }, - { name: "distributionFromAnyAddress", type: "bool" }, - ], + name: "transferabilityForUnitsOwner", + type: "bool", + label: "Transferability For Units Owner", + default: "false", + helpTip: + "If true, members can transfer their pool units to other addresses. Most pools leave this false.", + }, + { + name: "distributionFromAnyAddress", + type: "bool", + label: "Distribution From Any Address", + default: "false", + helpTip: + "If true, any address can call distribute/distributeFlow into this pool. If false, only the pool admin can. Most pools leave this false.", }, ], }, diff --git a/tests/unit/superfluid-protocol.test.ts b/tests/unit/superfluid-protocol.test.ts index 530ca6fa9..1e39f2254 100644 --- a/tests/unit/superfluid-protocol.test.ts +++ b/tests/unit/superfluid-protocol.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { reshapeArgsForAbi } from "@/lib/abi/struct-args"; import superfluidProtocol, { CFA_FORWARDER_ADDRESS, GDA_FORWARDER_ADDRESS, @@ -7,6 +8,8 @@ import superfluidProtocol, { const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; const KEBAB_CASE_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; +const CFA_MENTION_REGEX = /CFA/; +const GDA_MENTION_REGEX = /GDA/; const EXPECTED_CHAINS: string[] = [...SUPERFLUID_CHAIN_IDS]; const CFA_FORWARDER = CFA_FORWARDER_ADDRESS; @@ -148,7 +151,7 @@ describe("Superfluid protocol", () => { expect(action?.function).toBe("getFlowInfo"); }); - it("declares the four expected outputs with decimals on rate/deposit", () => { + it("declares the four expected outputs, decimals only on token-amount fields", () => { const action = findAction("get-flow"); const outputs = action?.outputs ?? []; expect(outputs.map((o) => o.name)).toEqual([ @@ -157,26 +160,59 @@ describe("Superfluid protocol", () => { "deposit", "owedDeposit", ]); - expect(outputs.find((o) => o.name === "flowRate")?.decimals).toBe(18); + expect( + outputs.find((o) => o.name === "flowRate")?.decimals + ).toBeUndefined(); expect(outputs.find((o) => o.name === "deposit")?.decimals).toBe(18); + expect(outputs.find((o) => o.name === "owedDeposit")?.decimals).toBe(18); }); }); - describe("get-net-flow action", () => { + describe("get-cfa-net-flow action", () => { it("is declared as a read action against cfaForwarder.getAccountFlowrate", () => { - const action = findAction("get-net-flow"); + const action = findAction("get-cfa-net-flow"); expect(action).toBeDefined(); expect(action?.type).toBe("read"); expect(action?.contract).toBe("cfaForwarder"); expect(action?.function).toBe("getAccountFlowrate"); }); - it("returns flowRate as int96 with decimals: 18", () => { + it("returns flowRate as int96 without decimals (rate, not token amount)", () => { + const action = findAction("get-cfa-net-flow"); + const out = action?.outputs?.[0]; + expect(out?.name).toBe("flowRate"); + expect(out?.type).toBe("int96"); + expect(out?.decimals).toBeUndefined(); + }); + + it("description points users to get-net-flow for combined readings", () => { + const action = findAction("get-cfa-net-flow"); + expect(action?.description).toContain("get-net-flow"); + }); + }); + + describe("get-net-flow action", () => { + it("is declared as a read action against gdaForwarder.getNetFlow", () => { + const action = findAction("get-net-flow"); + expect(action).toBeDefined(); + expect(action?.type).toBe("read"); + expect(action?.contract).toBe("gdaForwarder"); + expect(action?.function).toBe("getNetFlow"); + }); + + it("returns flowRate as int96 without decimals (rate, not token amount)", () => { const action = findAction("get-net-flow"); const out = action?.outputs?.[0]; expect(out?.name).toBe("flowRate"); expect(out?.type).toBe("int96"); - expect(out?.decimals).toBe(18); + expect(out?.decimals).toBeUndefined(); + }); + + it("description signals it covers both CFA and GDA flows", () => { + const action = findAction("get-net-flow"); + const desc = action?.description ?? ""; + expect(desc).toMatch(CFA_MENTION_REGEX); + expect(desc).toMatch(GDA_MENTION_REGEX); }); }); @@ -194,7 +230,7 @@ describe("Superfluid protocol", () => { expect([...unique][0]).toBe(GDA_FORWARDER); }); - it("ships an inline ABI with the 5 expected functions", () => { + it("ships an inline ABI with the 6 expected functions", () => { const contract = superfluidProtocol.contracts.gdaForwarder; const abi = JSON.parse(contract.abi as string) as Array<{ type: string; @@ -210,6 +246,7 @@ describe("Superfluid protocol", () => { "createPool", "distribute", "distributeFlow", + "getNetFlow", "updateMemberUnits", ].sort() ); @@ -239,7 +276,7 @@ describe("Superfluid protocol", () => { }); describe("GDA actions", () => { - it("declares the five expected GDA action slugs", () => { + it("declares the six expected GDA action slugs", () => { const slugs = superfluidProtocol.actions .filter((a) => a.contract === "gdaForwarder") .map((a) => a.slug) @@ -250,19 +287,64 @@ describe("Superfluid protocol", () => { "create-pool", "distribute", "distribute-flow", + "get-net-flow", "update-member-units", ].sort() ); }); - it("create-pool declares the config tuple input via components", () => { + it("create-pool flattens the PoolConfig tuple into two top-level bool inputs", () => { const action = findAction("create-pool"); - const config = action?.inputs.find((i) => i.name === "config"); - expect(config?.type).toBe("tuple"); - expect(config?.components?.map((c) => c.name)).toEqual([ + const inputNames = action?.inputs.map((i) => i.name); + expect(inputNames).toEqual([ + "token", + "admin", "transferabilityForUnitsOwner", "distributionFromAnyAddress", ]); + const transferability = action?.inputs.find( + (i) => i.name === "transferabilityForUnitsOwner" + ); + const distribution = action?.inputs.find( + (i) => i.name === "distributionFromAnyAddress" + ); + expect(transferability?.type).toBe("bool"); + expect(distribution?.type).toBe("bool"); + }); + + it("create-pool flat args reshape into the (bool,bool) tuple expected by the ABI", () => { + const contract = superfluidProtocol.contracts.gdaForwarder; + const abi = JSON.parse(contract.abi as string) as Array<{ + type: string; + name?: string; + inputs?: Array<{ + name: string; + type: string; + components?: Array<{ name: string; type: string }>; + }>; + }>; + const createPoolAbi = abi.find( + (f) => f.type === "function" && f.name === "createPool" + ); + expect(createPoolAbi).toBeDefined(); + + const flatArgs = [ + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + true, + false, + ]; + const reshaped = reshapeArgsForAbi(flatArgs, { + inputs: createPoolAbi?.inputs, + }); + expect(reshaped).toEqual([ + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + { + transferabilityForUnitsOwner: true, + distributionFromAnyAddress: false, + }, + ]); }); it("distribute-flow uses int96 flowRate with the shared helpTip", () => { @@ -362,8 +444,8 @@ describe("Superfluid protocol", () => { }); describe("overall integrity", () => { - it("declares 15 actions in total", () => { - expect(superfluidProtocol.actions).toHaveLength(15); + it("declares 16 actions in total", () => { + expect(superfluidProtocol.actions).toHaveLength(16); }); it("every action slug is unique kebab-case", () => { From be092fe1bb55a53e0eca306ad0e7f2d26b30a4fc Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 13:24:59 +1000 Subject: [PATCH 06/19] test: KEEP-434 add DNS NXDOMAIN integration case and ethers compatibility unit test Two follow-on tests for the bad-URL crash fix in the previous commit: 1. Integration: getOrCreateProvider against wss://does-not-exist-keep434.invalid/ exercises the dns.lookup ENOTFOUND error path. That was the original failure mode encountered during manual verification - the error came from node:dns rather than ws.ClientRequest, but the same EventEmitter-throw rule crashed the pod. Confirms the fix covers both error origins. 2. Unit: locks in the ethers invariant the fix relies on. ethers.WebSocketProvider(creator) does not call removeAllListeners on the underlying socket and does not strip EventEmitter-style listeners. If a future ethers upgrade changes that, this test breaks loudly so we do not silently lose the defensive listener and reintroduce the crash-on-bad-URL bug. Both were verified to fail when the fix is reverted (the integration case fails with leaked uncaughtException; the unit case continues to pass since it tests ethers' own behavior, not defaultFactory's choice). --- .../provider-manager-bad-url.test.ts | 20 ++++ .../provider-manager-default-factory.test.ts | 100 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 keeperhub-events/event-tracker/tests/unit/provider-manager-default-factory.test.ts diff --git a/keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts b/keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts index b7758162a..9188c1c93 100644 --- a/keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts +++ b/keeperhub-events/event-tracker/tests/integration/provider-manager-bad-url.test.ts @@ -70,4 +70,24 @@ describe("ChainProviderManager (real ws): bad-URL safety", () => { expect(capturedExceptions).toEqual([]); }); + + it("rejects cleanly when the host does not resolve (DNS NXDOMAIN)", async () => { + // Different failure mode than ECONNREFUSED: the .invalid TLD is + // reserved per RFC 6761 and guaranteed to never resolve, so this + // exercises the dns.lookup error path (`getaddrinfo ENOTFOUND`). + // That was the original failure mode I hit during KEEP-434 manual + // verification - the error came from `node:dns` rather than + // `ws.ClientRequest`, but the same EventEmitter-throw rule made it + // crash the pod. The fix's defensive listener covers both because + // ws emits 'error' on the WebSocket regardless of which network + // layer surfaced the failure. + await expect( + manager.getOrCreateProvider( + 31_339, + "wss://does-not-exist-keep434.invalid/", + ), + ).rejects.toThrow(/does-not-exist-keep434\.invalid/); + + expect(capturedExceptions).toEqual([]); + }); }); diff --git a/keeperhub-events/event-tracker/tests/unit/provider-manager-default-factory.test.ts b/keeperhub-events/event-tracker/tests/unit/provider-manager-default-factory.test.ts new file mode 100644 index 000000000..ae1933285 --- /dev/null +++ b/keeperhub-events/event-tracker/tests/unit/provider-manager-default-factory.test.ts @@ -0,0 +1,100 @@ +import { EventEmitter } from "node:events"; +import { ethers } from "ethers"; +import { afterEach, describe, expect, it } from "vitest"; + +/** + * Locks in the invariant that the bad-URL crash fix + * (provider-manager.ts:165 defaultFactory) relies on: + * + * `ethers.WebSocketProvider(creator)` does NOT remove or replace + * EventEmitter-style listeners attached to the underlying socket. ethers + * assigns to `socket.onerror` (a property), which coexists with + * `socket.on("error", ...)` listeners on the same EventEmitter. If a + * future ethers upgrade started calling `socket.removeAllListeners` + * inside `_start()`, our defensive listener would be stripped and the + * crash-on-bad-URL bug would resurface. + * + * Distinct from `provider-manager-bad-url.test.ts` which uses real + * network. This one is offline and runs as a fast unit check. + */ + +class MockWsSocket extends EventEmitter { + url: string; + onerror: ((ev: unknown) => void) | null = null; + onopen: ((ev: unknown) => void) | null = null; + onclose: ((ev: unknown) => void) | null = null; + onmessage: ((ev: unknown) => void) | null = null; + + constructor(url: string) { + super(); + this.url = url; + } + send(_payload: string): void { + // not used in this test + } + close(_code?: number, _reason?: string): void { + // not used in this test + } +} + +describe("defaultFactory creator pattern: ethers compatibility", () => { + const providers: ethers.WebSocketProvider[] = []; + + afterEach(async () => { + while (providers.length) { + const p = providers.pop(); + if (p) { + await p.destroy().catch(() => undefined); + } + } + }); + + it("EventEmitter on('error') listener attached inside the creator survives ethers' WebSocketProvider construction", () => { + // Mirror what defaultFactory does: create a socket, attach the + // defensive `on("error", noop)` listener synchronously, then hand + // it to ethers via the creator overload. + const socket = new MockWsSocket("ws://test/"); + socket.on("error", () => { + // Mirrors the no-op in defaultFactory. + }); + expect(socket.listenerCount("error")).toBe(1); + + const provider = new ethers.WebSocketProvider( + () => socket as unknown as ethers.WebSocketLike, + ); + providers.push(provider); + + // The defensive listener must still be attached after ethers wires + // up its own onerror. ethers uses property assignment (independent + // of EventEmitter listener storage), so listenerCount stays >= 1. + // This is the load-bearing invariant: if it ever drops to 0, an + // early `error` event from the underlying ws would re-throw and + // crash the pod again. + expect(socket.listenerCount("error")).toBeGreaterThanOrEqual(1); + }); + + it("ethers does not call removeAllListeners on the socket after construction", () => { + // Belt-and-suspenders: even if ethers attaches its own + // EventEmitter-style listener on top of ours, what matters is that + // it does not remove ours. Spy removeAllListeners and assert it is + // never called by ethers. + const socket = new MockWsSocket("ws://test/"); + let removeAllCalled = 0; + const orig = socket.removeAllListeners.bind(socket); + socket.removeAllListeners = ((event?: string | symbol) => { + removeAllCalled++; + return orig(event); + }) as typeof socket.removeAllListeners; + + socket.on("error", () => { + // defensive + }); + + const provider = new ethers.WebSocketProvider( + () => socket as unknown as ethers.WebSocketLike, + ); + providers.push(provider); + + expect(removeAllCalled).toBe(0); + }); +}); From 67a504d21e4c53f410c3ca4f594758baf295b0aa Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 13:52:28 +1000 Subject: [PATCH 07/19] test(superfluid): KEEP-415 add Sepolia on-chain integration test Mirrors tests/integration/protocol-wrapped-onchain.test.ts. Gated on INTEGRATION_TEST_RPC_URL so it skips in CI without a live RPC. Covers four assertions, focused on what the unit tests can't see: - get-flow / get-cfa-net-flow: eth_call simulates against the real CFA forwarder on Sepolia and decodes the result, proving selector dispatch and ABI shape are correct. - get-net-flow: same pattern against the GDA forwarder. This is the new action introduced earlier in this branch; the test confirms the contract -> function wiring on-chain. - create-pool: builds calldata from the flat (token, admin, bool, bool) inputs through reshape + coerce, then estimateGas against the GDA forwarder. Asserts the failure mode (if any) is a business revert, not an encoding error -- proving the flattened tuple shape produces calldata the contract accepts. Uses fUSDCx (0xb598...443B) as the SuperToken; the forwarders validate the token argument against the host registry and revert for unknown addresses, so a real Sepolia SuperToken is required for the read calls to decode. --- .../protocol-superfluid-onchain.test.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/integration/protocol-superfluid-onchain.test.ts diff --git a/tests/integration/protocol-superfluid-onchain.test.ts b/tests/integration/protocol-superfluid-onchain.test.ts new file mode 100644 index 000000000..3f938c0ab --- /dev/null +++ b/tests/integration/protocol-superfluid-onchain.test.ts @@ -0,0 +1,181 @@ +/** + * Superfluid On-Chain Integration Tests + * + * Verifies that the Superfluid protocol definition produces valid calldata + * the deployed CFA and GDA forwarders accept on Sepolia. Catches contract + * dispatch and ABI-shape mistakes the unit-test layer cannot see. + * + * Gated on INTEGRATION_TEST_RPC_URL env var - skipped in CI without it. + */ + +import { ethers } from "ethers"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +// `lib/rpc/providers` transitively imports `lib/safe-fetch` (via the +// safe-ethers adapter), which declares `import "server-only"` and would +// otherwise throw under vitest's Node runtime. +vi.mock("server-only", () => ({})); + +import { coerceArgsForAbi, reshapeArgsForAbi } from "@/lib/abi/struct-args"; +import type { + ProtocolAction, + ProtocolContract, + ProtocolDefinition, +} from "@/lib/protocol-registry"; +import { getRpcProviderFromUrls } from "@/lib/rpc/provider-factory"; +import type { RpcProviderManager } from "@/lib/rpc/providers"; +import { getRpcUrlByChainId } from "@/lib/rpc/rpc-config"; +import superfluidDef from "@/protocols/superfluid"; + +const RPC_URL = process.env.INTEGRATION_TEST_RPC_URL; +const CHAIN_ID = "11155111"; +const SEPOLIA_CHAIN_ID = 11_155_111; +const TEST_ADDRESS = "0x0000000000000000000000000000000000000001"; +// fUSDCx on Sepolia. The forwarders validate the token argument against the +// Superfluid host registry and revert for unknown addresses, so reads need a +// real SuperToken. fUSDCx is the canonical Sepolia test token; an account +// with no flows returns 0, which is exactly what we want to assert decoding. +const SEPOLIA_FUSDCX = "0xb598E6C621618a9f63788816ffb50Ee2862D443B"; + +function buildCalldata( + protocol: ProtocolDefinition, + actionSlug: string, + sampleInputs: Record +): { + to: string; + data: string; + action: ProtocolAction; + contract: ProtocolContract; +} { + const action = protocol.actions.find((a) => a.slug === actionSlug); + if (!action) { + throw new Error(`Action ${actionSlug} not found`); + } + + const contract = protocol.contracts[action.contract]; + if (!contract.abi) { + throw new Error(`Contract ${action.contract} has no ABI`); + } + + const contractAddress = contract.addresses[CHAIN_ID]; + if (!contractAddress) { + throw new Error(`Contract ${action.contract} not on chain ${CHAIN_ID}`); + } + + const rawArgs = action.inputs.map((inp) => { + const val = sampleInputs[inp.name] ?? inp.default ?? ""; + return val; + }); + + const abi = JSON.parse(contract.abi); + const functionAbi = abi.find( + (f: { name: string; type: string }) => + f.type === "function" && f.name === action.function + ); + // Reproduce the production pipeline: reshape flat args into tuples per ABI, + // then coerce stringly-typed leaves (bool "false" -> false) before encoding. + const reshaped = reshapeArgsForAbi(rawArgs, functionAbi); + const args = coerceArgsForAbi(reshaped, functionAbi); + const iface = new ethers.Interface(abi); + const data = iface.encodeFunctionData(action.function, args); + + return { to: contractAddress, data, action, contract }; +} + +describe.skipIf(!RPC_URL)("Superfluid on-chain integration", () => { + let manager: RpcProviderManager; + + beforeAll(async () => { + if (!RPC_URL) { + return; + } + manager = await getRpcProviderFromUrls( + RPC_URL, + getRpcUrlByChainId(SEPOLIA_CHAIN_ID, "fallback"), + SEPOLIA_CHAIN_ID, + "sepolia" + ); + }); + + it("get-flow: eth_call returns the four expected CFA flow-info outputs", async () => { + const { to, data, contract } = buildCalldata(superfluidDef, "get-flow", { + token: SEPOLIA_FUSDCX, + sender: TEST_ADDRESS, + receiver: TEST_ADDRESS, + }); + + const result = await manager.executeWithFailover((p) => + p.call({ to, data }) + ); + + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getFlowInfo", result); + expect(decoded).toBeDefined(); + expect(decoded).toHaveLength(4); + }, 15_000); + + it("get-cfa-net-flow: dispatches to cfaForwarder.getAccountFlowrate", async () => { + const { to, data, contract } = buildCalldata( + superfluidDef, + "get-cfa-net-flow", + { + token: SEPOLIA_FUSDCX, + account: TEST_ADDRESS, + } + ); + + const result = await manager.executeWithFailover((p) => + p.call({ to, data }) + ); + + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getAccountFlowrate", result); + expect(typeof decoded[0]).toBe("bigint"); + }, 15_000); + + it("get-net-flow: dispatches to gdaForwarder.getNetFlow", async () => { + const { to, data, contract } = buildCalldata( + superfluidDef, + "get-net-flow", + { + token: SEPOLIA_FUSDCX, + account: TEST_ADDRESS, + } + ); + + const result = await manager.executeWithFailover((p) => + p.call({ to, data }) + ); + + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getNetFlow", result); + expect(typeof decoded[0]).toBe("bigint"); + }, 15_000); + + it("create-pool: flat bool inputs reshape into a tuple the GDA forwarder accepts", async () => { + const { to, data } = buildCalldata(superfluidDef, "create-pool", { + token: SEPOLIA_FUSDCX, + admin: TEST_ADDRESS, + transferabilityForUnitsOwner: "false", + distributionFromAnyAddress: "false", + }); + + try { + await manager.executeWithFailover((p) => + p.estimateGas({ + to, + data, + from: TEST_ADDRESS, + }) + ); + } catch (error) { + const msg = String(error); + expect(msg).not.toContain("INVALID_ARGUMENT"); + expect(msg).not.toContain("could not decode"); + expect(msg).not.toContain("invalid function"); + } + }, 15_000); +}); From cbb4c0bb33ffecbf386c53c49beddbd14e78ef4d Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 13:58:10 +1000 Subject: [PATCH 08/19] test(superfluid): KEEP-415 expand integration coverage to all 16 actions Every declared action now has at least one dispatch test that runs against live Sepolia when INTEGRATION_TEST_RPC_URL is set: Reads (5) -- eth_call + decode: get-flow, get-cfa-net-flow, get-net-flow, get-super-token-balance, get-underlying-token Writes (11) -- estimateGas + assert the failure mode (if any) is a business revert, not an ABI/encoding error: create-flow, update-flow, delete-flow, create-pool, update-member-units, distribute, distribute-flow, connect-pool, wrap, unwrap, grant-flow-operator Stronger assertions: - Read tests verify the action lands at the correct forwarder (CFA_FORWARDER_ADDRESS / GDA_FORWARDER_ADDRESS). - get-underlying-token decodes the result and asserts it equals the expected fUSDC address, proving we read the right slot. - Final test cross-checks the slug list against the protocol definition so adding a new action without a dispatch test fails CI. buildCalldata gains an optional contractAddressOverride argument to support userSpecifiedAddress contracts (the SuperToken family). The encoding-error checker now runs against a single regex (ENCODING_ERROR_RE) inside each test rather than inline assertions in a helper, satisfying the noMisplacedAssertion lint rule. --- .../protocol-superfluid-onchain.test.ts | 335 ++++++++++++++---- 1 file changed, 267 insertions(+), 68 deletions(-) diff --git a/tests/integration/protocol-superfluid-onchain.test.ts b/tests/integration/protocol-superfluid-onchain.test.ts index 3f938c0ab..c98786349 100644 --- a/tests/integration/protocol-superfluid-onchain.test.ts +++ b/tests/integration/protocol-superfluid-onchain.test.ts @@ -2,8 +2,12 @@ * Superfluid On-Chain Integration Tests * * Verifies that the Superfluid protocol definition produces valid calldata - * the deployed CFA and GDA forwarders accept on Sepolia. Catches contract - * dispatch and ABI-shape mistakes the unit-test layer cannot see. + * the deployed CFA forwarder, GDA forwarder, and SuperToken contracts + * accept on Sepolia. Catches contract dispatch and ABI-shape mistakes the + * unit-test layer cannot see. + * + * Coverage: every action declared by the protocol gets at least one + * dispatch test (read decodes, write encodes without ABI errors). * * Gated on INTEGRATION_TEST_RPC_URL env var - skipped in CI without it. */ @@ -25,22 +29,45 @@ import type { import { getRpcProviderFromUrls } from "@/lib/rpc/provider-factory"; import type { RpcProviderManager } from "@/lib/rpc/providers"; import { getRpcUrlByChainId } from "@/lib/rpc/rpc-config"; -import superfluidDef from "@/protocols/superfluid"; +import superfluidDef, { + CFA_FORWARDER_ADDRESS, + GDA_FORWARDER_ADDRESS, +} from "@/protocols/superfluid"; const RPC_URL = process.env.INTEGRATION_TEST_RPC_URL; const CHAIN_ID = "11155111"; const SEPOLIA_CHAIN_ID = 11_155_111; const TEST_ADDRESS = "0x0000000000000000000000000000000000000001"; + // fUSDCx on Sepolia. The forwarders validate the token argument against the -// Superfluid host registry and revert for unknown addresses, so reads need a -// real SuperToken. fUSDCx is the canonical Sepolia test token; an account -// with no flows returns 0, which is exactly what we want to assert decoding. +// Superfluid host registry and revert for unknown addresses, so reads need +// a real SuperToken. fUSDCx is the canonical Sepolia test token; an account +// with no flows returns 0, which is exactly what we want for assertions. const SEPOLIA_FUSDCX = "0xb598E6C621618a9f63788816ffb50Ee2862D443B"; +// Underlying fUSDC for fUSDCx; getUnderlyingToken should return this +// (proves we're decoding the right slot, not just any address). +const SEPOLIA_FUSDC = "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; + +// Common dummy values for write-action inputs. estimateGas will revert +// with business reverts for most of these (insufficient balance, no flow +// to update, etc.) -- that is fine; we only assert the failure mode is +// not an ABI/encoding error. +const DUMMY_AMOUNT_WEI = "1000000000000000000"; // 1e18 +const DUMMY_FLOW_RATE = "1000000"; // wei/sec, small but non-zero +const DUMMY_UNITS = "1"; +const DUMMY_PERMISSIONS_ALL = "7"; // create+update+delete bitmap +const DUMMY_BYTES = "0x"; + +// Markers we treat as failures: ABI/calldata mistakes the test should catch. +// Anything else (require(false), insufficient balance, etc.) is a business +// revert -- expected when calling write ops from an unfunded test address. +const ENCODING_ERROR_RE = /INVALID_ARGUMENT|could not decode|invalid function/; function buildCalldata( protocol: ProtocolDefinition, actionSlug: string, - sampleInputs: Record + sampleInputs: Record, + contractAddressOverride?: string ): { to: string; data: string; @@ -57,23 +84,26 @@ function buildCalldata( throw new Error(`Contract ${action.contract} has no ABI`); } - const contractAddress = contract.addresses[CHAIN_ID]; + const contractAddress = + contractAddressOverride ?? contract.addresses[CHAIN_ID]; if (!contractAddress) { - throw new Error(`Contract ${action.contract} not on chain ${CHAIN_ID}`); + throw new Error( + `Contract ${action.contract} not on chain ${CHAIN_ID} and no override given` + ); } - const rawArgs = action.inputs.map((inp) => { - const val = sampleInputs[inp.name] ?? inp.default ?? ""; - return val; - }); + const rawArgs = action.inputs.map( + (inp) => sampleInputs[inp.name] ?? inp.default ?? "" + ); const abi = JSON.parse(contract.abi); const functionAbi = abi.find( (f: { name: string; type: string }) => f.type === "function" && f.name === action.function ); - // Reproduce the production pipeline: reshape flat args into tuples per ABI, - // then coerce stringly-typed leaves (bool "false" -> false) before encoding. + // Reproduce the production pipeline: reshape flat args into tuples per + // ABI, then coerce stringly-typed leaves (bool "false" -> false) before + // encoding. Same order as plugins/web3/steps/write-contract-core.ts. const reshaped = reshapeArgsForAbi(rawArgs, functionAbi); const args = coerceArgsForAbi(reshaped, functionAbi); const iface = new ethers.Interface(abi); @@ -97,85 +127,254 @@ describe.skipIf(!RPC_URL)("Superfluid on-chain integration", () => { ); }); - it("get-flow: eth_call returns the four expected CFA flow-info outputs", async () => { - const { to, data, contract } = buildCalldata(superfluidDef, "get-flow", { - token: SEPOLIA_FUSDCX, - sender: TEST_ADDRESS, - receiver: TEST_ADDRESS, - }); + // -- helpers ------------------------------------------------------------- + async function callAndDecode( + slug: string, + inputs: Record, + contractAddressOverride?: string + ): Promise<{ + decoded: ethers.Result; + contract: ProtocolContract; + action: ProtocolAction; + to: string; + }> { + const { to, data, contract, action } = buildCalldata( + superfluidDef, + slug, + inputs, + contractAddressOverride + ); const result = await manager.executeWithFailover((p) => p.call({ to, data }) ); - const abi = JSON.parse(contract.abi as string); const iface = new ethers.Interface(abi); - const decoded = iface.decodeFunctionResult("getFlowInfo", result); - expect(decoded).toBeDefined(); + const decoded = iface.decodeFunctionResult(action.function, result); + return { decoded, contract, action, to }; + } + + // Returns the error message from estimateGas, or "" if it succeeded. The + // test then asserts the message doesn't contain ABI-error markers. + async function estimateGasError( + slug: string, + inputs: Record, + contractAddressOverride?: string + ): Promise { + const { to, data } = buildCalldata( + superfluidDef, + slug, + inputs, + contractAddressOverride + ); + try { + await manager.executeWithFailover((p) => + p.estimateGas({ to, data, from: TEST_ADDRESS }) + ); + return ""; + } catch (error) { + return String(error); + } + } + + // -- CFA reads ----------------------------------------------------------- + + it("get-flow: returns the four expected CFA flow-info outputs", async () => { + const { decoded, to } = await callAndDecode("get-flow", { + token: SEPOLIA_FUSDCX, + sender: TEST_ADDRESS, + receiver: TEST_ADDRESS, + }); + expect(to).toBe(CFA_FORWARDER_ADDRESS); expect(decoded).toHaveLength(4); }, 15_000); it("get-cfa-net-flow: dispatches to cfaForwarder.getAccountFlowrate", async () => { - const { to, data, contract } = buildCalldata( - superfluidDef, - "get-cfa-net-flow", - { - token: SEPOLIA_FUSDCX, - account: TEST_ADDRESS, - } - ); + const { decoded, to } = await callAndDecode("get-cfa-net-flow", { + token: SEPOLIA_FUSDCX, + account: TEST_ADDRESS, + }); + expect(to).toBe(CFA_FORWARDER_ADDRESS); + expect(typeof decoded[0]).toBe("bigint"); + }, 15_000); - const result = await manager.executeWithFailover((p) => - p.call({ to, data }) - ); + // -- GDA reads ----------------------------------------------------------- - const abi = JSON.parse(contract.abi as string); - const iface = new ethers.Interface(abi); - const decoded = iface.decodeFunctionResult("getAccountFlowrate", result); + it("get-net-flow: dispatches to gdaForwarder.getNetFlow (combined CFA+GDA)", async () => { + const { decoded, to } = await callAndDecode("get-net-flow", { + token: SEPOLIA_FUSDCX, + account: TEST_ADDRESS, + }); + expect(to).toBe(GDA_FORWARDER_ADDRESS); expect(typeof decoded[0]).toBe("bigint"); }, 15_000); - it("get-net-flow: dispatches to gdaForwarder.getNetFlow", async () => { - const { to, data, contract } = buildCalldata( - superfluidDef, - "get-net-flow", - { - token: SEPOLIA_FUSDCX, - account: TEST_ADDRESS, - } + // -- SuperToken reads (userSpecifiedAddress) ----------------------------- + + it("get-super-token-balance: dispatches to the user-supplied SuperToken", async () => { + const { decoded } = await callAndDecode( + "get-super-token-balance", + { account: TEST_ADDRESS }, + SEPOLIA_FUSDCX ); + expect(typeof decoded[0]).toBe("bigint"); + }, 15_000); - const result = await manager.executeWithFailover((p) => - p.call({ to, data }) + it("get-underlying-token: returns the fUSDC underlying for fUSDCx", async () => { + const { decoded } = await callAndDecode( + "get-underlying-token", + {}, + SEPOLIA_FUSDCX + ); + expect((decoded[0] as string).toLowerCase()).toBe( + SEPOLIA_FUSDC.toLowerCase() ); + }, 15_000); - const abi = JSON.parse(contract.abi as string); - const iface = new ethers.Interface(abi); - const decoded = iface.decodeFunctionResult("getNetFlow", result); - expect(typeof decoded[0]).toBe("bigint"); + // -- CFA writes ---------------------------------------------------------- + + it("create-flow: encodes against cfaForwarder.createFlow", async () => { + const msg = await estimateGasError("create-flow", { + token: SEPOLIA_FUSDCX, + sender: TEST_ADDRESS, + receiver: TEST_ADDRESS, + flowRate: DUMMY_FLOW_RATE, + userData: DUMMY_BYTES, + }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + it("update-flow: encodes against cfaForwarder.updateFlow", async () => { + const msg = await estimateGasError("update-flow", { + token: SEPOLIA_FUSDCX, + sender: TEST_ADDRESS, + receiver: TEST_ADDRESS, + flowRate: DUMMY_FLOW_RATE, + userData: DUMMY_BYTES, + }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); }, 15_000); - it("create-pool: flat bool inputs reshape into a tuple the GDA forwarder accepts", async () => { - const { to, data } = buildCalldata(superfluidDef, "create-pool", { + it("delete-flow: encodes against cfaForwarder.deleteFlow", async () => { + const msg = await estimateGasError("delete-flow", { + token: SEPOLIA_FUSDCX, + sender: TEST_ADDRESS, + receiver: TEST_ADDRESS, + userData: DUMMY_BYTES, + }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + // -- GDA writes ---------------------------------------------------------- + + it("create-pool: flat bool inputs reshape into the (bool,bool) PoolConfig tuple", async () => { + const msg = await estimateGasError("create-pool", { token: SEPOLIA_FUSDCX, admin: TEST_ADDRESS, transferabilityForUnitsOwner: "false", distributionFromAnyAddress: "false", }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); - try { - await manager.executeWithFailover((p) => - p.estimateGas({ - to, - data, - from: TEST_ADDRESS, - }) - ); - } catch (error) { - const msg = String(error); - expect(msg).not.toContain("INVALID_ARGUMENT"); - expect(msg).not.toContain("could not decode"); - expect(msg).not.toContain("invalid function"); - } + it("update-member-units: encodes against gdaForwarder.updateMemberUnits", async () => { + const msg = await estimateGasError("update-member-units", { + pool: TEST_ADDRESS, + member: TEST_ADDRESS, + units: DUMMY_UNITS, + userData: DUMMY_BYTES, + }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); }, 15_000); + + it("distribute: encodes against gdaForwarder.distribute", async () => { + const msg = await estimateGasError("distribute", { + token: SEPOLIA_FUSDCX, + from: TEST_ADDRESS, + pool: TEST_ADDRESS, + amount: DUMMY_AMOUNT_WEI, + userData: DUMMY_BYTES, + }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + it("distribute-flow: encodes int96 flowRate against gdaForwarder.distributeFlow", async () => { + const msg = await estimateGasError("distribute-flow", { + token: SEPOLIA_FUSDCX, + from: TEST_ADDRESS, + pool: TEST_ADDRESS, + flowRate: DUMMY_FLOW_RATE, + userData: DUMMY_BYTES, + }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + it("connect-pool: encodes against gdaForwarder.connectPool", async () => { + const msg = await estimateGasError("connect-pool", { + pool: TEST_ADDRESS, + userData: DUMMY_BYTES, + }); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + // -- SuperToken writes (userSpecifiedAddress) ---------------------------- + + it("wrap: encodes uint256 amount against superToken.upgrade", async () => { + const msg = await estimateGasError( + "wrap", + { amount: DUMMY_AMOUNT_WEI }, + SEPOLIA_FUSDCX + ); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + it("unwrap: encodes uint256 amount against superToken.downgrade", async () => { + const msg = await estimateGasError( + "unwrap", + { amount: DUMMY_AMOUNT_WEI }, + SEPOLIA_FUSDCX + ); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + it("grant-flow-operator: encodes (address, uint8, int96) against superToken.updateFlowOperatorPermissions", async () => { + const msg = await estimateGasError( + "grant-flow-operator", + { + flowOperator: TEST_ADDRESS, + permissions: DUMMY_PERMISSIONS_ALL, + flowRateAllowance: DUMMY_FLOW_RATE, + }, + SEPOLIA_FUSDCX + ); + expect(msg).not.toMatch(ENCODING_ERROR_RE); + }, 15_000); + + // -- Coverage check ------------------------------------------------------ + + it("every declared action has at least one dispatch test in this file", () => { + const declared = new Set(superfluidDef.actions.map((a) => a.slug)); + const tested = new Set([ + "get-flow", + "get-cfa-net-flow", + "get-net-flow", + "get-super-token-balance", + "get-underlying-token", + "create-flow", + "update-flow", + "delete-flow", + "create-pool", + "update-member-units", + "distribute", + "distribute-flow", + "connect-pool", + "wrap", + "unwrap", + "grant-flow-operator", + ]); + const missing = [...declared].filter((s) => !tested.has(s)); + const stale = [...tested].filter((s) => !declared.has(s)); + expect(missing).toEqual([]); + expect(stale).toEqual([]); + }); }); From 4ede57b34db16c1db03481c980d6c2183621c6ad Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 14:37:11 +1000 Subject: [PATCH 09/19] chore(superfluid): KEEP-415 add missing protocol icon protocols/superfluid.ts already references "/protocols/superfluid.png" but the file was never added. Source: official Superfluid Finance GitHub org avatar (github.com/superfluid-finance.png?size=256). Same 256x256 RGBA PNG format as the other protocol icons in this directory. --- public/protocols/superfluid.png | Bin 0 -> 46814 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/protocols/superfluid.png diff --git a/public/protocols/superfluid.png b/public/protocols/superfluid.png new file mode 100644 index 0000000000000000000000000000000000000000..c79e42f63f928accdc918b5e556581a00b53da27 GIT binary patch literal 46814 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

VRkt>LMyG{(DUX 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 Date: Wed, 6 May 2026 15:03:28 +1000 Subject: [PATCH 12/19] test(superfluid): KEEP-415 capture live-demo workflow fixtures The four JSON files in tests/integration/fixtures/superfluid-workflows/ are the canonical workflow definitions used to verify Superfluid actions end-to-end against a deployed PR environment (k8s pod runtime, real signer wallet, real Sepolia RPC): - get-net-flow.json -- read demo, returned "0" against fUSDCx - create-pool.json -- write demo, mined tx, emitted PoolCreated event - wrap.json -- multi-step (web3.approve-token + superfluid.wrap) - grant-flow-operator-quirky.json -- known-quirky write reference protocol-superfluid-workflow-fixtures.test.ts validates each fixture against the live protocol registry: asserts every Superfluid action slug exists in protocols/superfluid.ts, _protocolMeta agrees with action.contract/function/type, network is in SUPERFLUID_CHAIN_IDS, and all required inputs are present in config. Pure metadata, no RPC, runs in CI unconditionally. If anyone removes/renames an action or drops a chain, these fixtures fail loudly instead of silently rotting. 21 assertions across 4 fixtures; all pass against the current registry. --- .../superfluid-workflows/create-pool.json | 42 ++++++ .../superfluid-workflows/get-net-flow.json | 39 +++++ .../grant-flow-operator-quirky.json | 42 ++++++ .../fixtures/superfluid-workflows/wrap.json | 60 ++++++++ ...tocol-superfluid-workflow-fixtures.test.ts | 134 ++++++++++++++++++ 5 files changed, 317 insertions(+) create mode 100644 tests/integration/fixtures/superfluid-workflows/create-pool.json create mode 100644 tests/integration/fixtures/superfluid-workflows/get-net-flow.json create mode 100644 tests/integration/fixtures/superfluid-workflows/grant-flow-operator-quirky.json create mode 100644 tests/integration/fixtures/superfluid-workflows/wrap.json create mode 100644 tests/integration/protocol-superfluid-workflow-fixtures.test.ts diff --git a/tests/integration/fixtures/superfluid-workflows/create-pool.json b/tests/integration/fixtures/superfluid-workflows/create-pool.json new file mode 100644 index 000000000..80542fdc4 --- /dev/null +++ b/tests/integration/fixtures/superfluid-workflows/create-pool.json @@ -0,0 +1,42 @@ +{ + "name": "Superfluid create-pool demo (KEEP-415 Fix 1)", + "description": "Real on-chain write: create a GDA pool on Sepolia. Exercises the flat (bool,bool) PoolConfig reshape end-to-end through the keeperhub workflow pipeline.", + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "position": { "x": 0, "y": 0 }, + "data": { + "type": "trigger", + "label": "", + "config": { "triggerType": "Manual" }, + "status": "idle", + "description": "" + } + }, + { + "id": "step-1", + "type": "action", + "position": { "x": 272, "y": 0 }, + "data": { + "type": "action", + "label": "", + "config": { + "actionType": "superfluid/create-pool", + "network": "11155111", + "token": "0xb598E6C621618a9f63788816ffb50Ee2862D443B", + "admin": "0x42d92ec2c2cd8ba6ab5f65079e46975cbcdc63ef", + "transferabilityForUnitsOwner": "false", + "distributionFromAnyAddress": "false", + "integrationId": "t6xtd727ow2up79uy6v0y", + "_protocolMeta": "{\"protocolSlug\":\"superfluid\",\"contractKey\":\"gdaForwarder\",\"functionName\":\"createPool\",\"actionType\":\"write\"}" + }, + "status": "idle", + "description": "" + } + } + ], + "edges": [ + { "id": "e1", "source": "trigger-1", "target": "step-1" } + ] +} diff --git a/tests/integration/fixtures/superfluid-workflows/get-net-flow.json b/tests/integration/fixtures/superfluid-workflows/get-net-flow.json new file mode 100644 index 000000000..35080c176 --- /dev/null +++ b/tests/integration/fixtures/superfluid-workflows/get-net-flow.json @@ -0,0 +1,39 @@ +{ + "name": "Superfluid net-flow probe (KEEP-415 demo)", + "description": "Reads the combined CFA+GDA net flow rate on Sepolia fUSDCx. Exercises the new get-net-flow action introduced in this PR.", + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "position": { "x": 0, "y": 0 }, + "data": { + "type": "trigger", + "label": "", + "config": { "triggerType": "Manual" }, + "status": "idle", + "description": "" + } + }, + { + "id": "step-1", + "type": "action", + "position": { "x": 272, "y": 0 }, + "data": { + "type": "action", + "label": "", + "config": { + "actionType": "superfluid/get-net-flow", + "network": "11155111", + "token": "0xb598E6C621618a9f63788816ffb50Ee2862D443B", + "account": "0x0000000000000000000000000000000000000001", + "_protocolMeta": "{\"protocolSlug\":\"superfluid\",\"contractKey\":\"gdaForwarder\",\"functionName\":\"getNetFlow\",\"actionType\":\"read\"}" + }, + "status": "idle", + "description": "" + } + } + ], + "edges": [ + { "id": "e1", "source": "trigger-1", "target": "step-1" } + ] +} diff --git a/tests/integration/fixtures/superfluid-workflows/grant-flow-operator-quirky.json b/tests/integration/fixtures/superfluid-workflows/grant-flow-operator-quirky.json new file mode 100644 index 000000000..7436c92b7 --- /dev/null +++ b/tests/integration/fixtures/superfluid-workflows/grant-flow-operator-quirky.json @@ -0,0 +1,42 @@ +{ + "name": "Superfluid grant-flow-operator demo (KEEP-415)", + "description": "On-chain write demo: grant a no-op flow-operator permission on fUSDCx. Exercises the full keeperhub -> Superfluid write pipeline.", + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "position": { "x": 0, "y": 0 }, + "data": { + "type": "trigger", + "label": "", + "config": { "triggerType": "Manual" }, + "status": "idle", + "description": "" + } + }, + { + "id": "step-1", + "type": "action", + "position": { "x": 272, "y": 0 }, + "data": { + "type": "action", + "label": "", + "config": { + "actionType": "superfluid/grant-flow-operator", + "network": "11155111", + "contractAddress": "0xb598E6C621618a9f63788816ffb50Ee2862D443B", + "flowOperator": "0x0000000000000000000000000000000000000001", + "permissions": "1", + "flowRateAllowance": "0", + "integrationId": "t6xtd727ow2up79uy6v0y", + "_protocolMeta": "{\"protocolSlug\":\"superfluid\",\"contractKey\":\"superToken\",\"functionName\":\"updateFlowOperatorPermissions\",\"actionType\":\"write\"}" + }, + "status": "idle", + "description": "" + } + } + ], + "edges": [ + { "id": "e1", "source": "trigger-1", "target": "step-1" } + ] +} diff --git a/tests/integration/fixtures/superfluid-workflows/wrap.json b/tests/integration/fixtures/superfluid-workflows/wrap.json new file mode 100644 index 000000000..cb1135d75 --- /dev/null +++ b/tests/integration/fixtures/superfluid-workflows/wrap.json @@ -0,0 +1,60 @@ +{ + "name": "Superfluid wrap demo (KEEP-415)", + "description": "Two-step workflow: approve fUSDCx to spend fUSDC, then wrap 1 fUSDC into 1 fUSDCx. Demonstrates ERC20 -> SuperToken via the deployed runtime.", + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "position": { "x": 0, "y": 0 }, + "data": { + "type": "trigger", + "label": "", + "config": { "triggerType": "Manual" }, + "status": "idle", + "description": "" + } + }, + { + "id": "step-approve", + "type": "action", + "position": { "x": 272, "y": 0 }, + "data": { + "type": "action", + "label": "", + "config": { + "actionType": "web3/approve-token", + "network": "11155111", + "tokenConfig": "{\"mode\":\"custom\",\"customToken\":{\"address\":\"0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db\",\"symbol\":\"fUSDC\"}}", + "spenderAddress": "0xb598E6C621618a9f63788816ffb50Ee2862D443B", + "amount": "1", + "integrationId": "t6xtd727ow2up79uy6v0y" + }, + "status": "idle", + "description": "" + } + }, + { + "id": "step-wrap", + "type": "action", + "position": { "x": 544, "y": 0 }, + "data": { + "type": "action", + "label": "", + "config": { + "actionType": "superfluid/wrap", + "network": "11155111", + "contractAddress": "0xb598E6C621618a9f63788816ffb50Ee2862D443B", + "amount": "1000000000000000000", + "integrationId": "t6xtd727ow2up79uy6v0y", + "_protocolMeta": "{\"protocolSlug\":\"superfluid\",\"contractKey\":\"superToken\",\"functionName\":\"upgrade\",\"actionType\":\"write\"}" + }, + "status": "idle", + "description": "" + } + } + ], + "edges": [ + { "id": "e1", "source": "trigger-1", "target": "step-approve" }, + { "id": "e2", "source": "step-approve", "target": "step-wrap" } + ] +} diff --git a/tests/integration/protocol-superfluid-workflow-fixtures.test.ts b/tests/integration/protocol-superfluid-workflow-fixtures.test.ts new file mode 100644 index 000000000..084db175c --- /dev/null +++ b/tests/integration/protocol-superfluid-workflow-fixtures.test.ts @@ -0,0 +1,134 @@ +/** + * Superfluid Workflow Fixture Validation + * + * The fixtures in `fixtures/superfluid-workflows/` are the canonical + * workflow JSONs used to verify Superfluid actions end-to-end against a + * deployed PR environment. They double as a regression set: if the + * protocol drifts (action removed, contract key renamed, chain dropped), + * these fixtures should fail to validate against the live registry. + * + * This test does NOT make network calls. It only checks that each + * fixture's references agree with `protocols/superfluid.ts`. Runs in CI + * unconditionally. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import superfluidDef, { SUPERFLUID_CHAIN_IDS } from "@/protocols/superfluid"; + +const FIXTURE_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures/superfluid-workflows" +); +const SUPPORTED_CHAINS: readonly string[] = SUPERFLUID_CHAIN_IDS; + +type WorkflowConfig = { + actionType?: string; + network?: string; + _protocolMeta?: string; + [key: string]: unknown; +}; + +type WorkflowNode = { + id: string; + type: "trigger" | "action"; + data: { config: WorkflowConfig }; +}; + +type Workflow = { + name: string; + nodes: WorkflowNode[]; + edges: Array<{ id: string; source: string; target: string }>; +}; + +type ProtocolMeta = { + protocolSlug: string; + contractKey: string; + functionName: string; + actionType: "read" | "write"; +}; + +function loadFixtures(): Array<{ file: string; workflow: Workflow }> { + const files = fs + .readdirSync(FIXTURE_DIR) + .filter((f) => f.endsWith(".json")) + .sort(); + return files.map((file) => { + const raw = fs.readFileSync(path.join(FIXTURE_DIR, file), "utf-8"); + return { file, workflow: JSON.parse(raw) as Workflow }; + }); +} + +const fixtures = loadFixtures(); +const SUPERFLUID_PREFIX = "superfluid/"; + +describe("Superfluid workflow fixtures", () => { + it("fixture directory contains at least one JSON", () => { + expect(fixtures.length).toBeGreaterThan(0); + }); + + for (const { file, workflow } of fixtures) { + describe(file, () => { + it("has a name and at least one trigger plus one action node", () => { + expect(workflow.name).toBeTruthy(); + expect(workflow.nodes.some((n) => n.type === "trigger")).toBe(true); + expect(workflow.nodes.some((n) => n.type === "action")).toBe(true); + }); + + const superfluidActions = workflow.nodes.filter( + (n) => + n.type === "action" && + typeof n.data.config.actionType === "string" && + n.data.config.actionType.startsWith(SUPERFLUID_PREFIX) + ); + + for (const node of superfluidActions) { + const config = node.data.config; + const slug = (config.actionType as string).slice( + SUPERFLUID_PREFIX.length + ); + + describe(`action node ${node.id} (${slug})`, () => { + const action = superfluidDef.actions.find((a) => a.slug === slug); + + it("references a declared Superfluid action", () => { + expect(action).toBeDefined(); + }); + + it("network is a Superfluid-supported chain ID", () => { + expect(config.network).toBeTypeOf("string"); + expect(SUPPORTED_CHAINS).toContain(config.network); + }); + + it("_protocolMeta parses and matches the action definition", () => { + if (!action) { + return; + } + expect(typeof config._protocolMeta).toBe("string"); + const meta = JSON.parse( + config._protocolMeta as string + ) as ProtocolMeta; + expect(meta.protocolSlug).toBe("superfluid"); + expect(meta.contractKey).toBe(action.contract); + expect(meta.functionName).toBe(action.function); + expect(meta.actionType).toBe(action.type); + }); + + it("required input fields are all present in config", () => { + if (!action) { + return; + } + const requiredInputs = action.inputs.filter( + (inp) => inp.required ?? inp.default === undefined + ); + for (const inp of requiredInputs) { + expect(config).toHaveProperty(inp.name); + } + }); + }); + } + }); + } +}); From 6c502db04d0b15e8cba1bd026d416bb160912208 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 15:07:08 +1000 Subject: [PATCH 13/19] test(superfluid): KEEP-415 add live-test driver scripts tests/scripts/run-fixture.ts -- Loads a workflow fixture JSON, POSTs to a target deploy's /api/workflows/create, /execute, polls, and prints the per-step trace plus final output. Auth is browser-cookie-based because PR hosts sit behind Cloudflare Access (the kh CLI's API key bypasses better-auth but not CF Access). Supports INTEGRATION_ID override for replaying fixtures captured in another org. tests/scripts/fund-test-wallet.ts -- Reads the team funder PK from TechOps/.secrets/WEB3.txt (or FUNDER_PK_PATH override), sends SepETH and optionally mints fUSDC via the permissive Sepolia mint() function on the fake-USDC contract. Used to bootstrap the keeperhub-managed signer wallet before running write fixtures. Both scripts are self-contained, run via `pnpm tsx tests/scripts/.ts`, and follow the existing scripts/ convention (header doc, env-var configuration, no implicit defaults for credentials). They make the existing live-test procedure reproducible without ad-hoc curl pipes. --- tests/scripts/e2e-superfluid-sepolia.ts | 459 +++++++++++++++++++ tests/scripts/fund-test-wallet.ts | 144 ++++++ tests/scripts/run-fixture.ts | 216 +++++++++ tests/scripts/verify-superfluid-addresses.ts | 134 ++++++ 4 files changed, 953 insertions(+) create mode 100644 tests/scripts/e2e-superfluid-sepolia.ts create mode 100644 tests/scripts/fund-test-wallet.ts create mode 100644 tests/scripts/run-fixture.ts create mode 100644 tests/scripts/verify-superfluid-addresses.ts diff --git a/tests/scripts/e2e-superfluid-sepolia.ts b/tests/scripts/e2e-superfluid-sepolia.ts new file mode 100644 index 000000000..32223b6a1 --- /dev/null +++ b/tests/scripts/e2e-superfluid-sepolia.ts @@ -0,0 +1,459 @@ +/** + * Live Sepolia end-to-end script for the Superfluid protocol. + * + * Walks the full streaming + pool lifecycle against real Superfluid contracts: + * approve -> wrap -> create-flow -> update-flow -> get-net-flow -> delete-flow + * -> create-pool -> update-member-units -> connect-pool -> distribute-flow + * -> read net flow -> cleanup + * + * Usage: + * export SUPERFLUID_E2E_SENDER_KEY=0x... + * pnpm tsx scripts/e2e-superfluid-sepolia.ts + * + * Contract addresses are pinned to Superfluid's canonical Sepolia deployments. + * If Superfluid migrates a contract, override via env vars: + * SUPERFLUID_E2E_CFA_FORWARDER (default: 0xcfA132E353cB4E398080B9700609bb008eceB125) + * SUPERFLUID_E2E_GDA_FORWARDER (default: 0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08) + * SUPERFLUID_E2E_SUPER_TOKEN (default: 0xb598E6C621618a9f63788816ffb50Ee2862D443B, fUSDCx) + * SUPERFLUID_E2E_UNDERLYING (default: 0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db, fUSDC -- "fUSDC Fake Token"; mint(address,uint256) is permissive on Sepolia) + * + * Required env vars: + * SUPERFLUID_E2E_SENDER_KEY hex private key, 0x-prefixed + * + * Optional env vars: + * SUPERFLUID_E2E_RECEIVER_KEY second wallet key; connect-pool step is SKIPPED if absent + * SUPERFLUID_E2E_RPC_URL default: https://ethereum-sepolia-rpc.publicnode.com + * SUPERFLUID_E2E_WRAP_AMOUNT wei to wrap (default: 100000000) + * SUPERFLUID_E2E_FLOW_RATE wei/sec flow rate (default: 1000000) + * + * Pre-flight: fund the sender wallet with Sepolia ETH (gas) and fUSDC. + * Faucet: https://app.superfluid.finance/faucet (select Sepolia, claim fUSDC). + * The script does NOT automate faucet interaction. + */ + +import { Contract, ethers, JsonRpcProvider, Wallet } from "ethers"; +import { + CFA_FORWARDER_ADDRESS, + GDA_FORWARDER_ADDRESS, +} from "@/protocols/superfluid"; + +const SEPOLIA_CHAIN_ID = 11155111; + +const DEFAULT_RPC = "https://ethereum-sepolia-rpc.publicnode.com"; +const DEFAULT_CFA_FORWARDER = CFA_FORWARDER_ADDRESS; +const DEFAULT_GDA_FORWARDER = GDA_FORWARDER_ADDRESS; +const DEFAULT_SUPER_TOKEN = "0xb598E6C621618a9f63788816ffb50Ee2862D443B"; +const DEFAULT_UNDERLYING = "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; +const DEFAULT_WRAP_AMOUNT = "100000000"; +const DEFAULT_FLOW_RATE = "1000000"; + +// Override gas limit on every write tx. Ethers v6's default eth_estimateGas +// buffer is too tight for CFA/GDA writes -- the actual gas usage can exceed +// the estimate due to SLOAD warming differences between simulation and +// execution, causing OOG reverts (e.g. updateFlow uses ~315k vs estimated +// ~285k). 1.5M gas covers the heaviest call (createPool) with margin. +const TX_OVERRIDES = { gasLimit: 1_500_000 }; + +// Stable receiver address used when SUPERFLUID_E2E_RECEIVER_KEY is not set. +// This is a deterministic dead-drop address; it will receive flows but nobody +// controls the key, so connect-pool cannot be signed for it. +const FALLBACK_RECEIVER = "0x000000000000000000000000000000000000dEaD"; + +const ERC20_ABI = [ + "function approve(address spender, uint256 amount) returns (bool)", + "function balanceOf(address account) view returns (uint256)", + "function decimals() view returns (uint8)", + "function allowance(address owner, address spender) view returns (uint256)", +]; + +const SUPER_TOKEN_ABI = [ + "function upgrade(uint256 amount)", + "function downgrade(uint256 amount)", + "function balanceOf(address account) view returns (uint256)", +]; + +const CFA_FORWARDER_ABI = [ + "function createFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", + "function updateFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", + "function deleteFlow(address token, address sender, address receiver, bytes userData) returns (bool)", + "function getFlowInfo(address token, address sender, address receiver) view returns (uint256 lastUpdated, int96 flowRate, uint256 deposit, uint256 owedDeposit)", + "function getAccountFlowrate(address token, address account) view returns (int96 flowRate)", +]; + +const GDA_FORWARDER_ABI = [ + "function createPool(address token, address admin, tuple(bool transferabilityForUnitsOwner, bool distributionFromAnyAddress) config) returns (bool success, address pool)", + "function updateMemberUnits(address pool, address member, uint128 units, bytes userData) returns (bool)", + "function getNetFlow(address token, address account) view returns (int96)", + "function connectPool(address pool, bytes userData) returns (bool)", + "function distributeFlow(address token, address from, address pool, int96 flowRate, bytes userData) returns (bool)", + "event PoolCreated(address indexed token, address indexed admin, address pool)", +]; + +type StepStatus = "PASS" | "SKIPPED" | "FAILED"; + +interface StepResult { + name: string; + status: StepStatus; + detail: string; +} + +const results: StepResult[] = []; + +function record(name: string, status: StepStatus, detail: string): void { + results.push({ name, status, detail }); + const prefix = status === "PASS" ? "OK" : status === "SKIPPED" ? "SKIPPED" : "FAILED"; + console.log(`[${prefix}] ${name}: ${detail}`); +} + +function requireEnv(name: string): string { + const val = process.env[name]; + if (!val) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return val; +} + +function getEnv(name: string, fallback: string): string { + return process.env[name] ?? fallback; +} + +async function sendAndWait( + label: string, + // biome-ignore lint/suspicious/noExplicitAny: ethers contract calls return ContractTransactionResponse which needs any + txPromise: Promise +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: ethers v6 contract method return type + const tx: any = await txPromise; + console.log(` [${label}] tx ${tx.hash}`); + const receipt: ethers.TransactionReceipt | null = await tx.wait(); + if (!receipt || receipt.status !== 1) { + throw new Error(`Transaction reverted: ${tx.hash}`); + } + return receipt; +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function main(): Promise { + // --- env setup --- + const senderKey = requireEnv("SUPERFLUID_E2E_SENDER_KEY"); + const receiverKey = process.env.SUPERFLUID_E2E_RECEIVER_KEY; + const rpcUrl = getEnv("SUPERFLUID_E2E_RPC_URL", DEFAULT_RPC); + const cfaAddress = getEnv("SUPERFLUID_E2E_CFA_FORWARDER", DEFAULT_CFA_FORWARDER); + const gdaAddress = getEnv("SUPERFLUID_E2E_GDA_FORWARDER", DEFAULT_GDA_FORWARDER); + const superTokenAddress = getEnv("SUPERFLUID_E2E_SUPER_TOKEN", DEFAULT_SUPER_TOKEN); + const underlyingAddress = getEnv("SUPERFLUID_E2E_UNDERLYING", DEFAULT_UNDERLYING); + const wrapAmount = BigInt(getEnv("SUPERFLUID_E2E_WRAP_AMOUNT", DEFAULT_WRAP_AMOUNT)); + const flowRate = BigInt(getEnv("SUPERFLUID_E2E_FLOW_RATE", DEFAULT_FLOW_RATE)); + + const provider = new JsonRpcProvider(rpcUrl, SEPOLIA_CHAIN_ID); + const senderWallet = new Wallet(senderKey, provider); + const senderAddress = senderWallet.address; + + let receiverAddress: string; + let receiverWallet: Wallet | null = null; + if (receiverKey) { + receiverWallet = new Wallet(receiverKey, provider); + receiverAddress = receiverWallet.address; + } else { + receiverAddress = FALLBACK_RECEIVER; + console.log(`SUPERFLUID_E2E_RECEIVER_KEY not set -- using fallback receiver ${FALLBACK_RECEIVER}`); + console.log("Steps requiring receiver signature will be SKIPPED."); + } + + console.log(`Sender: ${senderAddress}`); + console.log(`Receiver: ${receiverAddress}`); + console.log(`RPC: ${rpcUrl}`); + console.log(`Chain ID: ${SEPOLIA_CHAIN_ID}`); + console.log(""); + + const erc20 = new Contract(underlyingAddress, ERC20_ABI, senderWallet); + const superToken = new Contract(superTokenAddress, SUPER_TOKEN_ABI, senderWallet); + const cfa = new Contract(cfaAddress, CFA_FORWARDER_ABI, senderWallet); + const gda = new Contract(gdaAddress, GDA_FORWARDER_ABI, senderWallet); + + // --- step 1: pre-flight --- + console.log("[step 1 / pre-flight]"); + const ethBalance: bigint = await provider.getBalance(senderAddress); + const minEth = ethers.parseEther("0.01"); + if (ethBalance < minEth) { + console.error( + `Insufficient ETH for gas: ${ethers.formatEther(ethBalance)} ETH (need >= 0.01). Fund the sender wallet on Sepolia.` + ); + process.exit(1); + } + const underlyingBalance: bigint = await erc20.balanceOf(senderAddress); + if (underlyingBalance < wrapAmount) { + console.error( + `Insufficient fUSDC balance: ${underlyingBalance} wei (need >= ${wrapAmount}). Claim from https://app.superfluid.finance/faucet (Sepolia).` + ); + process.exit(1); + } + record( + "step 1 / pre-flight", + "PASS", + `ETH ${ethers.formatEther(ethBalance)} | fUSDC balance ${underlyingBalance} wei` + ); + + // --- idempotency: close any pre-existing flow before the lifecycle --- + console.log("\n[idempotency check]"); + const [, existingRate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (existingRate > BigInt(0)) { + console.log(` Pre-existing flow found (${existingRate} wei/s). Deleting before lifecycle.`); + await sendAndWait("pre-cleanup delete-flow", cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES)); + console.log(" Pre-existing flow deleted."); + } else { + console.log(" No pre-existing flow. Proceeding."); + } + + // --- step 2: approve underlying --- + console.log("\n[step 2 / approve]"); + const receipt2 = await sendAndWait( + "approve", + erc20.approve(superTokenAddress, wrapAmount, TX_OVERRIDES) + ); + record( + "step 2 / approve", + "PASS", + `tx ${receipt2.hash} | amount ${wrapAmount} wei` + ); + + // --- step 3: wrap --- + console.log("\n[step 3 / wrap]"); + const balBefore: bigint = await superToken.balanceOf(senderAddress); + const receipt3 = await sendAndWait("wrap", superToken.upgrade(wrapAmount, TX_OVERRIDES)); + const balAfter: bigint = await superToken.balanceOf(senderAddress); + if (balAfter <= balBefore) { + throw new Error(`Wrap did not increase SuperToken balance: before=${balBefore} after=${balAfter}`); + } + record( + "step 3 / wrap", + "PASS", + `tx ${receipt3.hash} | superToken balance before=${balBefore} after=${balAfter}` + ); + + // --- step 4: create-flow --- + console.log("\n[step 4 / create-flow]"); + const receipt4 = await sendAndWait( + "create-flow", + cfa.createFlow(superTokenAddress, senderAddress, receiverAddress, flowRate, "0x", TX_OVERRIDES) + ); + const [, flowRateAfterCreate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (flowRateAfterCreate !== flowRate) { + throw new Error(`Flow rate mismatch after create: expected ${flowRate}, got ${flowRateAfterCreate}`); + } + record( + "step 4 / create-flow", + "PASS", + `tx ${receipt4.hash} | flowRate ${flowRate} wei/s` + ); + + // --- step 5: update-flow --- + console.log("\n[step 5 / update-flow]"); + const newFlowRate = flowRate * BigInt(2); + const receipt5 = await sendAndWait( + "update-flow", + cfa.updateFlow(superTokenAddress, senderAddress, receiverAddress, newFlowRate, "0x", TX_OVERRIDES) + ); + const [, flowRateAfterUpdate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (flowRateAfterUpdate !== newFlowRate) { + throw new Error(`Flow rate mismatch after update: expected ${newFlowRate}, got ${flowRateAfterUpdate}`); + } + record( + "step 5 / update-flow", + "PASS", + `tx ${receipt5.hash} | flowRate ${newFlowRate} wei/s` + ); + + // --- step 6: get-net-flow --- + console.log("\n[step 6 / get-net-flow]"); + const netFlow: bigint = await cfa.getAccountFlowrate(superTokenAddress, receiverAddress); + // netFlow may be negative for the sender side; for receiver it should be >= newFlowRate + // (could be higher if receiver has other inbound flows) + record( + "step 6 / get-net-flow", + "PASS", + `receiver net flow ${netFlow} wei/s` + ); + + // --- step 7: delete-flow --- + console.log("\n[step 7 / delete-flow]"); + const receipt7 = await sendAndWait( + "delete-flow", + cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES) + ); + const [, flowRateAfterDelete]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( + superTokenAddress, + senderAddress, + receiverAddress + ); + if (flowRateAfterDelete !== BigInt(0)) { + throw new Error(`Flow still active after delete: rate=${flowRateAfterDelete}`); + } + record( + "step 7 / delete-flow", + "PASS", + `tx ${receipt7.hash} | flowRate now 0` + ); + + // --- step 8: create-pool --- + console.log("\n[step 8 / create-pool]"); + const receipt8 = await sendAndWait( + "create-pool", + gda.createPool(superTokenAddress, senderAddress, [false, false], TX_OVERRIDES) + ); + + // Parse PoolCreated event from receipt logs + const gdaInterface = new ethers.Interface(GDA_FORWARDER_ABI); + const poolCreatedTopic = gdaInterface.getEvent("PoolCreated")?.topicHash; + let poolAddress = ""; + for (const log of receipt8.logs) { + if (log.topics[0] === poolCreatedTopic) { + const parsed = gdaInterface.parseLog({ topics: [...log.topics], data: log.data }); + if (parsed?.args.pool) { + poolAddress = parsed.args.pool as string; + break; + } + } + } + if (!poolAddress) { + throw new Error("PoolCreated event not found in create-pool receipt"); + } + record( + "step 8 / create-pool", + "PASS", + `tx ${receipt8.hash} | pool ${poolAddress}` + ); + + // --- step 9: update-member-units --- + console.log("\n[step 9 / update-member-units]"); + const receipt9 = await sendAndWait( + "update-member-units", + gda.updateMemberUnits(poolAddress, receiverAddress, BigInt(100), "0x", TX_OVERRIDES) + ); + record( + "step 9 / update-member-units", + "PASS", + `tx ${receipt9.hash} | member ${receiverAddress} | units 100` + ); + + // --- step 10: connect-pool (receiver must sign) --- + console.log("\n[step 10 / connect-pool]"); + if (receiverWallet !== null) { + const gdaAsReceiver = new Contract(gdaAddress, GDA_FORWARDER_ABI, receiverWallet); + const receipt10 = await sendAndWait( + "connect-pool", + gdaAsReceiver.connectPool(poolAddress, "0x", TX_OVERRIDES) + ); + record( + "step 10 / connect-pool", + "PASS", + `tx ${receipt10.hash} | receiver ${receiverAddress} connected` + ); + } else { + record( + "step 10 / connect-pool", + "SKIPPED", + "SUPERFLUID_E2E_RECEIVER_KEY not set; receiver cannot sign connect-pool" + ); + } + + // --- step 11: distribute-flow --- + console.log("\n[step 11 / distribute-flow]"); + const receipt11 = await sendAndWait( + "distribute-flow", + gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, flowRate, "0x", TX_OVERRIDES) + ); + record( + "step 11 / distribute-flow", + "PASS", + `tx ${receipt11.hash} | flowRate ${flowRate} wei/s` + ); + + // --- step 12: read net flow twice, 5 seconds apart --- + // CFA's getAccountFlowrate aggregates only CFA flows. To observe the + // receiver's incoming pool stream we have to query the GDA forwarder's + // getNetFlow, which combines CFA and GDA flows. + console.log("\n[step 12 / read-net-flow x2]"); + const netFlowBefore: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); + console.log(` net flow (t=0): ${netFlowBefore} wei/s`); + await sleep(5000); + const netFlowAfter: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); + console.log(` net flow (t=5s): ${netFlowAfter} wei/s`); + + if (receiverWallet !== null) { + if (netFlowAfter <= BigInt(0)) { + throw new Error(`Expected receiver net flow to be > 0 after connect-pool + distribute-flow, got ${netFlowAfter}`); + } + record( + "step 12 / read-net-flow", + "PASS", + `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s` + ); + } else { + record( + "step 12 / read-net-flow", + "PASS", + `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s (receiver not connected; flow accumulation not verified)` + ); + } + + // --- step 13: cleanup --- + console.log("\n[step 13 / cleanup]"); + const receipt13 = await sendAndWait( + "cleanup distribute-flow", + gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, BigInt(0), "0x", TX_OVERRIDES) + ); + record( + "step 13 / cleanup", + "PASS", + `tx ${receipt13.hash} | pool stream closed` + ); + + // --- summary --- + console.log("\n--- SUMMARY ---"); + console.log( + `${"Step".padEnd(40)} ${"Status".padEnd(10)} Detail` + ); + console.log("-".repeat(100)); + for (const r of results) { + console.log(`${r.name.padEnd(40)} ${r.status.padEnd(10)} ${r.detail}`); + } + + const failed = results.filter((r) => r.status === "FAILED"); + const skipped = results.filter((r) => r.status === "SKIPPED"); + console.log( + `\n${results.length} steps total | ${results.length - failed.length - skipped.length} PASS | ${skipped.length} SKIPPED | ${failed.length} FAILED` + ); + + if (failed.length > 0) { + process.exit(1); + } +} + +main().catch((error: unknown) => { + const failed = results.filter((r) => r.status === "FAILED"); + const msg = error instanceof Error ? error.message : String(error); + if (failed.length === 0) { + // Unrecorded failure + console.error(`\nFATAL: ${msg}`); + } + process.exit(1); +}); diff --git a/tests/scripts/fund-test-wallet.ts b/tests/scripts/fund-test-wallet.ts new file mode 100644 index 000000000..a7824b69b --- /dev/null +++ b/tests/scripts/fund-test-wallet.ts @@ -0,0 +1,144 @@ +/** + * Fund a Sepolia test wallet for live Superfluid demos. + * + * Sends Sepolia ETH to a target address and (optionally) mints fUSDC via + * the permissive Sepolia mint() function on the fake-USDC contract. Used + * to bootstrap the keeperhub-managed signer wallet before running write + * workflow fixtures (create-pool, wrap, etc.) against a PR deploy. + * + * Usage: + * TARGET=0x42d92e...63ef \ + * ETH_AMOUNT=0.005 \ + * FUSDC_AMOUNT=5 \ + * pnpm tsx tests/scripts/fund-test-wallet.ts + * + * Required env: + * TARGET recipient address (the workflow wallet -- get it via + * GET /api/user/wallet on the deploy) + * + * Optional env: + * ETH_AMOUNT SepETH to send, in ether units (default "0.005") + * Pass "0" to skip the ETH transfer. + * FUSDC_AMOUNT fUSDC to mint, in token units (default "0" = skip) + * FUNDER_PK_PATH file containing PK = (default reads + * ../../../.secrets/WEB3.txt relative to this script, + * i.e. TechOps/.secrets/WEB3.txt assuming the standard + * mega-repo layout) + * RPC_URL Sepolia RPC (default ethereum-sepolia-rpc.publicnode.com) + * FUSDC_ADDRESS override fUSDC address (default 0xe72f...20Db) + * + * The fUSDC underlying on Sepolia ships with a permissive + * mint(address, uint256) -- per the e2e script's documented quirk -- so + * any caller can drop tokens into any address. Don't assume real USDC + * behaves like this. + * + * Don't echo PKs in CI logs; this script reads from disk and never + * prints the key. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ethers } from "ethers"; + +const TARGET = requireEnv("TARGET"); +const ETH_AMOUNT = process.env.ETH_AMOUNT ?? "0.005"; +const FUSDC_AMOUNT = process.env.FUSDC_AMOUNT ?? "0"; +const RPC_URL = + process.env.RPC_URL ?? "https://ethereum-sepolia-rpc.publicnode.com"; +const FUSDC_ADDRESS = + process.env.FUSDC_ADDRESS ?? "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; + +const PK_LINE_RE = /^PK\s*=\s*([0-9a-fA-F]{64})\s*$/m; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return v; +} + +function defaultFunderPath(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + // tests/scripts/ -> ../../ keeperhub root -> ../ TechOps root -> .secrets/WEB3.txt + return path.resolve(here, "../../../.secrets/WEB3.txt"); +} + +function loadFunderKey(): string { + const p = process.env.FUNDER_PK_PATH ?? defaultFunderPath(); + if (!fs.existsSync(p)) { + console.error(`Funder key file not found: ${p}`); + console.error("Set FUNDER_PK_PATH or place WEB3.txt at the default path."); + process.exit(1); + } + const raw = fs.readFileSync(p, "utf-8"); + const match = raw.match(PK_LINE_RE); + if (!match) { + console.error(`Could not find "PK = <64 hex chars>" line in ${p}`); + process.exit(1); + } + return `0x${match[1]}`; +} + +const FUSDC_MINT_ABI = [ + "function mint(address account, uint256 amount)", + "function decimals() view returns (uint8)", + "function balanceOf(address) view returns (uint256)", +]; + +async function main(): Promise { + if (!ethers.isAddress(TARGET)) { + throw new Error(`Invalid TARGET address: ${TARGET}`); + } + + const provider = new ethers.JsonRpcProvider(RPC_URL); + const funder = new ethers.Wallet(loadFunderKey(), provider); + console.log(`Funder: ${funder.address}`); + console.log(`Target: ${TARGET}`); + + const funderEth = await provider.getBalance(funder.address); + console.log(`Funder SepETH balance: ${ethers.formatEther(funderEth)}`); + + const ethWei = ethers.parseEther(ETH_AMOUNT); + if (ethWei > 0n) { + console.log(`\nSending ${ETH_AMOUNT} SepETH...`); + const tx = await funder.sendTransaction({ to: TARGET, value: ethWei }); + console.log(` tx: ${tx.hash}`); + const receipt = await tx.wait(); + console.log(` confirmed in block ${receipt?.blockNumber}`); + } else { + console.log("\nSkipping SepETH transfer (ETH_AMOUNT=0)"); + } + + const fusdcAmount = Number.parseFloat(FUSDC_AMOUNT); + if (fusdcAmount > 0) { + const fUSDC = new ethers.Contract(FUSDC_ADDRESS, FUSDC_MINT_ABI, funder); + const decimals = (await fUSDC.decimals()) as bigint; + const amountWei = ethers.parseUnits(FUSDC_AMOUNT, decimals); + console.log( + `\nMinting ${FUSDC_AMOUNT} fUSDC (${decimals} decimals = ${amountWei} wei)...` + ); + const tx = await fUSDC.mint(TARGET, amountWei); + console.log(` tx: ${tx.hash}`); + const receipt = await tx.wait(); + console.log(` confirmed in block ${receipt?.blockNumber}`); + const bal = (await fUSDC.balanceOf(TARGET)) as bigint; + console.log( + ` target fUSDC balance now: ${ethers.formatUnits(bal, decimals)}` + ); + } else { + console.log("\nSkipping fUSDC mint (FUSDC_AMOUNT=0)"); + } + + const targetEth = await provider.getBalance(TARGET); + console.log( + `\nDone. Target SepETH balance: ${ethers.formatEther(targetEth)}` + ); +} + +main().catch((e: unknown) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/scripts/run-fixture.ts b/tests/scripts/run-fixture.ts new file mode 100644 index 000000000..315089412 --- /dev/null +++ b/tests/scripts/run-fixture.ts @@ -0,0 +1,216 @@ +/** + * Live workflow fixture runner. + * + * Loads a workflow fixture JSON, POSTs to a target deploy's + * /api/workflows/create, triggers /api/workflow/{id}/execute, polls until + * the run terminates, prints the per-step trace and final output. + * + * Usage: + * FIXTURE=tests/integration/fixtures/superfluid-workflows/get-net-flow.json \ + * ORIGIN=https://app-pr-1114.keeperhub.com \ + * COOKIE='CF_Authorization=...; __Secure-better-auth.session_token=...' \ + * pnpm tsx tests/scripts/run-fixture.ts + * + * Required env: + * FIXTURE path to a workflow JSON (relative to repo root or absolute) + * ORIGIN full deploy origin (https://app-pr-N.keeperhub.com) + * COOKIE cookie header value carrying CF Access JWT and the better-auth + * session. Easiest to copy from a logged-in browser DevTools + * "Copy as cURL" against any /api/ request. + * + * Optional env: + * POLL_INTERVAL_MS default 4000 + * TIMEOUT_MS default 300000 (5 min) + * INTEGRATION_ID if set, replaces every node's data.config.integrationId + * before POST. Use when re-running a fixture against a + * different deploy/user (the captured ID is per-org). + * + * Why cookies and not just an API key: PR hosts sit behind Cloudflare + * Access. The kh CLI's API-key path bypasses better-auth but NOT CF + * Access, so /api/* returns the SSO HTML. Browser cookies carry both + * tokens. For long-running automation, use a CF Access service token + * instead and switch this script to header-based auth. + */ + +import fs from "node:fs"; +import path from "node:path"; + +type ExecResponse = { executionId: string; status: string }; + +type Execution = { + id: string; + status: "running" | "success" | "error" | string; + output: Record | null; + error: string | null; + duration: string | null; + completedSteps: string | null; +}; + +type StepTrace = { + id: string; + nodeId: string; + nodeName: string; + nodeType: string; + status: "success" | "error" | "running" | string; + durationMs: number | null; + error: string | null; +}; + +type WorkflowNodeDoc = { + id: string; + type: string; + data: { config?: Record }; +}; + +type WorkflowDoc = { + name: string; + description?: string; + nodes: WorkflowNodeDoc[]; + edges: unknown[]; +}; + +const FIXTURE = requireEnv("FIXTURE"); +const ORIGIN = requireEnv("ORIGIN"); +const COOKIE = requireEnv("COOKIE"); +const POLL_INTERVAL_MS = Number.parseInt( + process.env.POLL_INTERVAL_MS ?? "4000", + 10 +); +const TIMEOUT_MS = Number.parseInt(process.env.TIMEOUT_MS ?? "300000", 10); +const INTEGRATION_ID_OVERRIDE = process.env.INTEGRATION_ID; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return v; +} + +function loadFixture(p: string): WorkflowDoc { + const abs = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); + const raw = fs.readFileSync(abs, "utf-8"); + const doc = JSON.parse(raw) as WorkflowDoc; + if (INTEGRATION_ID_OVERRIDE) { + for (const node of doc.nodes) { + if (node.data?.config && "integrationId" in node.data.config) { + node.data.config.integrationId = INTEGRATION_ID_OVERRIDE; + } + } + } + return doc; +} + +function api(p: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("cookie", COOKIE); + headers.set("origin", ORIGIN); + if (init.body && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return fetch(`${ORIGIN}${p}`, { ...init, headers }); +} + +async function readJson(res: Response): Promise { + const text = await res.text(); + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 500)}`); + } + try { + return JSON.parse(text) as T; + } catch { + throw new Error( + `Non-JSON response (likely Cloudflare Access HTML). First 500 chars: ${text.slice( + 0, + 500 + )}` + ); + } +} + +async function createWorkflow(doc: WorkflowDoc): Promise { + const res = await api("/api/workflows/create", { + method: "POST", + body: JSON.stringify(doc), + }); + const created = await readJson<{ id: string; name: string }>(res); + return created.id; +} + +async function executeWorkflow(workflowId: string): Promise { + const res = await api(`/api/workflow/${workflowId}/execute`, { + method: "POST", + body: "{}", + }); + const exec = await readJson(res); + return exec.executionId; +} + +async function pollExecution(workflowId: string): Promise { + const start = Date.now(); + while (Date.now() - start < TIMEOUT_MS) { + const res = await api(`/api/workflows/${workflowId}/executions`); + const list = await readJson(res); + const latest = list[0]; + if (!latest) { + throw new Error("No execution found for workflow"); + } + if (latest.status !== "running") { + return latest; + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + throw new Error(`Timed out after ${TIMEOUT_MS}ms`); +} + +async function fetchSteps(executionId: string): Promise { + const res = await api(`/api/analytics/runs/${executionId}/steps`); + return readJson(res); +} + +async function main(): Promise { + const doc = loadFixture(FIXTURE); + console.log(`Fixture: ${FIXTURE}`); + console.log(`Workflow: ${doc.name}`); + + const workflowId = await createWorkflow(doc); + console.log(`Created workflow: ${workflowId}`); + + const executionId = await executeWorkflow(workflowId); + console.log(`Execution started: ${executionId}`); + + const final = await pollExecution(workflowId); + console.log( + `\nFinal status: ${final.status} (duration ${final.duration ?? "?"}ms, ` + + `${final.completedSteps ?? "?"} steps completed)` + ); + + const steps = await fetchSteps(executionId); + console.log("\nPer-step trace:"); + for (const s of steps) { + console.log( + ` [${s.status.padEnd(7)}] ${s.nodeName} (${s.nodeType})` + + ` - ${s.durationMs ?? "?"}ms` + + (s.error ? `\n error: ${s.error}` : "") + ); + } + + if (final.output) { + console.log("\nOutput:"); + console.log(JSON.stringify(final.output, null, 2)); + } + + if (final.error) { + console.log(`\nError: ${final.error}`); + process.exit(2); + } + if (final.status !== "success") { + process.exit(2); + } +} + +main().catch((e: unknown) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/scripts/verify-superfluid-addresses.ts b/tests/scripts/verify-superfluid-addresses.ts new file mode 100644 index 000000000..a4f5c6a76 --- /dev/null +++ b/tests/scripts/verify-superfluid-addresses.ts @@ -0,0 +1,134 @@ +/** + * Superfluid forwarder address verification. + * + * One-shot CLI: confirms CFAv1Forwarder and GDAv1Forwarder are deployed + * (have non-empty bytecode) at their pinned addresses on every chain + * Superfluid is shipped on. Run before opening the PR; paste the table + * output into the PR description. + * + * Interpreting failures: a row with a network error (HTTP 401/429/5xx, + * DNS, timeout) means the public RPC is unreachable -- retry or swap the + * URL in the CHAINS array below. A row showing FAIL with no error message + * means the address actually has empty bytecode on that chain (a real + * deployment miss to investigate). + * + * Usage: pnpm tsx scripts/verify-superfluid-addresses.ts + */ + +import { JsonRpcProvider } from "ethers"; +import { + CFA_FORWARDER_ADDRESS, + GDA_FORWARDER_ADDRESS, + SUPERFLUID_CHAIN_IDS, +} from "@/protocols/superfluid"; + +type ForwarderName = "CFAv1Forwarder" | "GDAv1Forwarder"; + +const FORWARDERS: Record = { + CFAv1Forwarder: CFA_FORWARDER_ADDRESS, + GDAv1Forwarder: GDA_FORWARDER_ADDRESS, +}; + +// RPC + display metadata per chain. The chain set itself is sourced from the +// protocol module so adding/removing a chain happens in exactly one place; +// any chain ID present in SUPERFLUID_CHAIN_IDS but missing here will surface +// as an "unknown chain" entry below. +const CHAIN_RPC: Record = { + "1": { name: "Ethereum Mainnet", rpc: "https://rpc.ankr.com/eth" }, + "10": { name: "Optimism", rpc: "https://mainnet.optimism.io" }, + "137": { name: "Polygon", rpc: "https://rpc.ankr.com/polygon" }, + "8453": { name: "Base", rpc: "https://mainnet.base.org" }, + "42161": { name: "Arbitrum One", rpc: "https://arb1.arbitrum.io/rpc" }, + "11155111": { + name: "Sepolia", + rpc: "https://ethereum-sepolia-rpc.publicnode.com", + }, +}; + +const CHAINS: Array<{ id: number; name: string; rpc: string }> = + SUPERFLUID_CHAIN_IDS.map((id) => { + const meta = CHAIN_RPC[id]; + return { + id: Number(id), + name: meta?.name ?? `Unknown chain ${id}`, + rpc: meta?.rpc ?? "", + }; + }); + +type CheckResult = { + chainName: string; + chainId: number; + forwarder: ForwarderName; + address: string; + deployed: boolean; + error?: string; +}; + +async function checkOne( + chain: { id: number; name: string; rpc: string }, + forwarder: ForwarderName +): Promise { + const address = FORWARDERS[forwarder]; + try { + const provider = new JsonRpcProvider(chain.rpc, chain.id); + const code = await provider.getCode(address); + return { + chainName: chain.name, + chainId: chain.id, + forwarder, + address, + deployed: code !== "0x", + }; + } catch (error) { + return { + chainName: chain.name, + chainId: chain.id, + forwarder, + address, + deployed: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function printMarkdownTable(results: CheckResult[]): void { + console.log(""); + console.log("| Chain | Chain ID | Contract | Address | Status |"); + console.log("|---|---|---|---|---|"); + for (const r of results) { + const status = r.deployed ? "OK" : `FAIL${r.error ? ` (${r.error})` : ""}`; + console.log( + `| ${r.chainName} | ${r.chainId} | ${r.forwarder} | \`${r.address}\` | ${status} |` + ); + } + console.log(""); +} + +async function main(): Promise { + console.error("Verifying Superfluid forwarder deployments..."); + + const tasks: Array> = []; + for (const chain of CHAINS) { + for (const fwd of Object.keys(FORWARDERS) as ForwarderName[]) { + tasks.push(checkOne(chain, fwd)); + } + } + + const results = await Promise.all(tasks); + printMarkdownTable(results); + + const failed = results.filter((r) => !r.deployed); + if (failed.length > 0) { + console.error( + `FAILED: ${failed.length} of ${results.length} checks did not find deployed bytecode.` + ); + process.exit(1); + } + + console.error(`All ${results.length} forwarder deployments verified.`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From 68cf0c3916fa15d83002835c2dabe479e5524fdd Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 15:10:02 +1000 Subject: [PATCH 14/19] chore(superfluid): KEEP-415 drop scripts/ duplicates after move to tests/scripts/ The previous commit registered tests/scripts/e2e-superfluid-sepolia.ts and tests/scripts/verify-superfluid-addresses.ts but didn't remove the originals at scripts/, leaving the same blob tracked at two paths. Removes the originals so the move is complete. Also updates the doc comment in protocols/superfluid.ts that referenced the old scripts/ path. --- protocols/superfluid.ts | 2 +- scripts/e2e-superfluid-sepolia.ts | 459 ------------------------- scripts/verify-superfluid-addresses.ts | 134 -------- 3 files changed, 1 insertion(+), 594 deletions(-) delete mode 100644 scripts/e2e-superfluid-sepolia.ts delete mode 100644 scripts/verify-superfluid-addresses.ts diff --git a/protocols/superfluid.ts b/protocols/superfluid.ts index 207aeb949..db7c6bc01 100644 --- a/protocols/superfluid.ts +++ b/protocols/superfluid.ts @@ -7,7 +7,7 @@ import { defineProtocol } from "@/lib/protocol-registry"; * Adding a chain: append its ID here. Both forwarders pick it up automatically * via sameOnAllChains() because the addresses are deliberately constant across * every chain Superfluid supports. To ship a new chain, also add an entry to - * scripts/verify-superfluid-addresses.ts so the bytecode check covers it. + * tests/scripts/verify-superfluid-addresses.ts so the bytecode check covers it. */ export const SUPERFLUID_CHAIN_IDS = [ "1", // Ethereum Mainnet diff --git a/scripts/e2e-superfluid-sepolia.ts b/scripts/e2e-superfluid-sepolia.ts deleted file mode 100644 index 32223b6a1..000000000 --- a/scripts/e2e-superfluid-sepolia.ts +++ /dev/null @@ -1,459 +0,0 @@ -/** - * Live Sepolia end-to-end script for the Superfluid protocol. - * - * Walks the full streaming + pool lifecycle against real Superfluid contracts: - * approve -> wrap -> create-flow -> update-flow -> get-net-flow -> delete-flow - * -> create-pool -> update-member-units -> connect-pool -> distribute-flow - * -> read net flow -> cleanup - * - * Usage: - * export SUPERFLUID_E2E_SENDER_KEY=0x... - * pnpm tsx scripts/e2e-superfluid-sepolia.ts - * - * Contract addresses are pinned to Superfluid's canonical Sepolia deployments. - * If Superfluid migrates a contract, override via env vars: - * SUPERFLUID_E2E_CFA_FORWARDER (default: 0xcfA132E353cB4E398080B9700609bb008eceB125) - * SUPERFLUID_E2E_GDA_FORWARDER (default: 0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08) - * SUPERFLUID_E2E_SUPER_TOKEN (default: 0xb598E6C621618a9f63788816ffb50Ee2862D443B, fUSDCx) - * SUPERFLUID_E2E_UNDERLYING (default: 0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db, fUSDC -- "fUSDC Fake Token"; mint(address,uint256) is permissive on Sepolia) - * - * Required env vars: - * SUPERFLUID_E2E_SENDER_KEY hex private key, 0x-prefixed - * - * Optional env vars: - * SUPERFLUID_E2E_RECEIVER_KEY second wallet key; connect-pool step is SKIPPED if absent - * SUPERFLUID_E2E_RPC_URL default: https://ethereum-sepolia-rpc.publicnode.com - * SUPERFLUID_E2E_WRAP_AMOUNT wei to wrap (default: 100000000) - * SUPERFLUID_E2E_FLOW_RATE wei/sec flow rate (default: 1000000) - * - * Pre-flight: fund the sender wallet with Sepolia ETH (gas) and fUSDC. - * Faucet: https://app.superfluid.finance/faucet (select Sepolia, claim fUSDC). - * The script does NOT automate faucet interaction. - */ - -import { Contract, ethers, JsonRpcProvider, Wallet } from "ethers"; -import { - CFA_FORWARDER_ADDRESS, - GDA_FORWARDER_ADDRESS, -} from "@/protocols/superfluid"; - -const SEPOLIA_CHAIN_ID = 11155111; - -const DEFAULT_RPC = "https://ethereum-sepolia-rpc.publicnode.com"; -const DEFAULT_CFA_FORWARDER = CFA_FORWARDER_ADDRESS; -const DEFAULT_GDA_FORWARDER = GDA_FORWARDER_ADDRESS; -const DEFAULT_SUPER_TOKEN = "0xb598E6C621618a9f63788816ffb50Ee2862D443B"; -const DEFAULT_UNDERLYING = "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; -const DEFAULT_WRAP_AMOUNT = "100000000"; -const DEFAULT_FLOW_RATE = "1000000"; - -// Override gas limit on every write tx. Ethers v6's default eth_estimateGas -// buffer is too tight for CFA/GDA writes -- the actual gas usage can exceed -// the estimate due to SLOAD warming differences between simulation and -// execution, causing OOG reverts (e.g. updateFlow uses ~315k vs estimated -// ~285k). 1.5M gas covers the heaviest call (createPool) with margin. -const TX_OVERRIDES = { gasLimit: 1_500_000 }; - -// Stable receiver address used when SUPERFLUID_E2E_RECEIVER_KEY is not set. -// This is a deterministic dead-drop address; it will receive flows but nobody -// controls the key, so connect-pool cannot be signed for it. -const FALLBACK_RECEIVER = "0x000000000000000000000000000000000000dEaD"; - -const ERC20_ABI = [ - "function approve(address spender, uint256 amount) returns (bool)", - "function balanceOf(address account) view returns (uint256)", - "function decimals() view returns (uint8)", - "function allowance(address owner, address spender) view returns (uint256)", -]; - -const SUPER_TOKEN_ABI = [ - "function upgrade(uint256 amount)", - "function downgrade(uint256 amount)", - "function balanceOf(address account) view returns (uint256)", -]; - -const CFA_FORWARDER_ABI = [ - "function createFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", - "function updateFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", - "function deleteFlow(address token, address sender, address receiver, bytes userData) returns (bool)", - "function getFlowInfo(address token, address sender, address receiver) view returns (uint256 lastUpdated, int96 flowRate, uint256 deposit, uint256 owedDeposit)", - "function getAccountFlowrate(address token, address account) view returns (int96 flowRate)", -]; - -const GDA_FORWARDER_ABI = [ - "function createPool(address token, address admin, tuple(bool transferabilityForUnitsOwner, bool distributionFromAnyAddress) config) returns (bool success, address pool)", - "function updateMemberUnits(address pool, address member, uint128 units, bytes userData) returns (bool)", - "function getNetFlow(address token, address account) view returns (int96)", - "function connectPool(address pool, bytes userData) returns (bool)", - "function distributeFlow(address token, address from, address pool, int96 flowRate, bytes userData) returns (bool)", - "event PoolCreated(address indexed token, address indexed admin, address pool)", -]; - -type StepStatus = "PASS" | "SKIPPED" | "FAILED"; - -interface StepResult { - name: string; - status: StepStatus; - detail: string; -} - -const results: StepResult[] = []; - -function record(name: string, status: StepStatus, detail: string): void { - results.push({ name, status, detail }); - const prefix = status === "PASS" ? "OK" : status === "SKIPPED" ? "SKIPPED" : "FAILED"; - console.log(`[${prefix}] ${name}: ${detail}`); -} - -function requireEnv(name: string): string { - const val = process.env[name]; - if (!val) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return val; -} - -function getEnv(name: string, fallback: string): string { - return process.env[name] ?? fallback; -} - -async function sendAndWait( - label: string, - // biome-ignore lint/suspicious/noExplicitAny: ethers contract calls return ContractTransactionResponse which needs any - txPromise: Promise -): Promise { - // biome-ignore lint/suspicious/noExplicitAny: ethers v6 contract method return type - const tx: any = await txPromise; - console.log(` [${label}] tx ${tx.hash}`); - const receipt: ethers.TransactionReceipt | null = await tx.wait(); - if (!receipt || receipt.status !== 1) { - throw new Error(`Transaction reverted: ${tx.hash}`); - } - return receipt; -} - -async function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function main(): Promise { - // --- env setup --- - const senderKey = requireEnv("SUPERFLUID_E2E_SENDER_KEY"); - const receiverKey = process.env.SUPERFLUID_E2E_RECEIVER_KEY; - const rpcUrl = getEnv("SUPERFLUID_E2E_RPC_URL", DEFAULT_RPC); - const cfaAddress = getEnv("SUPERFLUID_E2E_CFA_FORWARDER", DEFAULT_CFA_FORWARDER); - const gdaAddress = getEnv("SUPERFLUID_E2E_GDA_FORWARDER", DEFAULT_GDA_FORWARDER); - const superTokenAddress = getEnv("SUPERFLUID_E2E_SUPER_TOKEN", DEFAULT_SUPER_TOKEN); - const underlyingAddress = getEnv("SUPERFLUID_E2E_UNDERLYING", DEFAULT_UNDERLYING); - const wrapAmount = BigInt(getEnv("SUPERFLUID_E2E_WRAP_AMOUNT", DEFAULT_WRAP_AMOUNT)); - const flowRate = BigInt(getEnv("SUPERFLUID_E2E_FLOW_RATE", DEFAULT_FLOW_RATE)); - - const provider = new JsonRpcProvider(rpcUrl, SEPOLIA_CHAIN_ID); - const senderWallet = new Wallet(senderKey, provider); - const senderAddress = senderWallet.address; - - let receiverAddress: string; - let receiverWallet: Wallet | null = null; - if (receiverKey) { - receiverWallet = new Wallet(receiverKey, provider); - receiverAddress = receiverWallet.address; - } else { - receiverAddress = FALLBACK_RECEIVER; - console.log(`SUPERFLUID_E2E_RECEIVER_KEY not set -- using fallback receiver ${FALLBACK_RECEIVER}`); - console.log("Steps requiring receiver signature will be SKIPPED."); - } - - console.log(`Sender: ${senderAddress}`); - console.log(`Receiver: ${receiverAddress}`); - console.log(`RPC: ${rpcUrl}`); - console.log(`Chain ID: ${SEPOLIA_CHAIN_ID}`); - console.log(""); - - const erc20 = new Contract(underlyingAddress, ERC20_ABI, senderWallet); - const superToken = new Contract(superTokenAddress, SUPER_TOKEN_ABI, senderWallet); - const cfa = new Contract(cfaAddress, CFA_FORWARDER_ABI, senderWallet); - const gda = new Contract(gdaAddress, GDA_FORWARDER_ABI, senderWallet); - - // --- step 1: pre-flight --- - console.log("[step 1 / pre-flight]"); - const ethBalance: bigint = await provider.getBalance(senderAddress); - const minEth = ethers.parseEther("0.01"); - if (ethBalance < minEth) { - console.error( - `Insufficient ETH for gas: ${ethers.formatEther(ethBalance)} ETH (need >= 0.01). Fund the sender wallet on Sepolia.` - ); - process.exit(1); - } - const underlyingBalance: bigint = await erc20.balanceOf(senderAddress); - if (underlyingBalance < wrapAmount) { - console.error( - `Insufficient fUSDC balance: ${underlyingBalance} wei (need >= ${wrapAmount}). Claim from https://app.superfluid.finance/faucet (Sepolia).` - ); - process.exit(1); - } - record( - "step 1 / pre-flight", - "PASS", - `ETH ${ethers.formatEther(ethBalance)} | fUSDC balance ${underlyingBalance} wei` - ); - - // --- idempotency: close any pre-existing flow before the lifecycle --- - console.log("\n[idempotency check]"); - const [, existingRate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (existingRate > BigInt(0)) { - console.log(` Pre-existing flow found (${existingRate} wei/s). Deleting before lifecycle.`); - await sendAndWait("pre-cleanup delete-flow", cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES)); - console.log(" Pre-existing flow deleted."); - } else { - console.log(" No pre-existing flow. Proceeding."); - } - - // --- step 2: approve underlying --- - console.log("\n[step 2 / approve]"); - const receipt2 = await sendAndWait( - "approve", - erc20.approve(superTokenAddress, wrapAmount, TX_OVERRIDES) - ); - record( - "step 2 / approve", - "PASS", - `tx ${receipt2.hash} | amount ${wrapAmount} wei` - ); - - // --- step 3: wrap --- - console.log("\n[step 3 / wrap]"); - const balBefore: bigint = await superToken.balanceOf(senderAddress); - const receipt3 = await sendAndWait("wrap", superToken.upgrade(wrapAmount, TX_OVERRIDES)); - const balAfter: bigint = await superToken.balanceOf(senderAddress); - if (balAfter <= balBefore) { - throw new Error(`Wrap did not increase SuperToken balance: before=${balBefore} after=${balAfter}`); - } - record( - "step 3 / wrap", - "PASS", - `tx ${receipt3.hash} | superToken balance before=${balBefore} after=${balAfter}` - ); - - // --- step 4: create-flow --- - console.log("\n[step 4 / create-flow]"); - const receipt4 = await sendAndWait( - "create-flow", - cfa.createFlow(superTokenAddress, senderAddress, receiverAddress, flowRate, "0x", TX_OVERRIDES) - ); - const [, flowRateAfterCreate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (flowRateAfterCreate !== flowRate) { - throw new Error(`Flow rate mismatch after create: expected ${flowRate}, got ${flowRateAfterCreate}`); - } - record( - "step 4 / create-flow", - "PASS", - `tx ${receipt4.hash} | flowRate ${flowRate} wei/s` - ); - - // --- step 5: update-flow --- - console.log("\n[step 5 / update-flow]"); - const newFlowRate = flowRate * BigInt(2); - const receipt5 = await sendAndWait( - "update-flow", - cfa.updateFlow(superTokenAddress, senderAddress, receiverAddress, newFlowRate, "0x", TX_OVERRIDES) - ); - const [, flowRateAfterUpdate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (flowRateAfterUpdate !== newFlowRate) { - throw new Error(`Flow rate mismatch after update: expected ${newFlowRate}, got ${flowRateAfterUpdate}`); - } - record( - "step 5 / update-flow", - "PASS", - `tx ${receipt5.hash} | flowRate ${newFlowRate} wei/s` - ); - - // --- step 6: get-net-flow --- - console.log("\n[step 6 / get-net-flow]"); - const netFlow: bigint = await cfa.getAccountFlowrate(superTokenAddress, receiverAddress); - // netFlow may be negative for the sender side; for receiver it should be >= newFlowRate - // (could be higher if receiver has other inbound flows) - record( - "step 6 / get-net-flow", - "PASS", - `receiver net flow ${netFlow} wei/s` - ); - - // --- step 7: delete-flow --- - console.log("\n[step 7 / delete-flow]"); - const receipt7 = await sendAndWait( - "delete-flow", - cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES) - ); - const [, flowRateAfterDelete]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (flowRateAfterDelete !== BigInt(0)) { - throw new Error(`Flow still active after delete: rate=${flowRateAfterDelete}`); - } - record( - "step 7 / delete-flow", - "PASS", - `tx ${receipt7.hash} | flowRate now 0` - ); - - // --- step 8: create-pool --- - console.log("\n[step 8 / create-pool]"); - const receipt8 = await sendAndWait( - "create-pool", - gda.createPool(superTokenAddress, senderAddress, [false, false], TX_OVERRIDES) - ); - - // Parse PoolCreated event from receipt logs - const gdaInterface = new ethers.Interface(GDA_FORWARDER_ABI); - const poolCreatedTopic = gdaInterface.getEvent("PoolCreated")?.topicHash; - let poolAddress = ""; - for (const log of receipt8.logs) { - if (log.topics[0] === poolCreatedTopic) { - const parsed = gdaInterface.parseLog({ topics: [...log.topics], data: log.data }); - if (parsed?.args.pool) { - poolAddress = parsed.args.pool as string; - break; - } - } - } - if (!poolAddress) { - throw new Error("PoolCreated event not found in create-pool receipt"); - } - record( - "step 8 / create-pool", - "PASS", - `tx ${receipt8.hash} | pool ${poolAddress}` - ); - - // --- step 9: update-member-units --- - console.log("\n[step 9 / update-member-units]"); - const receipt9 = await sendAndWait( - "update-member-units", - gda.updateMemberUnits(poolAddress, receiverAddress, BigInt(100), "0x", TX_OVERRIDES) - ); - record( - "step 9 / update-member-units", - "PASS", - `tx ${receipt9.hash} | member ${receiverAddress} | units 100` - ); - - // --- step 10: connect-pool (receiver must sign) --- - console.log("\n[step 10 / connect-pool]"); - if (receiverWallet !== null) { - const gdaAsReceiver = new Contract(gdaAddress, GDA_FORWARDER_ABI, receiverWallet); - const receipt10 = await sendAndWait( - "connect-pool", - gdaAsReceiver.connectPool(poolAddress, "0x", TX_OVERRIDES) - ); - record( - "step 10 / connect-pool", - "PASS", - `tx ${receipt10.hash} | receiver ${receiverAddress} connected` - ); - } else { - record( - "step 10 / connect-pool", - "SKIPPED", - "SUPERFLUID_E2E_RECEIVER_KEY not set; receiver cannot sign connect-pool" - ); - } - - // --- step 11: distribute-flow --- - console.log("\n[step 11 / distribute-flow]"); - const receipt11 = await sendAndWait( - "distribute-flow", - gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, flowRate, "0x", TX_OVERRIDES) - ); - record( - "step 11 / distribute-flow", - "PASS", - `tx ${receipt11.hash} | flowRate ${flowRate} wei/s` - ); - - // --- step 12: read net flow twice, 5 seconds apart --- - // CFA's getAccountFlowrate aggregates only CFA flows. To observe the - // receiver's incoming pool stream we have to query the GDA forwarder's - // getNetFlow, which combines CFA and GDA flows. - console.log("\n[step 12 / read-net-flow x2]"); - const netFlowBefore: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); - console.log(` net flow (t=0): ${netFlowBefore} wei/s`); - await sleep(5000); - const netFlowAfter: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); - console.log(` net flow (t=5s): ${netFlowAfter} wei/s`); - - if (receiverWallet !== null) { - if (netFlowAfter <= BigInt(0)) { - throw new Error(`Expected receiver net flow to be > 0 after connect-pool + distribute-flow, got ${netFlowAfter}`); - } - record( - "step 12 / read-net-flow", - "PASS", - `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s` - ); - } else { - record( - "step 12 / read-net-flow", - "PASS", - `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s (receiver not connected; flow accumulation not verified)` - ); - } - - // --- step 13: cleanup --- - console.log("\n[step 13 / cleanup]"); - const receipt13 = await sendAndWait( - "cleanup distribute-flow", - gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, BigInt(0), "0x", TX_OVERRIDES) - ); - record( - "step 13 / cleanup", - "PASS", - `tx ${receipt13.hash} | pool stream closed` - ); - - // --- summary --- - console.log("\n--- SUMMARY ---"); - console.log( - `${"Step".padEnd(40)} ${"Status".padEnd(10)} Detail` - ); - console.log("-".repeat(100)); - for (const r of results) { - console.log(`${r.name.padEnd(40)} ${r.status.padEnd(10)} ${r.detail}`); - } - - const failed = results.filter((r) => r.status === "FAILED"); - const skipped = results.filter((r) => r.status === "SKIPPED"); - console.log( - `\n${results.length} steps total | ${results.length - failed.length - skipped.length} PASS | ${skipped.length} SKIPPED | ${failed.length} FAILED` - ); - - if (failed.length > 0) { - process.exit(1); - } -} - -main().catch((error: unknown) => { - const failed = results.filter((r) => r.status === "FAILED"); - const msg = error instanceof Error ? error.message : String(error); - if (failed.length === 0) { - // Unrecorded failure - console.error(`\nFATAL: ${msg}`); - } - process.exit(1); -}); diff --git a/scripts/verify-superfluid-addresses.ts b/scripts/verify-superfluid-addresses.ts deleted file mode 100644 index a4f5c6a76..000000000 --- a/scripts/verify-superfluid-addresses.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Superfluid forwarder address verification. - * - * One-shot CLI: confirms CFAv1Forwarder and GDAv1Forwarder are deployed - * (have non-empty bytecode) at their pinned addresses on every chain - * Superfluid is shipped on. Run before opening the PR; paste the table - * output into the PR description. - * - * Interpreting failures: a row with a network error (HTTP 401/429/5xx, - * DNS, timeout) means the public RPC is unreachable -- retry or swap the - * URL in the CHAINS array below. A row showing FAIL with no error message - * means the address actually has empty bytecode on that chain (a real - * deployment miss to investigate). - * - * Usage: pnpm tsx scripts/verify-superfluid-addresses.ts - */ - -import { JsonRpcProvider } from "ethers"; -import { - CFA_FORWARDER_ADDRESS, - GDA_FORWARDER_ADDRESS, - SUPERFLUID_CHAIN_IDS, -} from "@/protocols/superfluid"; - -type ForwarderName = "CFAv1Forwarder" | "GDAv1Forwarder"; - -const FORWARDERS: Record = { - CFAv1Forwarder: CFA_FORWARDER_ADDRESS, - GDAv1Forwarder: GDA_FORWARDER_ADDRESS, -}; - -// RPC + display metadata per chain. The chain set itself is sourced from the -// protocol module so adding/removing a chain happens in exactly one place; -// any chain ID present in SUPERFLUID_CHAIN_IDS but missing here will surface -// as an "unknown chain" entry below. -const CHAIN_RPC: Record = { - "1": { name: "Ethereum Mainnet", rpc: "https://rpc.ankr.com/eth" }, - "10": { name: "Optimism", rpc: "https://mainnet.optimism.io" }, - "137": { name: "Polygon", rpc: "https://rpc.ankr.com/polygon" }, - "8453": { name: "Base", rpc: "https://mainnet.base.org" }, - "42161": { name: "Arbitrum One", rpc: "https://arb1.arbitrum.io/rpc" }, - "11155111": { - name: "Sepolia", - rpc: "https://ethereum-sepolia-rpc.publicnode.com", - }, -}; - -const CHAINS: Array<{ id: number; name: string; rpc: string }> = - SUPERFLUID_CHAIN_IDS.map((id) => { - const meta = CHAIN_RPC[id]; - return { - id: Number(id), - name: meta?.name ?? `Unknown chain ${id}`, - rpc: meta?.rpc ?? "", - }; - }); - -type CheckResult = { - chainName: string; - chainId: number; - forwarder: ForwarderName; - address: string; - deployed: boolean; - error?: string; -}; - -async function checkOne( - chain: { id: number; name: string; rpc: string }, - forwarder: ForwarderName -): Promise { - const address = FORWARDERS[forwarder]; - try { - const provider = new JsonRpcProvider(chain.rpc, chain.id); - const code = await provider.getCode(address); - return { - chainName: chain.name, - chainId: chain.id, - forwarder, - address, - deployed: code !== "0x", - }; - } catch (error) { - return { - chainName: chain.name, - chainId: chain.id, - forwarder, - address, - deployed: false, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -function printMarkdownTable(results: CheckResult[]): void { - console.log(""); - console.log("| Chain | Chain ID | Contract | Address | Status |"); - console.log("|---|---|---|---|---|"); - for (const r of results) { - const status = r.deployed ? "OK" : `FAIL${r.error ? ` (${r.error})` : ""}`; - console.log( - `| ${r.chainName} | ${r.chainId} | ${r.forwarder} | \`${r.address}\` | ${status} |` - ); - } - console.log(""); -} - -async function main(): Promise { - console.error("Verifying Superfluid forwarder deployments..."); - - const tasks: Array> = []; - for (const chain of CHAINS) { - for (const fwd of Object.keys(FORWARDERS) as ForwarderName[]) { - tasks.push(checkOne(chain, fwd)); - } - } - - const results = await Promise.all(tasks); - printMarkdownTable(results); - - const failed = results.filter((r) => !r.deployed); - if (failed.length > 0) { - console.error( - `FAILED: ${failed.length} of ${results.length} checks did not find deployed bytecode.` - ); - process.exit(1); - } - - console.error(`All ${results.length} forwarder deployments verified.`); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); From 30de62c00267cbebb63211d8ae8e2eb7e39eebfc Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 15:26:08 +1000 Subject: [PATCH 15/19] chore(scripts): KEEP-415 take FUNDER_PK directly via env, not via file path fund-test-wallet.ts previously read the funder key by parsing "PK = " out of a file at FUNDER_PK_PATH (defaulting to TechOps/.secrets/WEB3.txt). That coupled the script to a specific filesystem layout and made it harder to use from CI or a different mega-repo position. Now reads FUNDER_PK directly from the env, accepts both 0x-prefixed and bare hex, validates the format up front, and drops the fs/path imports along with the regex parse. Same env-only convention the other tests/scripts/ helpers use. Source the value from a secrets manager, a gitignored .envrc, or your shell environment -- never check it in. --- tests/scripts/fund-test-wallet.ts | 46 ++++++++++--------------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/tests/scripts/fund-test-wallet.ts b/tests/scripts/fund-test-wallet.ts index a7824b69b..ef0df1aff 100644 --- a/tests/scripts/fund-test-wallet.ts +++ b/tests/scripts/fund-test-wallet.ts @@ -7,12 +7,16 @@ * workflow fixtures (create-pool, wrap, etc.) against a PR deploy. * * Usage: + * FUNDER_PK=0xabc... \ * TARGET=0x42d92e...63ef \ * ETH_AMOUNT=0.005 \ * FUSDC_AMOUNT=5 \ * pnpm tsx tests/scripts/fund-test-wallet.ts * * Required env: + * FUNDER_PK hex private key, 0x-prefixed (or unprefixed; both are + * accepted). Source it from your secrets manager or a + * gitignored .envrc -- never check it in. * TARGET recipient address (the workflow wallet -- get it via * GET /api/user/wallet on the deploy) * @@ -20,10 +24,6 @@ * ETH_AMOUNT SepETH to send, in ether units (default "0.005") * Pass "0" to skip the ETH transfer. * FUSDC_AMOUNT fUSDC to mint, in token units (default "0" = skip) - * FUNDER_PK_PATH file containing PK = (default reads - * ../../../.secrets/WEB3.txt relative to this script, - * i.e. TechOps/.secrets/WEB3.txt assuming the standard - * mega-repo layout) * RPC_URL Sepolia RPC (default ethereum-sepolia-rpc.publicnode.com) * FUSDC_ADDRESS override fUSDC address (default 0xe72f...20Db) * @@ -31,16 +31,13 @@ * mint(address, uint256) -- per the e2e script's documented quirk -- so * any caller can drop tokens into any address. Don't assume real USDC * behaves like this. - * - * Don't echo PKs in CI logs; this script reads from disk and never - * prints the key. */ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { ethers } from "ethers"; +const HEX_KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/; + +const FUNDER_PK = normalizePk(requireEnv("FUNDER_PK")); const TARGET = requireEnv("TARGET"); const ETH_AMOUNT = process.env.ETH_AMOUNT ?? "0.005"; const FUSDC_AMOUNT = process.env.FUSDC_AMOUNT ?? "0"; @@ -49,8 +46,6 @@ const RPC_URL = const FUSDC_ADDRESS = process.env.FUSDC_ADDRESS ?? "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; -const PK_LINE_RE = /^PK\s*=\s*([0-9a-fA-F]{64})\s*$/m; - function requireEnv(name: string): string { const v = process.env[name]; if (!v) { @@ -60,26 +55,15 @@ function requireEnv(name: string): string { return v; } -function defaultFunderPath(): string { - const here = path.dirname(fileURLToPath(import.meta.url)); - // tests/scripts/ -> ../../ keeperhub root -> ../ TechOps root -> .secrets/WEB3.txt - return path.resolve(here, "../../../.secrets/WEB3.txt"); -} - -function loadFunderKey(): string { - const p = process.env.FUNDER_PK_PATH ?? defaultFunderPath(); - if (!fs.existsSync(p)) { - console.error(`Funder key file not found: ${p}`); - console.error("Set FUNDER_PK_PATH or place WEB3.txt at the default path."); - process.exit(1); - } - const raw = fs.readFileSync(p, "utf-8"); - const match = raw.match(PK_LINE_RE); - if (!match) { - console.error(`Could not find "PK = <64 hex chars>" line in ${p}`); +function normalizePk(raw: string): string { + const trimmed = raw.trim(); + if (!HEX_KEY_RE.test(trimmed)) { + console.error( + "FUNDER_PK must be a 64-character hex string (with or without 0x prefix)" + ); process.exit(1); } - return `0x${match[1]}`; + return trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`; } const FUSDC_MINT_ABI = [ @@ -94,7 +78,7 @@ async function main(): Promise { } const provider = new ethers.JsonRpcProvider(RPC_URL); - const funder = new ethers.Wallet(loadFunderKey(), provider); + const funder = new ethers.Wallet(FUNDER_PK, provider); console.log(`Funder: ${funder.address}`); console.log(`Target: ${TARGET}`); From 9ee123d1090fd4f3c6a05cf2e0d84005af8f3a10 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 15:32:38 +1000 Subject: [PATCH 16/19] refactor(rpc): KEEP-415 single source of truth for chain RPCs in test scripts The chain seed file (scripts/seed/seed-chains.ts) already imports its RPC URLs from lib/rpc/rpc-config.ts, but three test scripts duplicated URLs inline: - tests/scripts/verify-superfluid-addresses.ts had its own per-chain CHAIN_RPC map with hand-picked URLs (and chose different primaries than the lib for ETH/Polygon). - tests/scripts/fund-test-wallet.ts hardcoded the Sepolia URL. - tests/scripts/e2e-superfluid-sepolia.ts hardcoded the Sepolia URL. Fix: every test script now imports PUBLIC_RPCS from lib/rpc/rpc-config.ts and references the same constants the seed file does. Updating an RPC URL is a one-line change in the lib that benefits every caller. lib/rpc/rpc-config.ts gains an OPTIMISM_MAINNET entry in PUBLIC_RPCS (the verify script needs it; Superfluid runs on Optimism). No CHAIN_CONFIG entry yet because no keeperhub-supported feature uses Optimism -- add one when the chain becomes a registered choice in the workflow builder. --- lib/rpc/rpc-config.ts | 9 +++- tests/scripts/e2e-superfluid-sepolia.ts | 5 ++- tests/scripts/fund-test-wallet.ts | 7 ++-- tests/scripts/verify-superfluid-addresses.ts | 43 ++++++++++---------- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/lib/rpc/rpc-config.ts b/lib/rpc/rpc-config.ts index b638337cd..d6d15d501 100644 --- a/lib/rpc/rpc-config.ts +++ b/lib/rpc/rpc-config.ts @@ -43,6 +43,11 @@ export const PUBLIC_RPCS = { POLYGON_AMOY_FALLBACK: "https://polygon-amoy-bor-rpc.publicnode.com", ARBITRUM_MAINNET: "https://arb1.arbitrum.io/rpc", ARBITRUM_MAINNET_FALLBACK: "https://rpc.ankr.com/arbitrum", + // Optimism is not in CHAIN_CONFIG (no keeperhub-supported feature has + // needed it yet), but Superfluid runs there and the verify script + // imports this entry. Add a CHAIN_CONFIG entry alongside if/when + // keeperhub adds Optimism as a registered chain. + OPTIMISM_MAINNET: "https://mainnet.optimism.io", ARBITRUM_SEPOLIA: "https://sepolia-rollup.arbitrum.io/rpc", ARBITRUM_SEPOLIA_FALLBACK: "https://arbitrum-sepolia-rpc.publicnode.com", AVAX_MAINNET: "https://api.avax.network/ext/bc/C/rpc", @@ -459,7 +464,9 @@ export function getPrivateRpcUrl( export function getUsePrivateMempoolRpc( options: GetPrivateMempoolOptions ): boolean { - return options.rpcConfig[options.jsonKey]?.isPrivateMempoolRpcEnabled ?? false; + return ( + options.rpcConfig[options.jsonKey]?.isPrivateMempoolRpcEnabled ?? false + ); } /** diff --git a/tests/scripts/e2e-superfluid-sepolia.ts b/tests/scripts/e2e-superfluid-sepolia.ts index 32223b6a1..6afed252d 100644 --- a/tests/scripts/e2e-superfluid-sepolia.ts +++ b/tests/scripts/e2e-superfluid-sepolia.ts @@ -22,7 +22,7 @@ * * Optional env vars: * SUPERFLUID_E2E_RECEIVER_KEY second wallet key; connect-pool step is SKIPPED if absent - * SUPERFLUID_E2E_RPC_URL default: https://ethereum-sepolia-rpc.publicnode.com + * SUPERFLUID_E2E_RPC_URL default: PUBLIC_RPCS.SEPOLIA from lib/rpc/rpc-config.ts * SUPERFLUID_E2E_WRAP_AMOUNT wei to wrap (default: 100000000) * SUPERFLUID_E2E_FLOW_RATE wei/sec flow rate (default: 1000000) * @@ -32,6 +32,7 @@ */ import { Contract, ethers, JsonRpcProvider, Wallet } from "ethers"; +import { PUBLIC_RPCS } from "@/lib/rpc/rpc-config"; import { CFA_FORWARDER_ADDRESS, GDA_FORWARDER_ADDRESS, @@ -39,7 +40,7 @@ import { const SEPOLIA_CHAIN_ID = 11155111; -const DEFAULT_RPC = "https://ethereum-sepolia-rpc.publicnode.com"; +const DEFAULT_RPC = PUBLIC_RPCS.SEPOLIA; const DEFAULT_CFA_FORWARDER = CFA_FORWARDER_ADDRESS; const DEFAULT_GDA_FORWARDER = GDA_FORWARDER_ADDRESS; const DEFAULT_SUPER_TOKEN = "0xb598E6C621618a9f63788816ffb50Ee2862D443B"; diff --git a/tests/scripts/fund-test-wallet.ts b/tests/scripts/fund-test-wallet.ts index ef0df1aff..89468ce15 100644 --- a/tests/scripts/fund-test-wallet.ts +++ b/tests/scripts/fund-test-wallet.ts @@ -24,7 +24,8 @@ * ETH_AMOUNT SepETH to send, in ether units (default "0.005") * Pass "0" to skip the ETH transfer. * FUSDC_AMOUNT fUSDC to mint, in token units (default "0" = skip) - * RPC_URL Sepolia RPC (default ethereum-sepolia-rpc.publicnode.com) + * RPC_URL Sepolia RPC (default PUBLIC_RPCS.SEPOLIA from + * lib/rpc/rpc-config.ts -- the single source of truth) * FUSDC_ADDRESS override fUSDC address (default 0xe72f...20Db) * * The fUSDC underlying on Sepolia ships with a permissive @@ -34,6 +35,7 @@ */ import { ethers } from "ethers"; +import { PUBLIC_RPCS } from "@/lib/rpc/rpc-config"; const HEX_KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/; @@ -41,8 +43,7 @@ const FUNDER_PK = normalizePk(requireEnv("FUNDER_PK")); const TARGET = requireEnv("TARGET"); const ETH_AMOUNT = process.env.ETH_AMOUNT ?? "0.005"; const FUSDC_AMOUNT = process.env.FUSDC_AMOUNT ?? "0"; -const RPC_URL = - process.env.RPC_URL ?? "https://ethereum-sepolia-rpc.publicnode.com"; +const RPC_URL = process.env.RPC_URL ?? PUBLIC_RPCS.SEPOLIA; const FUSDC_ADDRESS = process.env.FUSDC_ADDRESS ?? "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; diff --git a/tests/scripts/verify-superfluid-addresses.ts b/tests/scripts/verify-superfluid-addresses.ts index a4f5c6a76..a425775f0 100644 --- a/tests/scripts/verify-superfluid-addresses.ts +++ b/tests/scripts/verify-superfluid-addresses.ts @@ -7,15 +7,17 @@ * output into the PR description. * * Interpreting failures: a row with a network error (HTTP 401/429/5xx, - * DNS, timeout) means the public RPC is unreachable -- retry or swap the - * URL in the CHAINS array below. A row showing FAIL with no error message - * means the address actually has empty bytecode on that chain (a real - * deployment miss to investigate). + * DNS, timeout) means the public RPC is unreachable -- retry, or update + * PUBLIC_RPCS in lib/rpc/rpc-config.ts (the single source of truth for + * chain RPC URLs). A row showing FAIL with no error message means the + * address actually has empty bytecode on that chain (a real deployment + * miss to investigate). * - * Usage: pnpm tsx scripts/verify-superfluid-addresses.ts + * Usage: pnpm tsx tests/scripts/verify-superfluid-addresses.ts */ import { JsonRpcProvider } from "ethers"; +import { PUBLIC_RPCS } from "@/lib/rpc/rpc-config"; import { CFA_FORWARDER_ADDRESS, GDA_FORWARDER_ADDRESS, @@ -29,25 +31,22 @@ const FORWARDERS: Record = { GDAv1Forwarder: GDA_FORWARDER_ADDRESS, }; -// RPC + display metadata per chain. The chain set itself is sourced from the -// protocol module so adding/removing a chain happens in exactly one place; -// any chain ID present in SUPERFLUID_CHAIN_IDS but missing here will surface -// as an "unknown chain" entry below. -const CHAIN_RPC: Record = { - "1": { name: "Ethereum Mainnet", rpc: "https://rpc.ankr.com/eth" }, - "10": { name: "Optimism", rpc: "https://mainnet.optimism.io" }, - "137": { name: "Polygon", rpc: "https://rpc.ankr.com/polygon" }, - "8453": { name: "Base", rpc: "https://mainnet.base.org" }, - "42161": { name: "Arbitrum One", rpc: "https://arb1.arbitrum.io/rpc" }, - "11155111": { - name: "Sepolia", - rpc: "https://ethereum-sepolia-rpc.publicnode.com", - }, +// Chain ID -> { display name, public RPC URL }. RPCs come from +// lib/rpc/rpc-config.ts so updating the URL in one place benefits every +// caller (seed-chains.ts, the runtime resolver, and this script). Display +// names are local because the lib intentionally only carries URLs. +const CHAIN_META: Record = { + "1": { name: "Ethereum Mainnet", rpc: PUBLIC_RPCS.ETH_MAINNET }, + "10": { name: "Optimism", rpc: PUBLIC_RPCS.OPTIMISM_MAINNET }, + "137": { name: "Polygon", rpc: PUBLIC_RPCS.POLYGON_MAINNET }, + "8453": { name: "Base", rpc: PUBLIC_RPCS.BASE_MAINNET }, + "42161": { name: "Arbitrum One", rpc: PUBLIC_RPCS.ARBITRUM_MAINNET }, + "11155111": { name: "Sepolia", rpc: PUBLIC_RPCS.SEPOLIA }, }; -const CHAINS: Array<{ id: number; name: string; rpc: string }> = +const CHAINS: { id: number; name: string; rpc: string }[] = SUPERFLUID_CHAIN_IDS.map((id) => { - const meta = CHAIN_RPC[id]; + const meta = CHAIN_META[id]; return { id: Number(id), name: meta?.name ?? `Unknown chain ${id}`, @@ -107,7 +106,7 @@ function printMarkdownTable(results: CheckResult[]): void { async function main(): Promise { console.error("Verifying Superfluid forwarder deployments..."); - const tasks: Array> = []; + const tasks: Promise[] = []; for (const chain of CHAINS) { for (const fwd of Object.keys(FORWARDERS) as ForwarderName[]) { tasks.push(checkOne(chain, fwd)); From ef333b6a08d98749f35d3c8250d1abe9bec8f17f Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 15:50:05 +1000 Subject: [PATCH 17/19] chore(superfluid): KEEP-415 move live-test scripts out of keeperhub Removes tests/scripts/ from this PR -- the four scripts (run-fixture, fund-test-wallet, e2e-superfluid-sepolia, verify-superfluid-addresses) land in TechOps/scripts/ instead. They drove the manual PR-deploy verification but are tooling, not protocol or test code, so keeping them in the keeperhub PR was bloat. Side effects of the move: - lib/rpc/rpc-config.ts loses its OPTIMISM_MAINNET entry. It was added for the verify script which is leaving; no keeperhub-supported feature uses Optimism yet, so the lib stays focused on registered chains. - protocols/superfluid.ts docstring updated to point at the new TechOps location for the bytecode-check script (and to flag the manual sync requirement -- the script keeps inline copies of the forwarder addresses and chain-id list now, so adding a chain means updating both files). - Fixes the typecheck failure on the previous commit: fund-test-wallet used a `0n` BigInt literal which is unavailable at the project's ES2017 target. The script now lives in TechOps where it's not bound by keeperhub's tsconfig, but the pattern is fixed there too (BigInt(0)). Coverage remaining in keeperhub: - tests/integration/protocol-superfluid-onchain.test.ts (17 tests) - tests/integration/protocol-superfluid-workflow-fixtures.test.ts - tests/integration/fixtures/superfluid-workflows/ (4 JSON fixtures) - tests/unit/superfluid-protocol.test.ts (39 unit tests) --- tests/scripts/e2e-superfluid-sepolia.ts | 460 ------------------- tests/scripts/fund-test-wallet.ts | 129 ------ tests/scripts/run-fixture.ts | 216 --------- tests/scripts/verify-superfluid-addresses.ts | 133 ------ 4 files changed, 938 deletions(-) delete mode 100644 tests/scripts/e2e-superfluid-sepolia.ts delete mode 100644 tests/scripts/fund-test-wallet.ts delete mode 100644 tests/scripts/run-fixture.ts delete mode 100644 tests/scripts/verify-superfluid-addresses.ts diff --git a/tests/scripts/e2e-superfluid-sepolia.ts b/tests/scripts/e2e-superfluid-sepolia.ts deleted file mode 100644 index 6afed252d..000000000 --- a/tests/scripts/e2e-superfluid-sepolia.ts +++ /dev/null @@ -1,460 +0,0 @@ -/** - * Live Sepolia end-to-end script for the Superfluid protocol. - * - * Walks the full streaming + pool lifecycle against real Superfluid contracts: - * approve -> wrap -> create-flow -> update-flow -> get-net-flow -> delete-flow - * -> create-pool -> update-member-units -> connect-pool -> distribute-flow - * -> read net flow -> cleanup - * - * Usage: - * export SUPERFLUID_E2E_SENDER_KEY=0x... - * pnpm tsx scripts/e2e-superfluid-sepolia.ts - * - * Contract addresses are pinned to Superfluid's canonical Sepolia deployments. - * If Superfluid migrates a contract, override via env vars: - * SUPERFLUID_E2E_CFA_FORWARDER (default: 0xcfA132E353cB4E398080B9700609bb008eceB125) - * SUPERFLUID_E2E_GDA_FORWARDER (default: 0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08) - * SUPERFLUID_E2E_SUPER_TOKEN (default: 0xb598E6C621618a9f63788816ffb50Ee2862D443B, fUSDCx) - * SUPERFLUID_E2E_UNDERLYING (default: 0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db, fUSDC -- "fUSDC Fake Token"; mint(address,uint256) is permissive on Sepolia) - * - * Required env vars: - * SUPERFLUID_E2E_SENDER_KEY hex private key, 0x-prefixed - * - * Optional env vars: - * SUPERFLUID_E2E_RECEIVER_KEY second wallet key; connect-pool step is SKIPPED if absent - * SUPERFLUID_E2E_RPC_URL default: PUBLIC_RPCS.SEPOLIA from lib/rpc/rpc-config.ts - * SUPERFLUID_E2E_WRAP_AMOUNT wei to wrap (default: 100000000) - * SUPERFLUID_E2E_FLOW_RATE wei/sec flow rate (default: 1000000) - * - * Pre-flight: fund the sender wallet with Sepolia ETH (gas) and fUSDC. - * Faucet: https://app.superfluid.finance/faucet (select Sepolia, claim fUSDC). - * The script does NOT automate faucet interaction. - */ - -import { Contract, ethers, JsonRpcProvider, Wallet } from "ethers"; -import { PUBLIC_RPCS } from "@/lib/rpc/rpc-config"; -import { - CFA_FORWARDER_ADDRESS, - GDA_FORWARDER_ADDRESS, -} from "@/protocols/superfluid"; - -const SEPOLIA_CHAIN_ID = 11155111; - -const DEFAULT_RPC = PUBLIC_RPCS.SEPOLIA; -const DEFAULT_CFA_FORWARDER = CFA_FORWARDER_ADDRESS; -const DEFAULT_GDA_FORWARDER = GDA_FORWARDER_ADDRESS; -const DEFAULT_SUPER_TOKEN = "0xb598E6C621618a9f63788816ffb50Ee2862D443B"; -const DEFAULT_UNDERLYING = "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; -const DEFAULT_WRAP_AMOUNT = "100000000"; -const DEFAULT_FLOW_RATE = "1000000"; - -// Override gas limit on every write tx. Ethers v6's default eth_estimateGas -// buffer is too tight for CFA/GDA writes -- the actual gas usage can exceed -// the estimate due to SLOAD warming differences between simulation and -// execution, causing OOG reverts (e.g. updateFlow uses ~315k vs estimated -// ~285k). 1.5M gas covers the heaviest call (createPool) with margin. -const TX_OVERRIDES = { gasLimit: 1_500_000 }; - -// Stable receiver address used when SUPERFLUID_E2E_RECEIVER_KEY is not set. -// This is a deterministic dead-drop address; it will receive flows but nobody -// controls the key, so connect-pool cannot be signed for it. -const FALLBACK_RECEIVER = "0x000000000000000000000000000000000000dEaD"; - -const ERC20_ABI = [ - "function approve(address spender, uint256 amount) returns (bool)", - "function balanceOf(address account) view returns (uint256)", - "function decimals() view returns (uint8)", - "function allowance(address owner, address spender) view returns (uint256)", -]; - -const SUPER_TOKEN_ABI = [ - "function upgrade(uint256 amount)", - "function downgrade(uint256 amount)", - "function balanceOf(address account) view returns (uint256)", -]; - -const CFA_FORWARDER_ABI = [ - "function createFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", - "function updateFlow(address token, address sender, address receiver, int96 flowRate, bytes userData) returns (bool)", - "function deleteFlow(address token, address sender, address receiver, bytes userData) returns (bool)", - "function getFlowInfo(address token, address sender, address receiver) view returns (uint256 lastUpdated, int96 flowRate, uint256 deposit, uint256 owedDeposit)", - "function getAccountFlowrate(address token, address account) view returns (int96 flowRate)", -]; - -const GDA_FORWARDER_ABI = [ - "function createPool(address token, address admin, tuple(bool transferabilityForUnitsOwner, bool distributionFromAnyAddress) config) returns (bool success, address pool)", - "function updateMemberUnits(address pool, address member, uint128 units, bytes userData) returns (bool)", - "function getNetFlow(address token, address account) view returns (int96)", - "function connectPool(address pool, bytes userData) returns (bool)", - "function distributeFlow(address token, address from, address pool, int96 flowRate, bytes userData) returns (bool)", - "event PoolCreated(address indexed token, address indexed admin, address pool)", -]; - -type StepStatus = "PASS" | "SKIPPED" | "FAILED"; - -interface StepResult { - name: string; - status: StepStatus; - detail: string; -} - -const results: StepResult[] = []; - -function record(name: string, status: StepStatus, detail: string): void { - results.push({ name, status, detail }); - const prefix = status === "PASS" ? "OK" : status === "SKIPPED" ? "SKIPPED" : "FAILED"; - console.log(`[${prefix}] ${name}: ${detail}`); -} - -function requireEnv(name: string): string { - const val = process.env[name]; - if (!val) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return val; -} - -function getEnv(name: string, fallback: string): string { - return process.env[name] ?? fallback; -} - -async function sendAndWait( - label: string, - // biome-ignore lint/suspicious/noExplicitAny: ethers contract calls return ContractTransactionResponse which needs any - txPromise: Promise -): Promise { - // biome-ignore lint/suspicious/noExplicitAny: ethers v6 contract method return type - const tx: any = await txPromise; - console.log(` [${label}] tx ${tx.hash}`); - const receipt: ethers.TransactionReceipt | null = await tx.wait(); - if (!receipt || receipt.status !== 1) { - throw new Error(`Transaction reverted: ${tx.hash}`); - } - return receipt; -} - -async function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function main(): Promise { - // --- env setup --- - const senderKey = requireEnv("SUPERFLUID_E2E_SENDER_KEY"); - const receiverKey = process.env.SUPERFLUID_E2E_RECEIVER_KEY; - const rpcUrl = getEnv("SUPERFLUID_E2E_RPC_URL", DEFAULT_RPC); - const cfaAddress = getEnv("SUPERFLUID_E2E_CFA_FORWARDER", DEFAULT_CFA_FORWARDER); - const gdaAddress = getEnv("SUPERFLUID_E2E_GDA_FORWARDER", DEFAULT_GDA_FORWARDER); - const superTokenAddress = getEnv("SUPERFLUID_E2E_SUPER_TOKEN", DEFAULT_SUPER_TOKEN); - const underlyingAddress = getEnv("SUPERFLUID_E2E_UNDERLYING", DEFAULT_UNDERLYING); - const wrapAmount = BigInt(getEnv("SUPERFLUID_E2E_WRAP_AMOUNT", DEFAULT_WRAP_AMOUNT)); - const flowRate = BigInt(getEnv("SUPERFLUID_E2E_FLOW_RATE", DEFAULT_FLOW_RATE)); - - const provider = new JsonRpcProvider(rpcUrl, SEPOLIA_CHAIN_ID); - const senderWallet = new Wallet(senderKey, provider); - const senderAddress = senderWallet.address; - - let receiverAddress: string; - let receiverWallet: Wallet | null = null; - if (receiverKey) { - receiverWallet = new Wallet(receiverKey, provider); - receiverAddress = receiverWallet.address; - } else { - receiverAddress = FALLBACK_RECEIVER; - console.log(`SUPERFLUID_E2E_RECEIVER_KEY not set -- using fallback receiver ${FALLBACK_RECEIVER}`); - console.log("Steps requiring receiver signature will be SKIPPED."); - } - - console.log(`Sender: ${senderAddress}`); - console.log(`Receiver: ${receiverAddress}`); - console.log(`RPC: ${rpcUrl}`); - console.log(`Chain ID: ${SEPOLIA_CHAIN_ID}`); - console.log(""); - - const erc20 = new Contract(underlyingAddress, ERC20_ABI, senderWallet); - const superToken = new Contract(superTokenAddress, SUPER_TOKEN_ABI, senderWallet); - const cfa = new Contract(cfaAddress, CFA_FORWARDER_ABI, senderWallet); - const gda = new Contract(gdaAddress, GDA_FORWARDER_ABI, senderWallet); - - // --- step 1: pre-flight --- - console.log("[step 1 / pre-flight]"); - const ethBalance: bigint = await provider.getBalance(senderAddress); - const minEth = ethers.parseEther("0.01"); - if (ethBalance < minEth) { - console.error( - `Insufficient ETH for gas: ${ethers.formatEther(ethBalance)} ETH (need >= 0.01). Fund the sender wallet on Sepolia.` - ); - process.exit(1); - } - const underlyingBalance: bigint = await erc20.balanceOf(senderAddress); - if (underlyingBalance < wrapAmount) { - console.error( - `Insufficient fUSDC balance: ${underlyingBalance} wei (need >= ${wrapAmount}). Claim from https://app.superfluid.finance/faucet (Sepolia).` - ); - process.exit(1); - } - record( - "step 1 / pre-flight", - "PASS", - `ETH ${ethers.formatEther(ethBalance)} | fUSDC balance ${underlyingBalance} wei` - ); - - // --- idempotency: close any pre-existing flow before the lifecycle --- - console.log("\n[idempotency check]"); - const [, existingRate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (existingRate > BigInt(0)) { - console.log(` Pre-existing flow found (${existingRate} wei/s). Deleting before lifecycle.`); - await sendAndWait("pre-cleanup delete-flow", cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES)); - console.log(" Pre-existing flow deleted."); - } else { - console.log(" No pre-existing flow. Proceeding."); - } - - // --- step 2: approve underlying --- - console.log("\n[step 2 / approve]"); - const receipt2 = await sendAndWait( - "approve", - erc20.approve(superTokenAddress, wrapAmount, TX_OVERRIDES) - ); - record( - "step 2 / approve", - "PASS", - `tx ${receipt2.hash} | amount ${wrapAmount} wei` - ); - - // --- step 3: wrap --- - console.log("\n[step 3 / wrap]"); - const balBefore: bigint = await superToken.balanceOf(senderAddress); - const receipt3 = await sendAndWait("wrap", superToken.upgrade(wrapAmount, TX_OVERRIDES)); - const balAfter: bigint = await superToken.balanceOf(senderAddress); - if (balAfter <= balBefore) { - throw new Error(`Wrap did not increase SuperToken balance: before=${balBefore} after=${balAfter}`); - } - record( - "step 3 / wrap", - "PASS", - `tx ${receipt3.hash} | superToken balance before=${balBefore} after=${balAfter}` - ); - - // --- step 4: create-flow --- - console.log("\n[step 4 / create-flow]"); - const receipt4 = await sendAndWait( - "create-flow", - cfa.createFlow(superTokenAddress, senderAddress, receiverAddress, flowRate, "0x", TX_OVERRIDES) - ); - const [, flowRateAfterCreate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (flowRateAfterCreate !== flowRate) { - throw new Error(`Flow rate mismatch after create: expected ${flowRate}, got ${flowRateAfterCreate}`); - } - record( - "step 4 / create-flow", - "PASS", - `tx ${receipt4.hash} | flowRate ${flowRate} wei/s` - ); - - // --- step 5: update-flow --- - console.log("\n[step 5 / update-flow]"); - const newFlowRate = flowRate * BigInt(2); - const receipt5 = await sendAndWait( - "update-flow", - cfa.updateFlow(superTokenAddress, senderAddress, receiverAddress, newFlowRate, "0x", TX_OVERRIDES) - ); - const [, flowRateAfterUpdate]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (flowRateAfterUpdate !== newFlowRate) { - throw new Error(`Flow rate mismatch after update: expected ${newFlowRate}, got ${flowRateAfterUpdate}`); - } - record( - "step 5 / update-flow", - "PASS", - `tx ${receipt5.hash} | flowRate ${newFlowRate} wei/s` - ); - - // --- step 6: get-net-flow --- - console.log("\n[step 6 / get-net-flow]"); - const netFlow: bigint = await cfa.getAccountFlowrate(superTokenAddress, receiverAddress); - // netFlow may be negative for the sender side; for receiver it should be >= newFlowRate - // (could be higher if receiver has other inbound flows) - record( - "step 6 / get-net-flow", - "PASS", - `receiver net flow ${netFlow} wei/s` - ); - - // --- step 7: delete-flow --- - console.log("\n[step 7 / delete-flow]"); - const receipt7 = await sendAndWait( - "delete-flow", - cfa.deleteFlow(superTokenAddress, senderAddress, receiverAddress, "0x", TX_OVERRIDES) - ); - const [, flowRateAfterDelete]: [bigint, bigint, bigint, bigint] = await cfa.getFlowInfo( - superTokenAddress, - senderAddress, - receiverAddress - ); - if (flowRateAfterDelete !== BigInt(0)) { - throw new Error(`Flow still active after delete: rate=${flowRateAfterDelete}`); - } - record( - "step 7 / delete-flow", - "PASS", - `tx ${receipt7.hash} | flowRate now 0` - ); - - // --- step 8: create-pool --- - console.log("\n[step 8 / create-pool]"); - const receipt8 = await sendAndWait( - "create-pool", - gda.createPool(superTokenAddress, senderAddress, [false, false], TX_OVERRIDES) - ); - - // Parse PoolCreated event from receipt logs - const gdaInterface = new ethers.Interface(GDA_FORWARDER_ABI); - const poolCreatedTopic = gdaInterface.getEvent("PoolCreated")?.topicHash; - let poolAddress = ""; - for (const log of receipt8.logs) { - if (log.topics[0] === poolCreatedTopic) { - const parsed = gdaInterface.parseLog({ topics: [...log.topics], data: log.data }); - if (parsed?.args.pool) { - poolAddress = parsed.args.pool as string; - break; - } - } - } - if (!poolAddress) { - throw new Error("PoolCreated event not found in create-pool receipt"); - } - record( - "step 8 / create-pool", - "PASS", - `tx ${receipt8.hash} | pool ${poolAddress}` - ); - - // --- step 9: update-member-units --- - console.log("\n[step 9 / update-member-units]"); - const receipt9 = await sendAndWait( - "update-member-units", - gda.updateMemberUnits(poolAddress, receiverAddress, BigInt(100), "0x", TX_OVERRIDES) - ); - record( - "step 9 / update-member-units", - "PASS", - `tx ${receipt9.hash} | member ${receiverAddress} | units 100` - ); - - // --- step 10: connect-pool (receiver must sign) --- - console.log("\n[step 10 / connect-pool]"); - if (receiverWallet !== null) { - const gdaAsReceiver = new Contract(gdaAddress, GDA_FORWARDER_ABI, receiverWallet); - const receipt10 = await sendAndWait( - "connect-pool", - gdaAsReceiver.connectPool(poolAddress, "0x", TX_OVERRIDES) - ); - record( - "step 10 / connect-pool", - "PASS", - `tx ${receipt10.hash} | receiver ${receiverAddress} connected` - ); - } else { - record( - "step 10 / connect-pool", - "SKIPPED", - "SUPERFLUID_E2E_RECEIVER_KEY not set; receiver cannot sign connect-pool" - ); - } - - // --- step 11: distribute-flow --- - console.log("\n[step 11 / distribute-flow]"); - const receipt11 = await sendAndWait( - "distribute-flow", - gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, flowRate, "0x", TX_OVERRIDES) - ); - record( - "step 11 / distribute-flow", - "PASS", - `tx ${receipt11.hash} | flowRate ${flowRate} wei/s` - ); - - // --- step 12: read net flow twice, 5 seconds apart --- - // CFA's getAccountFlowrate aggregates only CFA flows. To observe the - // receiver's incoming pool stream we have to query the GDA forwarder's - // getNetFlow, which combines CFA and GDA flows. - console.log("\n[step 12 / read-net-flow x2]"); - const netFlowBefore: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); - console.log(` net flow (t=0): ${netFlowBefore} wei/s`); - await sleep(5000); - const netFlowAfter: bigint = await gda.getNetFlow(superTokenAddress, receiverAddress); - console.log(` net flow (t=5s): ${netFlowAfter} wei/s`); - - if (receiverWallet !== null) { - if (netFlowAfter <= BigInt(0)) { - throw new Error(`Expected receiver net flow to be > 0 after connect-pool + distribute-flow, got ${netFlowAfter}`); - } - record( - "step 12 / read-net-flow", - "PASS", - `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s` - ); - } else { - record( - "step 12 / read-net-flow", - "PASS", - `t=0: ${netFlowBefore} wei/s | t=5s: ${netFlowAfter} wei/s (receiver not connected; flow accumulation not verified)` - ); - } - - // --- step 13: cleanup --- - console.log("\n[step 13 / cleanup]"); - const receipt13 = await sendAndWait( - "cleanup distribute-flow", - gda.distributeFlow(superTokenAddress, senderAddress, poolAddress, BigInt(0), "0x", TX_OVERRIDES) - ); - record( - "step 13 / cleanup", - "PASS", - `tx ${receipt13.hash} | pool stream closed` - ); - - // --- summary --- - console.log("\n--- SUMMARY ---"); - console.log( - `${"Step".padEnd(40)} ${"Status".padEnd(10)} Detail` - ); - console.log("-".repeat(100)); - for (const r of results) { - console.log(`${r.name.padEnd(40)} ${r.status.padEnd(10)} ${r.detail}`); - } - - const failed = results.filter((r) => r.status === "FAILED"); - const skipped = results.filter((r) => r.status === "SKIPPED"); - console.log( - `\n${results.length} steps total | ${results.length - failed.length - skipped.length} PASS | ${skipped.length} SKIPPED | ${failed.length} FAILED` - ); - - if (failed.length > 0) { - process.exit(1); - } -} - -main().catch((error: unknown) => { - const failed = results.filter((r) => r.status === "FAILED"); - const msg = error instanceof Error ? error.message : String(error); - if (failed.length === 0) { - // Unrecorded failure - console.error(`\nFATAL: ${msg}`); - } - process.exit(1); -}); diff --git a/tests/scripts/fund-test-wallet.ts b/tests/scripts/fund-test-wallet.ts deleted file mode 100644 index 89468ce15..000000000 --- a/tests/scripts/fund-test-wallet.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Fund a Sepolia test wallet for live Superfluid demos. - * - * Sends Sepolia ETH to a target address and (optionally) mints fUSDC via - * the permissive Sepolia mint() function on the fake-USDC contract. Used - * to bootstrap the keeperhub-managed signer wallet before running write - * workflow fixtures (create-pool, wrap, etc.) against a PR deploy. - * - * Usage: - * FUNDER_PK=0xabc... \ - * TARGET=0x42d92e...63ef \ - * ETH_AMOUNT=0.005 \ - * FUSDC_AMOUNT=5 \ - * pnpm tsx tests/scripts/fund-test-wallet.ts - * - * Required env: - * FUNDER_PK hex private key, 0x-prefixed (or unprefixed; both are - * accepted). Source it from your secrets manager or a - * gitignored .envrc -- never check it in. - * TARGET recipient address (the workflow wallet -- get it via - * GET /api/user/wallet on the deploy) - * - * Optional env: - * ETH_AMOUNT SepETH to send, in ether units (default "0.005") - * Pass "0" to skip the ETH transfer. - * FUSDC_AMOUNT fUSDC to mint, in token units (default "0" = skip) - * RPC_URL Sepolia RPC (default PUBLIC_RPCS.SEPOLIA from - * lib/rpc/rpc-config.ts -- the single source of truth) - * FUSDC_ADDRESS override fUSDC address (default 0xe72f...20Db) - * - * The fUSDC underlying on Sepolia ships with a permissive - * mint(address, uint256) -- per the e2e script's documented quirk -- so - * any caller can drop tokens into any address. Don't assume real USDC - * behaves like this. - */ - -import { ethers } from "ethers"; -import { PUBLIC_RPCS } from "@/lib/rpc/rpc-config"; - -const HEX_KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/; - -const FUNDER_PK = normalizePk(requireEnv("FUNDER_PK")); -const TARGET = requireEnv("TARGET"); -const ETH_AMOUNT = process.env.ETH_AMOUNT ?? "0.005"; -const FUSDC_AMOUNT = process.env.FUSDC_AMOUNT ?? "0"; -const RPC_URL = process.env.RPC_URL ?? PUBLIC_RPCS.SEPOLIA; -const FUSDC_ADDRESS = - process.env.FUSDC_ADDRESS ?? "0xe72f289584eDA2bE69Cfe487f4638F09bAc920Db"; - -function requireEnv(name: string): string { - const v = process.env[name]; - if (!v) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return v; -} - -function normalizePk(raw: string): string { - const trimmed = raw.trim(); - if (!HEX_KEY_RE.test(trimmed)) { - console.error( - "FUNDER_PK must be a 64-character hex string (with or without 0x prefix)" - ); - process.exit(1); - } - return trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`; -} - -const FUSDC_MINT_ABI = [ - "function mint(address account, uint256 amount)", - "function decimals() view returns (uint8)", - "function balanceOf(address) view returns (uint256)", -]; - -async function main(): Promise { - if (!ethers.isAddress(TARGET)) { - throw new Error(`Invalid TARGET address: ${TARGET}`); - } - - const provider = new ethers.JsonRpcProvider(RPC_URL); - const funder = new ethers.Wallet(FUNDER_PK, provider); - console.log(`Funder: ${funder.address}`); - console.log(`Target: ${TARGET}`); - - const funderEth = await provider.getBalance(funder.address); - console.log(`Funder SepETH balance: ${ethers.formatEther(funderEth)}`); - - const ethWei = ethers.parseEther(ETH_AMOUNT); - if (ethWei > 0n) { - console.log(`\nSending ${ETH_AMOUNT} SepETH...`); - const tx = await funder.sendTransaction({ to: TARGET, value: ethWei }); - console.log(` tx: ${tx.hash}`); - const receipt = await tx.wait(); - console.log(` confirmed in block ${receipt?.blockNumber}`); - } else { - console.log("\nSkipping SepETH transfer (ETH_AMOUNT=0)"); - } - - const fusdcAmount = Number.parseFloat(FUSDC_AMOUNT); - if (fusdcAmount > 0) { - const fUSDC = new ethers.Contract(FUSDC_ADDRESS, FUSDC_MINT_ABI, funder); - const decimals = (await fUSDC.decimals()) as bigint; - const amountWei = ethers.parseUnits(FUSDC_AMOUNT, decimals); - console.log( - `\nMinting ${FUSDC_AMOUNT} fUSDC (${decimals} decimals = ${amountWei} wei)...` - ); - const tx = await fUSDC.mint(TARGET, amountWei); - console.log(` tx: ${tx.hash}`); - const receipt = await tx.wait(); - console.log(` confirmed in block ${receipt?.blockNumber}`); - const bal = (await fUSDC.balanceOf(TARGET)) as bigint; - console.log( - ` target fUSDC balance now: ${ethers.formatUnits(bal, decimals)}` - ); - } else { - console.log("\nSkipping fUSDC mint (FUSDC_AMOUNT=0)"); - } - - const targetEth = await provider.getBalance(TARGET); - console.log( - `\nDone. Target SepETH balance: ${ethers.formatEther(targetEth)}` - ); -} - -main().catch((e: unknown) => { - console.error(e); - process.exit(1); -}); diff --git a/tests/scripts/run-fixture.ts b/tests/scripts/run-fixture.ts deleted file mode 100644 index 315089412..000000000 --- a/tests/scripts/run-fixture.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Live workflow fixture runner. - * - * Loads a workflow fixture JSON, POSTs to a target deploy's - * /api/workflows/create, triggers /api/workflow/{id}/execute, polls until - * the run terminates, prints the per-step trace and final output. - * - * Usage: - * FIXTURE=tests/integration/fixtures/superfluid-workflows/get-net-flow.json \ - * ORIGIN=https://app-pr-1114.keeperhub.com \ - * COOKIE='CF_Authorization=...; __Secure-better-auth.session_token=...' \ - * pnpm tsx tests/scripts/run-fixture.ts - * - * Required env: - * FIXTURE path to a workflow JSON (relative to repo root or absolute) - * ORIGIN full deploy origin (https://app-pr-N.keeperhub.com) - * COOKIE cookie header value carrying CF Access JWT and the better-auth - * session. Easiest to copy from a logged-in browser DevTools - * "Copy as cURL" against any /api/ request. - * - * Optional env: - * POLL_INTERVAL_MS default 4000 - * TIMEOUT_MS default 300000 (5 min) - * INTEGRATION_ID if set, replaces every node's data.config.integrationId - * before POST. Use when re-running a fixture against a - * different deploy/user (the captured ID is per-org). - * - * Why cookies and not just an API key: PR hosts sit behind Cloudflare - * Access. The kh CLI's API-key path bypasses better-auth but NOT CF - * Access, so /api/* returns the SSO HTML. Browser cookies carry both - * tokens. For long-running automation, use a CF Access service token - * instead and switch this script to header-based auth. - */ - -import fs from "node:fs"; -import path from "node:path"; - -type ExecResponse = { executionId: string; status: string }; - -type Execution = { - id: string; - status: "running" | "success" | "error" | string; - output: Record | null; - error: string | null; - duration: string | null; - completedSteps: string | null; -}; - -type StepTrace = { - id: string; - nodeId: string; - nodeName: string; - nodeType: string; - status: "success" | "error" | "running" | string; - durationMs: number | null; - error: string | null; -}; - -type WorkflowNodeDoc = { - id: string; - type: string; - data: { config?: Record }; -}; - -type WorkflowDoc = { - name: string; - description?: string; - nodes: WorkflowNodeDoc[]; - edges: unknown[]; -}; - -const FIXTURE = requireEnv("FIXTURE"); -const ORIGIN = requireEnv("ORIGIN"); -const COOKIE = requireEnv("COOKIE"); -const POLL_INTERVAL_MS = Number.parseInt( - process.env.POLL_INTERVAL_MS ?? "4000", - 10 -); -const TIMEOUT_MS = Number.parseInt(process.env.TIMEOUT_MS ?? "300000", 10); -const INTEGRATION_ID_OVERRIDE = process.env.INTEGRATION_ID; - -function requireEnv(name: string): string { - const v = process.env[name]; - if (!v) { - console.error(`Missing required env var: ${name}`); - process.exit(1); - } - return v; -} - -function loadFixture(p: string): WorkflowDoc { - const abs = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); - const raw = fs.readFileSync(abs, "utf-8"); - const doc = JSON.parse(raw) as WorkflowDoc; - if (INTEGRATION_ID_OVERRIDE) { - for (const node of doc.nodes) { - if (node.data?.config && "integrationId" in node.data.config) { - node.data.config.integrationId = INTEGRATION_ID_OVERRIDE; - } - } - } - return doc; -} - -function api(p: string, init: RequestInit = {}): Promise { - const headers = new Headers(init.headers); - headers.set("cookie", COOKIE); - headers.set("origin", ORIGIN); - if (init.body && !headers.has("content-type")) { - headers.set("content-type", "application/json"); - } - return fetch(`${ORIGIN}${p}`, { ...init, headers }); -} - -async function readJson(res: Response): Promise { - const text = await res.text(); - if (!res.ok) { - throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 500)}`); - } - try { - return JSON.parse(text) as T; - } catch { - throw new Error( - `Non-JSON response (likely Cloudflare Access HTML). First 500 chars: ${text.slice( - 0, - 500 - )}` - ); - } -} - -async function createWorkflow(doc: WorkflowDoc): Promise { - const res = await api("/api/workflows/create", { - method: "POST", - body: JSON.stringify(doc), - }); - const created = await readJson<{ id: string; name: string }>(res); - return created.id; -} - -async function executeWorkflow(workflowId: string): Promise { - const res = await api(`/api/workflow/${workflowId}/execute`, { - method: "POST", - body: "{}", - }); - const exec = await readJson(res); - return exec.executionId; -} - -async function pollExecution(workflowId: string): Promise { - const start = Date.now(); - while (Date.now() - start < TIMEOUT_MS) { - const res = await api(`/api/workflows/${workflowId}/executions`); - const list = await readJson(res); - const latest = list[0]; - if (!latest) { - throw new Error("No execution found for workflow"); - } - if (latest.status !== "running") { - return latest; - } - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - } - throw new Error(`Timed out after ${TIMEOUT_MS}ms`); -} - -async function fetchSteps(executionId: string): Promise { - const res = await api(`/api/analytics/runs/${executionId}/steps`); - return readJson(res); -} - -async function main(): Promise { - const doc = loadFixture(FIXTURE); - console.log(`Fixture: ${FIXTURE}`); - console.log(`Workflow: ${doc.name}`); - - const workflowId = await createWorkflow(doc); - console.log(`Created workflow: ${workflowId}`); - - const executionId = await executeWorkflow(workflowId); - console.log(`Execution started: ${executionId}`); - - const final = await pollExecution(workflowId); - console.log( - `\nFinal status: ${final.status} (duration ${final.duration ?? "?"}ms, ` + - `${final.completedSteps ?? "?"} steps completed)` - ); - - const steps = await fetchSteps(executionId); - console.log("\nPer-step trace:"); - for (const s of steps) { - console.log( - ` [${s.status.padEnd(7)}] ${s.nodeName} (${s.nodeType})` + - ` - ${s.durationMs ?? "?"}ms` + - (s.error ? `\n error: ${s.error}` : "") - ); - } - - if (final.output) { - console.log("\nOutput:"); - console.log(JSON.stringify(final.output, null, 2)); - } - - if (final.error) { - console.log(`\nError: ${final.error}`); - process.exit(2); - } - if (final.status !== "success") { - process.exit(2); - } -} - -main().catch((e: unknown) => { - console.error(e); - process.exit(1); -}); diff --git a/tests/scripts/verify-superfluid-addresses.ts b/tests/scripts/verify-superfluid-addresses.ts deleted file mode 100644 index a425775f0..000000000 --- a/tests/scripts/verify-superfluid-addresses.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Superfluid forwarder address verification. - * - * One-shot CLI: confirms CFAv1Forwarder and GDAv1Forwarder are deployed - * (have non-empty bytecode) at their pinned addresses on every chain - * Superfluid is shipped on. Run before opening the PR; paste the table - * output into the PR description. - * - * Interpreting failures: a row with a network error (HTTP 401/429/5xx, - * DNS, timeout) means the public RPC is unreachable -- retry, or update - * PUBLIC_RPCS in lib/rpc/rpc-config.ts (the single source of truth for - * chain RPC URLs). A row showing FAIL with no error message means the - * address actually has empty bytecode on that chain (a real deployment - * miss to investigate). - * - * Usage: pnpm tsx tests/scripts/verify-superfluid-addresses.ts - */ - -import { JsonRpcProvider } from "ethers"; -import { PUBLIC_RPCS } from "@/lib/rpc/rpc-config"; -import { - CFA_FORWARDER_ADDRESS, - GDA_FORWARDER_ADDRESS, - SUPERFLUID_CHAIN_IDS, -} from "@/protocols/superfluid"; - -type ForwarderName = "CFAv1Forwarder" | "GDAv1Forwarder"; - -const FORWARDERS: Record = { - CFAv1Forwarder: CFA_FORWARDER_ADDRESS, - GDAv1Forwarder: GDA_FORWARDER_ADDRESS, -}; - -// Chain ID -> { display name, public RPC URL }. RPCs come from -// lib/rpc/rpc-config.ts so updating the URL in one place benefits every -// caller (seed-chains.ts, the runtime resolver, and this script). Display -// names are local because the lib intentionally only carries URLs. -const CHAIN_META: Record = { - "1": { name: "Ethereum Mainnet", rpc: PUBLIC_RPCS.ETH_MAINNET }, - "10": { name: "Optimism", rpc: PUBLIC_RPCS.OPTIMISM_MAINNET }, - "137": { name: "Polygon", rpc: PUBLIC_RPCS.POLYGON_MAINNET }, - "8453": { name: "Base", rpc: PUBLIC_RPCS.BASE_MAINNET }, - "42161": { name: "Arbitrum One", rpc: PUBLIC_RPCS.ARBITRUM_MAINNET }, - "11155111": { name: "Sepolia", rpc: PUBLIC_RPCS.SEPOLIA }, -}; - -const CHAINS: { id: number; name: string; rpc: string }[] = - SUPERFLUID_CHAIN_IDS.map((id) => { - const meta = CHAIN_META[id]; - return { - id: Number(id), - name: meta?.name ?? `Unknown chain ${id}`, - rpc: meta?.rpc ?? "", - }; - }); - -type CheckResult = { - chainName: string; - chainId: number; - forwarder: ForwarderName; - address: string; - deployed: boolean; - error?: string; -}; - -async function checkOne( - chain: { id: number; name: string; rpc: string }, - forwarder: ForwarderName -): Promise { - const address = FORWARDERS[forwarder]; - try { - const provider = new JsonRpcProvider(chain.rpc, chain.id); - const code = await provider.getCode(address); - return { - chainName: chain.name, - chainId: chain.id, - forwarder, - address, - deployed: code !== "0x", - }; - } catch (error) { - return { - chainName: chain.name, - chainId: chain.id, - forwarder, - address, - deployed: false, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -function printMarkdownTable(results: CheckResult[]): void { - console.log(""); - console.log("| Chain | Chain ID | Contract | Address | Status |"); - console.log("|---|---|---|---|---|"); - for (const r of results) { - const status = r.deployed ? "OK" : `FAIL${r.error ? ` (${r.error})` : ""}`; - console.log( - `| ${r.chainName} | ${r.chainId} | ${r.forwarder} | \`${r.address}\` | ${status} |` - ); - } - console.log(""); -} - -async function main(): Promise { - console.error("Verifying Superfluid forwarder deployments..."); - - const tasks: Promise[] = []; - for (const chain of CHAINS) { - for (const fwd of Object.keys(FORWARDERS) as ForwarderName[]) { - tasks.push(checkOne(chain, fwd)); - } - } - - const results = await Promise.all(tasks); - printMarkdownTable(results); - - const failed = results.filter((r) => !r.deployed); - if (failed.length > 0) { - console.error( - `FAILED: ${failed.length} of ${results.length} checks did not find deployed bytecode.` - ); - process.exit(1); - } - - console.error(`All ${results.length} forwarder deployments verified.`); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); From eca3856de0ac7bd664f73308db1c5897bccde877 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Wed, 6 May 2026 16:52:02 +1000 Subject: [PATCH 18/19] fix(executor): resolve {{@}} templates referencing run-code output (KEEP-442) HTTP Request `endpoint` (and `httpHeaders`/`httpBody`) string fields already pass through the workflow template substitution layer like every other config string -- but `{{@prep:Prep.url}}` was resolving to the empty string when `prep` was a `code/run-code` returning `{ url: "..." }`. The bug shows up at HTTP request validation as `URL is required`. Root cause: `resolveFromOutputData` only unwraps the HTTP-style `{ data: ... }` wrapper. `runCodeStep` returns `{ success, result, logs }`, so when a downstream template references `Prep.url` (no explicit `result.` prefix) the resolver finds neither `data.url` nor `data.data.url` and falls back to the "missing path" branch, which substitutes "". Fix: extend `resolveFromOutputData` with a `.result` fallback that mirrors the existing `.data` fallback. Backward-compatible (`Prep.result.url` still resolves directly via the top-level path) and also fixes any other string field that references a code/run-code output's inner field (protocol-action arg fields, downstream `code` strings, etc.). Unblocks the dynamic Bridge Route Optimizer / MEV-Aware Swap Quote workflows on the catalog roadmap. - Add `hasNestedResultShape` helper alongside `hasNestedDataShape` - Walk `.data` then `.result` so HTTP responses still take precedence when both wrappers are present - Export `processTemplates` and `resolveFromOutputData` so the new unit test can drive the executor's substitution layer directly - Add `tests/unit/http-request-template-substitution.test.ts` covering the bug repro plus header/body templating and ordering precedence Verified end-to-end on local dev: trigger -> prep (code/run-code returning {url}) -> across (HTTP GET with endpoint={{@prep:Prep.url}}) returned a real Across API response. --- lib/workflow/executor/executor.workflow.ts | 39 ++++- ...http-request-template-substitution.test.ts | 148 ++++++++++++++++++ 2 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 tests/unit/http-request-template-substitution.test.ts diff --git a/lib/workflow/executor/executor.workflow.ts b/lib/workflow/executor/executor.workflow.ts index ce57c28f8..3f3ca82d3 100644 --- a/lib/workflow/executor/executor.workflow.ts +++ b/lib/workflow/executor/executor.workflow.ts @@ -551,16 +551,49 @@ function hasNestedDataShape( ); } +// KEEP-442: code/run-code wraps the user's return value in `.result` (the +// step output is `{ success, result, logs }`). Without this fallback, a +// downstream string field referencing `{{@prep:Prep.url}}` (where `prep` +// is a code/run-code that returned `{ url }`) resolves to "" because +// `data.url` is undefined and the existing `.data` fallback only matches +// the HTTP-style wrapper shape. +function hasNestedResultShape( + data: unknown +): data is Record & { result: object } { + return ( + typeof data === "object" && + data !== null && + "result" in data && + typeof (data as Record).result === "object" && + (data as Record).result !== null + ); +} + /** - * Resolve from output.data, or from output.data.data when step wraps body in .data (e.g. HTTP). + * Resolve a field path from output data, transparently unwrapping common + * step-result wrappers when the path doesn't match at the top level: + * - `{ data: ... }` (HTTP-style result) + * - `{ result: ... }` (code/run-code wrapper -- KEEP-442) */ -function resolveFromOutputData(data: unknown, fieldPath: string): unknown { +export function resolveFromOutputData( + data: unknown, + fieldPath: string +): unknown { const fromTop = fieldPath ? resolveConfigFieldPath(data, fieldPath) : data; if (fromTop !== undefined && fromTop !== null) { return fromTop; } if (hasNestedDataShape(data)) { const inner = data.data; + const fromInner = fieldPath + ? resolveConfigFieldPath(inner, fieldPath) + : inner; + if (fromInner !== undefined && fromInner !== null) { + return fromInner; + } + } + if (hasNestedResultShape(data)) { + const inner = data.result; return fieldPath ? resolveConfigFieldPath(inner, fieldPath) : inner; } return; @@ -631,7 +664,7 @@ function replaceConfigTemplate( * Process template variables in config. * Recurses into nested objects; supports array paths like data.recipes[0]. */ -function processTemplates( +export function processTemplates( config: Record, outputs: NodeOutputs ): Record { diff --git a/tests/unit/http-request-template-substitution.test.ts b/tests/unit/http-request-template-substitution.test.ts new file mode 100644 index 000000000..682f5aab2 --- /dev/null +++ b/tests/unit/http-request-template-substitution.test.ts @@ -0,0 +1,148 @@ +/** + * KEEP-442: HTTP Request system action's `endpoint`, `httpHeaders`, and + * `httpBody` string fields go through the workflow template substitution + * layer (`processTemplates`) like every other config string. The bug was + * that templates resolved to "" when referencing a `code/run-code` + * upstream because the resolver did not unwrap the run-code wrapper + * shape `{ success, result, logs }` -- only the HTTP-style `{ data: ... }` + * wrapper. Adding a `.result` fallback to `resolveFromOutputData` makes + * `{{@runCodeNode:Label.fieldInsideResult}}` resolve correctly and is + * what unblocks the dynamic Bridge Route Optimizer / MEV-Aware Swap + * Quote workflows in the catalog roadmap. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { + processTemplates, + resolveFromOutputData, +} from "@/lib/workflow/executor/executor.workflow"; + +type NodeOutputs = Record; + +// Shape that runCodeStep returns when user code does `return { url, ... }`. +const RUN_CODE_OUTPUT = { + success: true, + result: { + url: "https://app.across.to/api/suggested-fees?inputToken=0xA0b&outputToken=0x833&originChainId=1&destinationChainId=8453&amount=1000000", + headers: { "X-Trace": "abc" }, + body: { hello: "world" }, + }, + logs: [], +}; + +const HTTP_STEP_OUTPUT = { + success: true, + data: { + items: [{ name: "First" }, { name: "Second" }], + nextCursor: "cur_42", + }, + status: 200, +}; + +describe("KEEP-442: HTTP Request template substitution from run-code outputs", () => { + describe("resolveFromOutputData", () => { + it("resolves a top-level field directly", () => { + const data = { url: "https://example.com" }; + expect(resolveFromOutputData(data, "url")).toBe("https://example.com"); + }); + + it("unwraps the HTTP-style { data: ... } wrapper for downstream lookups", () => { + expect(resolveFromOutputData(HTTP_STEP_OUTPUT, "items[0].name")).toBe( + "First" + ); + expect(resolveFromOutputData(HTTP_STEP_OUTPUT, "nextCursor")).toBe( + "cur_42" + ); + }); + + it("unwraps the run-code-style { result: ... } wrapper -- the KEEP-442 fix", () => { + expect(resolveFromOutputData(RUN_CODE_OUTPUT, "url")).toBe( + RUN_CODE_OUTPUT.result.url + ); + expect(resolveFromOutputData(RUN_CODE_OUTPUT, "headers.X-Trace")).toBe( + "abc" + ); + expect(resolveFromOutputData(RUN_CODE_OUTPUT, "body.hello")).toBe( + "world" + ); + }); + + it("still honours an explicit `result.` prefix (back-compat)", () => { + expect(resolveFromOutputData(RUN_CODE_OUTPUT, "result.url")).toBe( + RUN_CODE_OUTPUT.result.url + ); + }); + + it("returns undefined when the field path is not found in any wrapper", () => { + expect(resolveFromOutputData(RUN_CODE_OUTPUT, "missing.deep")).toBe( + undefined + ); + }); + + it("prefers top-level over the .result fallback when both have the same key", () => { + const data = { url: "TOP", result: { url: "INNER" } }; + expect(resolveFromOutputData(data, "url")).toBe("TOP"); + }); + + it("prefers .data over .result when both wrappers exist", () => { + const data = { + success: true, + data: { url: "FROM_DATA" }, + result: { url: "FROM_RESULT" }, + }; + expect(resolveFromOutputData(data, "url")).toBe("FROM_DATA"); + }); + }); + + describe("processTemplates -- HTTP Request fields with run-code upstream", () => { + const outputs: NodeOutputs = { + prep: { label: "Prep", data: RUN_CODE_OUTPUT }, + }; + + it("resolves {{@prep:Prep.url}} in the endpoint field (KEEP-442 repro)", () => { + const config = { + actionType: "HTTP Request", + httpMethod: "GET", + endpoint: "{{@prep:Prep.url}}", + httpHeaders: "{}", + httpBody: "{}", + }; + const processed = processTemplates(config, outputs); + expect(processed.endpoint).toBe(RUN_CODE_OUTPUT.result.url); + }); + + it("resolves templates embedded in httpHeaders and httpBody JSON strings", () => { + const config = { + actionType: "HTTP Request", + httpMethod: "POST", + endpoint: "https://api.example.com/x", + httpHeaders: '{"X-Trace": "{{@prep:Prep.headers.X-Trace}}"}', + httpBody: '{"greeting": "{{@prep:Prep.body.hello}}"}', + }; + const processed = processTemplates(config, outputs); + expect(processed.httpHeaders).toBe('{"X-Trace": "abc"}'); + expect(processed.httpBody).toBe('{"greeting": "world"}'); + }); + + it("resolves templates referencing an HTTP upstream (existing flow stays intact)", () => { + const httpOutputs: NodeOutputs = { + api: { label: "API", data: HTTP_STEP_OUTPUT }, + }; + const config = { + endpoint: "https://x.test/page?cursor={{@api:API.nextCursor}}", + }; + const processed = processTemplates(config, httpOutputs); + expect(processed.endpoint).toBe("https://x.test/page?cursor=cur_42"); + }); + + it("falls back to '' when the referenced node is not in outputs", () => { + const config = { + endpoint: "{{@missing:Whatever.url}}", + }; + const processed = processTemplates(config, outputs); + expect(processed.endpoint).toBe(""); + }); + }); +}); From ab8c342ab959a44e937fe156404b8a57b134e45f Mon Sep 17 00:00:00 2001 From: Simon KP Date: Wed, 6 May 2026 17:04:50 +1000 Subject: [PATCH 19/19] test(executor): cover wrapper edge cases in template resolver (KEEP-442) Address review feedback on PR #1147: - Tighten `hasNestedDataShape` to also reject `data === null`, matching `hasNestedResultShape` -- removes the asymmetry the reviewer flagged and keeps the type guard honest (the runtime was already null-safe via resolveConfigFieldPath, but the predicate now matches behavior). - Pin the intentional `.data` -> `.result` fall-through with an explicit test, so future readers know the behavior on outputs that carry both wrappers is by design. - Document the existing limitation: a primitive `.result` (e.g. a code/run-code that returned a bare string) is not unwrapped because the fallback can only walk into objects. Whole-output references still resolve via top-level. - Cover null-wrapper guards for both `.data` and `.result`. Tests: 11 -> 15 cases, all pass. No code path changes beyond the hasNestedDataShape predicate; existing 3696 unit tests still pass. --- lib/workflow/executor/executor.workflow.ts | 3 +- ...http-request-template-substitution.test.ts | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/workflow/executor/executor.workflow.ts b/lib/workflow/executor/executor.workflow.ts index 3f3ca82d3..62ad46f8c 100644 --- a/lib/workflow/executor/executor.workflow.ts +++ b/lib/workflow/executor/executor.workflow.ts @@ -547,7 +547,8 @@ function hasNestedDataShape( typeof data === "object" && data !== null && "data" in data && - typeof (data as Record).data === "object" + typeof (data as Record).data === "object" && + (data as Record).data !== null ); } diff --git a/tests/unit/http-request-template-substitution.test.ts b/tests/unit/http-request-template-substitution.test.ts index 682f5aab2..ea5ad94cf 100644 --- a/tests/unit/http-request-template-substitution.test.ts +++ b/tests/unit/http-request-template-substitution.test.ts @@ -94,6 +94,44 @@ describe("KEEP-442: HTTP Request template substitution from run-code outputs", ( }; expect(resolveFromOutputData(data, "url")).toBe("FROM_DATA"); }); + + // Documents the intentional fall-through behavior when both wrappers exist + // but the path is missing inside .data: continue searching .result rather + // than stopping. Useful for hypothetical step outputs that carry both + // wrappers; safe today because no shipping step does. + it("falls through from .data to .result when the path is absent inside .data", () => { + const data = { + success: true, + data: { other: "x" }, + result: { url: "FROM_RESULT" }, + }; + expect(resolveFromOutputData(data, "url")).toBe("FROM_RESULT"); + }); + + it("ignores .data when the wrapper value is null (still tries .result)", () => { + const data = { + success: true, + data: null, + result: { url: "FROM_RESULT" }, + }; + expect(resolveFromOutputData(data, "url")).toBe("FROM_RESULT"); + }); + + it("ignores a null .result wrapper (does not throw)", () => { + const data = { success: true, result: null }; + expect(resolveFromOutputData(data, "url")).toBe(undefined); + }); + + // Pre-existing behavior we are pinning, not changing: a primitive + // result (e.g. `return "https://x"` from code/run-code) is rejected by + // hasNestedResultShape because the fallback can only walk into objects. + // The whole-output reference (no field path) returns the wrapper + // because top-level lookup succeeds. + it("does not unwrap a primitive .result; whole-output ref still works", () => { + const data = { success: true, result: "https://primitive", logs: [] }; + expect(resolveFromOutputData(data, "anything")).toBe(undefined); + expect(resolveFromOutputData(data, "")).toEqual(data); + }); }); describe("processTemplates -- HTTP Request fields with run-code upstream", () => {

VRkt>LMyG{(DUX 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 Date: Wed, 6 May 2026 14:55:05 +1000 Subject: [PATCH 10/19] fix(agentic-wallet): allow data-chain workflow listings to accept payment (KEEP-432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priced workflow listings whose chain identifies a data chain (Ethereum, Arbitrum, Polygon, BNB, Avalanche, 0G, Plasma) used to 403 with CHAIN_MISMATCH on every paid invocation because the binding required wf.chain to normalise to "base" or "tempo". The chain field on a listing is overloaded — for Base-data workflows it doubles as the payment-chain pin, but for Ethereum-data workflows it identifies where the contracts live, not which chain payment must arrive on. Replace normaliseChainTag (BindingChain | null) with classifyChainTag returning {kind: "payment" | "data" | "unrecognised"}. Data-chain listings whitelist Ethereum, Arbitrum, Avalanche, BNB, Polygon, 0G, and Plasma (mainnet ids only, mirroring lib/rpc/rpc-config.ts) and accept either Base x402 or Tempo MPP payment. Payment-chain pinning, the fix-pack-3 N-1 cross-chain-proof defence, and the unrecognised-tag defensive reject are all preserved. Server-derived payTo and amount equality on the Base path still fire on data-chain listings — verified by new tests. Tempo daily-spend deduction still binds to the correct workflow price. KNOWN_DATA_CHAIN_IDS is exported so the test suite iterates the production set directly, eliminating the manual-sync drift the previous in-test array required. Test coverage adds 11 new cases: - 4 data-chain happy paths (Base + Tempo for Ethereum + parameterised loop) - 2 security-equality assertions (PAYTO_MISMATCH, AMOUNT_MISMATCH) - 3 data-chain edge cases (non-integer amount, case-insensitive payTo, WORKFLOW_NOT_PAYABLE) - 5 strictness assertions (whitespace, leading-zero, hex, float, testnet-id rejection) - 3 symmetric tempo-pin negative tests 35/35 tests pass. Lint and type-check clean. --- lib/agentic-wallet/workflow-binding.ts | 112 ++++++--- .../agentic-wallet-workflow-binding.test.ts | 213 +++++++++++++++++- 2 files changed, 293 insertions(+), 32 deletions(-) diff --git a/lib/agentic-wallet/workflow-binding.ts b/lib/agentic-wallet/workflow-binding.ts index ebc9903ed..3b6f082a3 100644 --- a/lib/agentic-wallet/workflow-binding.ts +++ b/lib/agentic-wallet/workflow-binding.ts @@ -33,6 +33,33 @@ * mismatch (defensive) rather than null (permissive) so we never silently * widen access on an unrecognised tag. * + * Fix-pack-5 (KEEP-432): the chain field on a listing is overloaded — for + * Base-data workflows the data chain and the payment chain happen to be the + * same ("base"/"8453"), so the registered chain doubles as the payment-chain + * pin. For workflows whose data chain is *not* a payment chain (Ethereum, + * Optimism, Polygon, Arbitrum), the listing's chain identifies WHERE THE + * CONTRACTS LIVE, not which chain payment must arrive on. Such listings now + * accept either Base x402 or Tempo MPP. The defensive-mismatch behaviour for + * unknown / unparseable tags is preserved — only the explicitly whitelisted + * data-chain ids in `KNOWN_DATA_CHAIN_IDS` widen the payment side. + * + * Security: even on data-chain listings the binding still server-derives + * payTo from the registry on the Base path, and the Tempo path still resolves + * the workflow's price for the daily-spend deduction. The fix-pack-3 N-1 + * concern (dual-chain victim, attacker chooses weaker chain) is unchanged for + * Base- and Tempo-pinned listings. For data-chain listings there is no + * payment-chain preference inherent to the workflow, so the pin doesn't apply. + * + * Note on creator degree-of-freedom: workflows.chain is a free-form text + * column written by the listing API (lib/mcp/listing.ts) without an input + * allowlist. A creator can intentionally tag a Base-only workflow with + * chain="1" to widen acceptance to either payment chain. This isn't a + * security boundary violation — payTo is still server-derived from the + * org's wallet so payers are paying the legit creator regardless — but it + * does mean which payment chains a listing accepts is author-controlled, + * not platform-enforced. Tighten at the listing API if that ever becomes + * a policy concern. + * * Lookup chain mirrors lib/x402/payment-gate.ts:resolveCreatorWallet. */ import { and, eq } from "drizzle-orm"; @@ -75,36 +102,65 @@ const TEMPO_MAINNET_CHAIN_ID_STR = String(TEMPO_MAINNET_CHAIN_ID); const TEMPO_TESTNET_CHAIN_ID_STR = String(TEMPO_TESTNET_CHAIN_ID); /** - * Map any chain tag we accept on a workflow listing — slug ("base", - * "tempo") or numeric chain id ("8453", "4217", "4218") — into the - * canonical BindingChain form used by /sign callers. Returns null for - * unrecognised values so the caller can decide whether null means - * "permissive legacy" (when wf.chain itself is null) or "defensive - * mismatch" (when wf.chain is set but unparseable). + * Whitelisted data-chain ids — chains that KeeperHub workflows can READ from + * but that are NOT payment chains. Listings with one of these chains accept + * payment via either Base x402 or Tempo MPP. Mirrors the mainnet set declared + * in lib/rpc/rpc-config.ts; extend by editing this set when adding support + * for a new read-only chain. + * + * Decimal string form to match the format stored in workflows.chain. + */ +export const KNOWN_DATA_CHAIN_IDS = new Set([ + "1", // Ethereum mainnet + "42161", // Arbitrum One + "43114", // Avalanche C-Chain + "56", // BNB Chain + "137", // Polygon + "16661", // 0G Mainnet (Aristotle) + "9745", // Plasma Mainnet +]); + +type ChainClassification = + | { readonly kind: "payment"; readonly chain: BindingChain } + | { readonly kind: "data" } + | { readonly kind: "unrecognised" }; + +/** + * Classify a workflow.chain tag into one of three buckets: + * - "payment": a recognised payment-chain slug or chain id; the listing is + * pinned to that payment chain and the caller must match. + * - "data": a recognised data-chain id (Ethereum, OP, Polygon, Arbitrum); the + * listing's chain identifies where the contracts live, not which chain + * payment must arrive on. Either Base or Tempo payment is accepted. + * - "unrecognised": a non-empty value we can't parse. Treated as defensive + * mismatch by the binding so a typo or future tag never silently widens + * access. * - * Case-insensitive on slug input; whitespace-trimmed. Numeric forms - * are matched as decimal strings against the canonical constants in - * lib/agentic-wallet/constants.ts so a future chain-id rename only - * has to be done in one place. + * Case-insensitive on slug input; whitespace-trimmed. Numeric forms match + * the canonical constants in lib/agentic-wallet/constants.ts so a chain-id + * rename only happens in one place. */ -function normaliseChainTag( +function classifyChainTag( value: string | null | undefined -): BindingChain | null { +): ChainClassification { if (typeof value !== "string") { - return null; + return { kind: "unrecognised" }; } const v = value.trim().toLowerCase(); if (v === "base" || v === BASE_CHAIN_ID_STR) { - return "base"; + return { kind: "payment", chain: "base" }; } if ( v === "tempo" || v === TEMPO_MAINNET_CHAIN_ID_STR || v === TEMPO_TESTNET_CHAIN_ID_STR ) { - return "tempo"; + return { kind: "payment", chain: "tempo" }; + } + if (KNOWN_DATA_CHAIN_IDS.has(v)) { + return { kind: "data" }; } - return null; + return { kind: "unrecognised" }; } function priceToMicro( @@ -156,17 +212,21 @@ export async function verifyWorkflowBinding( }; } - // Fix-pack-3 N-1 + Fix-pack-4 (KEEP-391): reject requests whose - // caller-supplied chain does not match the workflow's registered chain, - // comparing on a normalised tag so listings stored with a numeric chain - // id ("8453", "4217", "4218") compare equal to the slug forms ("base", - // "tempo"). Null is permissive for legacy listings that pre-date the - // workflows.chain column. Unknown / unparseable values are treated as a - // mismatch (defensive) rather than null (permissive) so we never silently - // widen access on an unrecognised tag. + // Fix-pack-3 N-1 + Fix-pack-4 (KEEP-391) + Fix-pack-5 (KEEP-432): classify + // the workflow's chain tag into payment / data / unrecognised. Payment- + // chain listings stay pinned to that chain (the original cross-chain-proof + // defence). Data-chain listings (Ethereum, Arbitrum, Polygon, BNB, Avalanche, 0G, Plasma) only + // describe where the workflow READS contracts from — they have no inherent + // payment-chain preference, so either Base x402 or Tempo MPP is accepted. + // Unrecognised tags stay defensive (mismatch) so a typo never widens access. + // A null wf.chain remains permissive for legacy listings that pre-date the + // workflows.chain column. if (wf.chain) { - const wfNorm = normaliseChainTag(wf.chain); - if (wfNorm === null || wfNorm !== chain) { + const wfClass = classifyChainTag(wf.chain); + const matched = + wfClass.kind === "data" || + (wfClass.kind === "payment" && wfClass.chain === chain); + if (!matched) { return { ok: false, status: 403, diff --git a/tests/unit/agentic-wallet-workflow-binding.test.ts b/tests/unit/agentic-wallet-workflow-binding.test.ts index a690f6a3d..94e0436ce 100644 --- a/tests/unit/agentic-wallet-workflow-binding.test.ts +++ b/tests/unit/agentic-wallet-workflow-binding.test.ts @@ -59,7 +59,7 @@ vi.mock("@/lib/db/schema", () => ({ organizationWallets: { _table: "para_wallets" }, })); -const { verifyWorkflowBinding } = await import( +const { verifyWorkflowBinding, KNOWN_DATA_CHAIN_IDS } = await import( "@/lib/agentic-wallet/workflow-binding" ); @@ -291,10 +291,10 @@ describe("verifyWorkflowBinding", () => { }); it("rejects an unrecognised wf.chain tag (defensive — no silent widening)", async () => { - // wf.chain is a non-null string we cannot normalise. Treat as - // mismatch rather than falling through to permissive null branch, - // so a typo or future chain stored as "ethereum" can never pass - // a stolen-HMAC attacker's request through. + // wf.chain is a non-null string we cannot classify (slug form, not in + // the data-chain whitelist, not a payment chain). Treat as mismatch + // rather than falling through to permissive null branch, so a typo or + // future chain stored as "ethereum" / "9999" can never pass through. queueWorkflow({ chain: "ethereum" }); const rBase = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); expect(rBase).toMatchObject({ @@ -303,7 +303,7 @@ describe("verifyWorkflowBinding", () => { code: "CHAIN_MISMATCH", }); - queueWorkflow({ chain: "1" }); + queueWorkflow({ chain: "9999" }); const rTempo = await verifyWorkflowBinding(SLUG, "tempo", "", "0"); expect(rTempo).toMatchObject({ ok: false, @@ -312,4 +312,205 @@ describe("verifyWorkflowBinding", () => { }); }); }); + + // KEEP-432 (Fix-pack-5): listings whose chain identifies a data chain + // (Ethereum, Optimism, Polygon, Arbitrum) have no inherent payment-chain + // preference. Either Base x402 or Tempo MPP must be accepted, otherwise + // priced cross-chain-data workflows are unreachable from the wallet. + describe("data-chain listings (KEEP-432)", () => { + it("accepts Base payment for an Ethereum-data listing (chain=1)", async () => { + queueWorkflow({ chain: "1" }); + queueWallet({}); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r.ok).toBe(true); + }); + + it("accepts Tempo payment for an Ethereum-data listing (chain=1)", async () => { + queueWorkflow({ chain: "1" }); + queueWallet({}); + const r = await verifyWorkflowBinding(SLUG, "tempo", "", "0"); + expect(r.ok).toBe(true); + }); + + it("accepts either payment chain for every whitelisted data-chain listing", async () => { + // Sourced directly from production to eliminate manual-sync drift — + // adding a chain id to KNOWN_DATA_CHAIN_IDS now extends test coverage + // automatically. Skip "1" because it's covered by the explicit + // Ethereum tests above. + for (const dataChain of KNOWN_DATA_CHAIN_IDS) { + if (dataChain === "1") { + continue; + } + + queueWorkflow({ chain: dataChain }); + queueWallet({}); + const rBase = await verifyWorkflowBinding( + SLUG, + "base", + CREATOR, + "50000" + ); + expect(rBase.ok).toBe(true); + + queueWorkflow({ chain: dataChain }); + queueWallet({}); + const rTempo = await verifyWorkflowBinding(SLUG, "tempo", "", "0"); + expect(rTempo.ok).toBe(true); + } + }); + + it("still enforces payTo equality on the Base path for a data-chain listing", async () => { + queueWorkflow({ chain: "1" }); + queueWallet({}); + const r = await verifyWorkflowBinding(SLUG, "base", ATTACKER, "50000"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "PAYTO_MISMATCH", + }); + }); + + it("still enforces amount equality on the Base path for a data-chain listing", async () => { + queueWorkflow({ chain: "1" }); + queueWallet({}); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "100000"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "AMOUNT_MISMATCH", + }); + }); + + it("rejects non-integer amount on Base for a data-chain listing", async () => { + queueWorkflow({ chain: "1" }); + queueWallet({}); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "abc"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "AMOUNT_MISMATCH", + }); + }); + + it("compares payTo case-insensitively on a data-chain listing", async () => { + queueWorkflow({ chain: "1" }); + queueWallet({ walletAddress: CREATOR.toUpperCase() }); + const r = await verifyWorkflowBinding( + SLUG, + "base", + CREATOR.toLowerCase(), + "50000" + ); + expect(r.ok).toBe(true); + }); + + it("returns 403 WORKFLOW_NOT_PAYABLE for a data-chain listing without an active wallet", async () => { + queueWorkflow({ chain: "1" }); + queueWallet(null); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "WORKFLOW_NOT_PAYABLE", + }); + }); + }); + + // KEEP-432 negative-set integrity: lock in the strict-decimal classifier + // semantics so a future producer change (hex serialisation, leading-zero + // normalisation, etc.) can't silently break existing listings or widen + // acceptance to a chain that was meant to reject. + describe("classifier strictness (KEEP-432 negative set)", () => { + it("rejects whitespace-only wf.chain as unrecognised", async () => { + // " " is truthy so reaches classifyChainTag, then trims to "" which + // doesn't match any known tag. + queueWorkflow({ chain: " " }); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "CHAIN_MISMATCH", + }); + }); + + it("rejects leading-zero numeric ids ('08453', '01')", async () => { + queueWorkflow({ chain: "08453" }); + const r1 = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r1).toMatchObject({ ok: false, code: "CHAIN_MISMATCH" }); + + queueWorkflow({ chain: "01" }); + const r2 = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r2).toMatchObject({ ok: false, code: "CHAIN_MISMATCH" }); + }); + + it("rejects 0x-prefixed hex chain ids", async () => { + queueWorkflow({ chain: "0x1" }); + const r1 = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r1).toMatchObject({ ok: false, code: "CHAIN_MISMATCH" }); + + queueWorkflow({ chain: "0x2105" }); // 8453 in hex + const r2 = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r2).toMatchObject({ ok: false, code: "CHAIN_MISMATCH" }); + }); + + it("rejects float / decimal chain forms ('8453.0')", async () => { + queueWorkflow({ chain: "8453.0" }); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r).toMatchObject({ ok: false, code: "CHAIN_MISMATCH" }); + }); + + it("rejects testnet ids that aren't on the mainnet whitelist", async () => { + // Mainnet-only by intent. If KeeperHub starts supporting testnet + // listings, extend KNOWN_DATA_CHAIN_IDS and update this test. + const testnetIds = [ + "11155111", // Sepolia + "421614", // Arbitrum Sepolia + "80002", // Polygon Amoy + "43113", // Avalanche Fuji + "9746", // Plasma testnet + "16602", // 0G Galileo testnet + ]; + for (const t of testnetIds) { + queueWorkflow({ chain: t }); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r).toMatchObject({ ok: false, code: "CHAIN_MISMATCH" }); + } + }); + }); + + // KEEP-432 symmetric cross-chain-proof tests: the original Fix-pack-3 N-1 + // defence is bidirectional. The KEEP-391 test suite covers Base-pinned + + // tempo-caller; these cover the inverse so a future code change that + // flips the equality direction is caught. + describe("payment-chain pin is symmetric (KEEP-432)", () => { + it("rejects tempo-pinned listing with base caller", async () => { + queueWorkflow({ chain: "tempo" }); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "CHAIN_MISMATCH", + }); + }); + + it("rejects Tempo-mainnet-id listing with base caller", async () => { + queueWorkflow({ chain: "4217" }); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "CHAIN_MISMATCH", + }); + }); + + it("rejects Tempo-testnet-id listing with base caller", async () => { + queueWorkflow({ chain: "4218" }); + const r = await verifyWorkflowBinding(SLUG, "base", CREATOR, "50000"); + expect(r).toMatchObject({ + ok: false, + status: 403, + code: "CHAIN_MISMATCH", + }); + }); + }); }); From a71fc5eab500a4464c3d2014c68a9b6eb92254b0 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 6 May 2026 14:58:44 +1000 Subject: [PATCH 11/19] chore(superfluid): KEEP-415 swap protocol icon for cleaner mark Replaces the textured GitHub-avatar version with the Superfluid square logo from EthGlobal's CDN -- dark-navy background with the white Superfluid wordmark. Stylistically closer to the other protocol icons (clean mark on solid background) than the prior pattern-fill version. Source: https://ethglobal.b-cdn.net/organizations/x59d1/square-logo/default.png Dimensions: 400x400 RGB. --- public/protocols/superfluid.png | Bin 46814 -> 4739 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/protocols/superfluid.png b/public/protocols/superfluid.png index c79e42f63f928accdc918b5e556581a00b53da27..9b5295e8b7d67abeb15326b490e9810cd97c3006 100644 GIT binary patch literal 4739 zcmeHLeN>WJ9!4{R%n6)H)|?a_%kl$vY|J)KVk%AMRIZ|t!#+as+h6=V~IysF_ z3QT_F*oUryO1g}hi_YLUwU~0Uz}5^aLqi4gE5v|zFDQHV-~DG#v!_4a_dVxw@5Ar@ zp5MoF@7G}mKet}HaV-o6vkuu891eq-l^7nlIT%_0w59@l5UKlO(_t{H4-JnQti1Af zFqn-`NbsJ>%u3}`@wd#FvlnJMFR-zn`W2p1VHmO7TRbi%x(vHnVzvzLxu#Fr93E<& zx7YF?#8t8GR9E4!^NxgSJ77JMWweGk5X`1#i2_EvaOQH~(`U6mO!Eh4`%w}vgD5EEZxX`-7_KyrJ2+u`{e*dH3hVy|~5GB;k@v_S+HYSrD@e z2fktYzPfQ6>DZKmKXodOd1kbKEjf8)9>BZ~4cl=qt$ltx7RzlMng9mq%i?$RD->5C z+3#sKCl3(xmU-DnNCH}`u6nhiOTUhieAG8ZsUgZAj!u_d;Q6;b^O_>gX8iLQ>*GzE z-j1k#$zO=g=Vy#}ilfFvvZ}XoT+2X|&V^V!2t45*bH!b(PAGH7#4~x)Tz>jY3w=%A z{qKRl^zolLam7c~lO1h2P2MTMu~=N(z@c~o zdRU)EoIF1tf&Pt7E$>gzFAYME1v3jJx=~+x`Ov=h)dpWP_-;7;Vua>fTCGa9NImh1 zeCxm=1jU;0P-8_-b}~B6L+H$XQ5J{MmGR(3JUHc#C!K}mj?&Zm7p~83!&lsbiaxbW z6$1}IxB8;Ued{T^=rg6MRt75St-wmoP7|Jo@Nij0A#bd}r>bi)dW=q~sw?Z&5rYP-0 zlvK;BokQagZ6yZ2?-*8IH1cV{bLQgPzlKw2V9Q0-j5w2H@PwY_7y@`GQy?g`J%@yz z#_+-Z58c8^JAGQ)!%j$UWMiRjX*6B^>tKYvDa1L{TX)6i1R9?&apgunDd72gaVNy* zw8-do)WBK5u_O%68HWG419-zapC`Ih@?+?KKX{1^2&`Gg1kT^MmdvzwN`60RbTWd2 zBkNRZ=#7D-I%u|eO3HAfEQkaK^F#`$#2 z01||9UsO-fhi8_1MKoP_dD#V^%s82gwuv7E8h|Prf?|^x4E__Nm!y2;Xi#5QU)ZCIN*O3MKVfp_4liBt>eX$k_!t8S8#WzMG&1-*S}NM!G{nU3y=RbZBXp`eIXO@%ne>%B z!RCyEuiTNo%fm1RloSwZ51Xrm9YkK7^)VZ5o9_-!@wL=+QqiALD z|ImTMX+sxgL+P5i?yfTW8^w=|g^+|xhk`V|)mK9a)%#yRzkZiyFM=xM zX`0s$MrYUiSFw3t=4Ps8tLTt7%6WsHYs%9$pIcb!V{!&XF`orJ%J*^Y z1G~`f&eTjBB-@i3CoU_CJU#%#8qj8o>RpvjDl4-yJE0@f{W>c@wg~N=B*|ZBzo#Pp zJv&GJ2FNhLhl4|B4$xPSqixU_P{nW6T^}#2VxXFVhSS+6!8s%)IQrG=k;M0>!uRLn tclf3NW4t0M`Z@BQy3f#eu794Ab?fAw)n+cG;8qD1fb|HVg3NC?7c73mz^h;(yG7#=fd{ke2tx_dF&`@X{T|khpmnh@ad<5<&R<( zTO}n8`cDzDnn87_-dbYozavY_Emw0BpO$NREvwABLIVP(t1~?M$McmY64b;~XaXKiFHX7$3VUgM z+1L&1*r>f0zTE1u%+}iL37>WoleIieYN_S7_+A&t(LmmdZ3~SQwW`hbt#*Y?bOd8f z1QUqsw|I48Gv?*S3fpZ}5w+g8qY>m;i=U_L8jSep-qAj<2jtN!C2CuqBf@BWn5f+l zAe}|16G_{kwX2Z(j{01Sua^#kC9Us4r4BjlB@OJcn^-N6ta$}v$~XV`T&l@+)wjl; zmqN!f3fEhMzd`hFCwIy#^YOsCzB7&6mh92<;j~xpFxR0XU!ntJ!F%gG^Z8*qSxeyV zf_%hVR_E&ZA@iAs8hSCNGh35XwTsqj()rLHgf``+n5=<0QqUYO1ZUdv3$0RG-gc`= z_uNFSyI|Z5w4Ic>1dDAJz8CR2Qf5qd*E|)=P?|3ikbu3!hpCvH(Ij7XP%SW}F2mGj z9}bq=+I&di;jM|S&l?G9Py6K7X{S9mcVCxo?`GPbuKw+gBrYHmIu{X5XlIW8gV8G* zW1sKGWBS5TuI#B*-s#tn@6YEno3K1>{^d&52YvBPP5fgN{7xRF;IxsuMXe zA|hOg1l*gk)^T4ZIz?3jbGogyui2|S6txClD$8#&E_IoiQms#fEj6#l-=u#N3g%if zH(=pQK96QdKS{~IzTr65Fmcfsv(+kQjQM+UHVUdgMYMUhW!{*ODWVYJ34ciB>5ve!$#$6seMeD;dQ9-&sG# zvExF+7x$I+AdGSi6XWGZx9yzmt&foTdgpR+0(^~jdnYHSvF)PXM0P{jFap|)PhN9+ zcH6{iq9FKstOzMIL)dw)#+Cs=3)zS(4)`6w;kD>7lXE9_vl7f>G3>>h1m8%WxN6Sq zh#eV@6#DxWvZT5X3_0^=XpnfiS=ZWs8kHi@vCOq-T=2g?j540DwpP2m^#jACjTY)0 z=W{&E463a(eDP^S^=red5npjF@TnlGFlrG`?GbeUp1XHwC&$lE$Fymm?(g0PD#*zI zbjyv!o+Jho=y#&)KKACoSYBEh1w3BpfnG$`*B+6pGzd9@tX40Fp z?cBC?)21LfI%+zmdCuG(Pif3fmH{ePW&qh;G?=}`73Cl2BV~K|wYFjemKsd6?jLLw zlW`sM55k2a@q12hC!wUlz>vEf*2*7CrRz;bgtmnMhsB&pk+^6c1~(*Tzb!2R{tG1brXN#L`lsM3#;`mSln-*?J2 z8BQOK+GhvW5#OcrVzFuH^`aj8(BJur=Gl5@lglUM?^N7k zIALrXf6~G;EJ(K)@ccOC`eT!3&@Zv3XsN3bOZ4$#f--4ArE{A(wT09}mMJNpQi&&{ z%k5Tqg5NGa4S7_&#)rl2L3x!V_ozStM{b$(aEGg=3ua4o(#wJXN34;&;leam#V^Og z3O5dcCIrHHwRMC3JdEH&N;t@c5y7j}t z$m2p72Ye?7=OFiXJN?*io732}pZ@uopTLZ348kNJAvzh1Pf{5b&w4a-eK4!Vw~{jA zXEjyi&O|*c)BhT_H9O+(`71NCeBc7WIyK-uXItpOL7D9kh-Kq~!YJ&D{J-cF*6^`u z*fe29t)Xr)y~NCMXW}p6y199}I-F;Jkjl0S} zKJUPuEbG84@&_Cb3@YK*>c1rt8;)1>^d$vFMmvDqx_nT~wvR?B)S?Qqvl@9EJYWvk zs$_BDjfm%gjp(Hl9V{%)Tz$OWw5!;Z3BequL${g3gQx4B@BVH{mnm#^VjdVR99rt$ zG@&ax)$5X-G)XJf|96jd2Wyk-ZT4~L1+cvIO8mB8tu=0>A1hb3pP6OH-3wE7Ox^SjsoNJL z=DSCipY=L$%wHC<^1Q-4D6JkY7XBMFrNa~@+331CfJ1C$fAvN5`=$qNHrPsKU^?up zLF$=+O0;yN zk*6S$)p#Wi$V<$X?K#ruHAq%SzQ^}iln)RaJe*k!s#2E7zVVQJra{j3EC&iHH19TC zK_;Pe1t{}OK3N-)uukx(%x^}tF#5#n$|aF=(Yf`v#)guozwggB2Ya`H!p$dCC#Xpr z$-yX(A(DA)nD|hHvb2bIL72DEH+rUzji#$c!fl`zOwgIvuh3}GLX7i#D1+6#qB)>L zd1Dx4?i9)?;Xqzk@A;_nUA+Gm6sj^8j#V%GGMjc~KamX4F(lPd*J&zey`zPfB#!wI z)3Mqzp(=8?{k`>UfK|dBx!z<53`%1E)FoO|5Ngu^UPtnxN(FZM+;Bx@p_j4%z;f6u zhxVdjp{95hY3_t0DVs3ZUG|HE%A6#!^78UrvQ{fGV$*4}%bU=Het0pvDP9jjqu_7h`9nnGLhx~fa)*c(@?I;evTpgbN*A2fywG4G*Bp@t0sOCa;yu@GjPUa5ZN z;cv!Fzq9GX`T90k$3`iTKy_gz*8!jbK#rh0R zO%}2}?kOzX8zs4&frmx{@@m885XH*fWU(X#gZ~6#HEcZoaWitt34|?Uy6wesYaD|? z23s!^5UTwa9Ee zDmU<({U7h_n)ExZXMK1N(>lKl~#k?A2UiF{azzsrg$`^c3>q{XFY;w|2=K`eZU z7JR^OeW{Vk?`Uq}Q+3bufyf)Cmg!D0xsi}zXXy-ol}%+uV)Y6(oB<%9}=MUt^dg7w2Lq`*;yrv(S4V(r}u5C}a~>fHXVH{o!g^V)J>ix#Ap z^6N9j7VM=s%#oUzasfgO{$*u2w>Y^>qpR^8-uQI!P_U#@FA1;Zzl=Zw?Y$E`gNKEw zcu4i%D)*a4Dn%c!ko$!R2fZB0IZ}ZOz9h497>Ht}8r-@pOD^=;3^ca5X#bN;<`EZB zY+I|6NwQGrMdfj_(jn({IOoCU46}+Nfkj}M1p!MTU6sbx8IY~7GrPR(!pqDak;4R- z!lo@}9SumGuouYqOrtv%NWM3~-8?pX$7TmEe*E{~EbJQmSx0#`lr&s6QG+6pFW zzFBRAq~m$7`l=*rVfm}yjO&1H`PY0+@vmTuHDD|^Oi)9`w+xJP?ed))4)|K5GRI0# z>!@lSlTh7-q3y?P@BrEQxNEwn27hqK$OgRcL)L>IM*F^lS$mqKKKGm{$`b52669=x z?beF5wS$(-i%~l!PCchjzIhn6{Za!w7DU#Ngjbk+PHEIXzy0}#5IePb+vvcdj^5&q z)?**tckR}74I1y2pd7Wc_hUK&*F~+jTd8CXQpgcBcWQgCfr3`tuc6rM=wsTw-~Rag z^prR9JckQTjzg~SoGy?{H{|8N1>@`EWs_O~IN|vta#D5d*uDtPq{sh|t<2T;c<^M1SV%az zDTc0o!FBNKb4j%$IOY$x*SvAH)e!39Onz&OuYkV8e4S&^ykMuj2V1-T4I-YbWk=>e zD(#8}{maJ2MhveDwlB@0+z;2$Jj2PmqcUa%=Q!O7-lZ(l^CJ3IudN()DIJ?DDt-+n zahCh{mb|^*Ey@t9?5rvOuLM+$k*Mc2r(F(MV=fB|aBP{-UmP`Vr3K9l2~O#WBaByO zdgy-Ksq=eyc)F51E<(1DgIdo_N;Qs8zTWSnn(4(VF$wcpRDZ_><6C?l=H=^lzFsOL zNzRs@RGnnWKMN&h1DLqw`391e<|ya2d%?!Xe=98>d?sZsmgXz?hn-FF8A|Kc zr(BxXw)mDx>YR9zz&3ytfN03RNbI}uiZZ8YCIFgr?pMvKi!YEa?y~Pn?3mG^zAvmF zQ802n_lgQVuRcp-?{GzbD3%oHhWYqD{*{U5`;g82y(1Qd7c(72X|W0Caym1ZHafg) z$@tdOqbPsq;_W7oY*px+v2%4s*7jPuXtGv@_$BOEH`l#SC@N4W*!TKqaXnaz%gYz- zN}0tGdik0lKjp;TeWKsS-@o+|odTnod6`vxAbzYfL)4Awm#d(b7PmWa+;8rK-o`pTG0J-eLw6>zR~Bwo7kUDikg`NnTLnV*2^5;b() z;_LLKH{O@Bw}8o+zJ}vGVwCPaX3RZ8NnlxIMXNB`7`Bh<6;b&z-s~oprS;&Ho-J}Q z3$V9g@(MTj^*S9yCMvZ_Wu7Rj_nWZhL znI(fMAvGzY&qhp!kEi+7dhTL7Atcb-EZy zkS7gSbYU+b3kD7LRF{Smzk7AU+lkQ;uS3MK2RY~W*d8Xf55_orDSvGJX>Y? zngfsNDw0WU8HnwU`j~a344nMAJz9SHrZlQsoYy_cXCkreXbcBl=e9jO(5|V+AjTze zIjQB_C|9_GuWT@>w+wYL!@G!y%aSN!y$d_qSwUB&`Np}6ivMObf<|~(hrXV=yxMvX z6<=w4=Uf#{J#nvn?RRdm0Zek5ethteC$t;NR02?|o6W5r-+5J&8^viUe8W6X&&NaS zxJyCT(Qcgu6!~tpkpla^+W8_&*@#OHgopl$c#n+(Xnx!~A1S~ZpE@4);5okrm4Gf` zkhR={8-v0=XT~Q7Dv8lQLH!iPWcf=3YXLiyJk4)OiZRLY zPTse~R1RpH)FhMmEvVBP1O^s1IOM9h|Go#4OeBXHKzqrlR=#?~_(IDI(8YV?PofP} zTQ3W-w1T5GV?a22++#p&O!S_VszP??A24ZNgQLY&hq})RhAeEq|E`8TgXWiChL2n? z$z3w{q|jh)dW}V*C7%b%`NB_wcl!4olJvNfY?X9nA9f|&$3}c7HqD$aW@avZNR4=h z-$;Nh4do2GNn6ImK4pa3e&x{g+5^@PC_DH_6i}QG7~P(%cF{d1dw@1xQmoZRdUeKI z!zd#7m=8Dc&eS6S3gyD3SsZJBND=CeHVo=D4aT+jJ?V*O%6q0bDU*xkhPcGdZc`$$ z>`~hY83_YB2>pnP_={S;zql&U&bR4qw}$qk{C zY!pqk<`=3Mtz#H`*?=jT-S@SOTa$kTHEG;I>At;uau%jgIBL803N%wl(>300$Ae9F zToi*Ap1iMh4B+U`dqO9}i%zC&A*BxA87w%S*jC{^tf8gLl4x1&!i;TPxI!?HSup={ zA{&)jA|o3lbjy$o;HUnr+9~4&UN>@}^&X?e{KH3Wu2pBa2zC$J5^Fu?tAN`)MKL6K zi=&0!0u6E613RC}`@@7nx(IPP6#s-}Y4iTz&H)NxW?83u=;oesDTMkq`pH)LYj`s# zQH$TlCpG7NnSJqLxDLYPQXD}|{`r(8hTnWhqd=!bSEbo1>S_O0Oad)Ub;Ozu+SKud z6k!{j-B=kbD$*;11#H18G>b)HACiZq`$kaAcdWFWbmCHiygp{91Ai}=WID=4%*XjC zg@bl|2$f&%WFJ+=`s8gjWJ&pjjR??h$zOi^it@2d@#&5pak+!m1r$%ieYElBgvVQ` z3YCiHoUV$j$UsL#j+-M4J-YsOaF}?Edkzd(C?@8-sTPde(Uzn~YQGv&&coSDte z3!S=4cigPM;zk*T5y=yttym2B(ai@W@^7rn_+pcDO--qO()EU1lV?>zRo0gNK34ig zO)EGHybL^OfupGrCw0^W{Nl)wP$sGIA~m{LAw+awNKx!FLz5iGFj9q;Mabf;<2=98 zFGtZ-mxb~H;4K#6)kUuh%zweQD>)2c)Cf$ccjn7}QhT-JC zn^M$sJ>>G|4ivnDs+aA`d|OMHHI3JaTj|!`e0f!UN`)Qy_>SpcFJ``jUcpL^Y!N_CqCs;r zi6=j=_TrDSoFgGZcLS{D%~d+PL0bk}M8ev?tktsay<5jBJ#*Nqd=AHch>IV?-ahi- z6lo4TcJ(!`(?La3xxDaM^KQT#ai@;AhPC zZl_lqKh`5nR~_o6HF9MSV)GsuYWe(4f& zd&wD9bO3Edf6#42%!`mr*(wG($La=S(wo^1h8{SPEdS9x3%-g}>fnOLeZy*mG{T?XYP6m%YD3 z=QHBpRxaRJZ0!#f-H0@8_92}v(K9j@j2h_WhpD$(-d_{K))T|tet{fC>=Awx;%3ok z6~K~;G!Bq5kN?;?rM2w%E=zORyFHFtnW?dM>tK}qkpwHGaDBOwPP z!Zky@%nUK!>U^fq>avDbEyBkbV!H5XM@CGa9NU-d~F@z7)mLT;WGa- zHB&1xr8pda`xTet_khKI$Wfq*$-fypa#V~?Qwy3gndB-u1-FUDg+>ks;#ywwHJ>?F z*(F9&FJ_zkX-+@^phJ^YnDfbq2f=KgXz4zzdyK_Y0sw!`Kr~FXug|yIPC__K2YOsm z%04Zhmi5btN?)+Nprbow&>w3wO3(rFnvZ@b4{J}gq$|G!p$b<5S&A)yn_qSG$hNdO zrCKBbxWtZqtYf`}G<4zO>UEPdrbivL0bYEllW`s2Y4$2Gw&J(XCVF4Ur*%nxGad|< z7eP$@^`KvQi5fb1PCe(g@TFI${FG+Ic$}ZGOHAhd@F=8D3|BGoGDexbI$SRrw*5B8 zsr$%a2VDzN+poVK8Dj1Iy+kV{mKs!KKA-hQeB{l?Y%%GJPqLthNUR^#IEU^hO(y&Y zQ_oH1PJ>#`e&~;dw82nj4j3zzzyGD6g1TUZbmjW%H03^K!t|^#$7*z>MNv)>$mH(6 z8S8$;=`$9^B04wx`(mOehLWtoYyb25%Q$I9zp|k`lr4C$udx+gsAVsh0Qo{QhZNop z9jea;s(f1vxI%Z4tYrT;a@v*fu|&&oS8@huiuL>Mc_+S%}LWj zV3@hY1a1{)yy22zuTCBcxO$astQ4tjD4U|ao|-tnis`oS&Ujm1@gYo}Sot}|rntw$DzDa~c% zcA$N851;n0yJV-b-C}LTE!7?PoeTymBcYrAxN*hC?F8307WLrnl(z{T-PX^U)dH2& z;-Pf^QBS;qf)SDOeXGAA@KpAoiy655-q~KdTY|myueeA9RHB&VrpR~Y27n-f)KeZCmp z=!3rn5&zokPz~D5S^J4e?DcDAR2yoUW7ok@J0$KSD6SqkFu1L&IjLjOgpw_976QD`~j- zv-ID{pJk28nsIxU`5hmK+~!K<46(^{32FR(a>;F9R796>qE&2UvC&(6`IFSNUofXN zp2Z)W=Zb&?Xtt{oZ$U;4quWNk$(<+iq8fWYFEMnjrSE9Um(g*kz?m@oo)+@mc}@DN zXP5w`tp6RwK?ExhEgIlqja7zvJ%IVjnbf8_o-i}k+wb-Ky|kIkr@sq*BG&QV0c!ZX zda^UldYSL+2PxHG2?OX?vGOxhQ%i*Toj~s65o^<54>c3y-tgo;oP{z>^fdgbSkF!7 zh~gIzI#O5ttdsL!el%|lGrXCCwu9%_g0UNdoCKCm>B-f9uk75VFs#`#WKn~cAVYRe zqA#>X6JBX#2X7hyS+`d%_SXY%8xyRRs>B4$XcDSNhmlbp8;**AH(M*I#phpKUQ-;i zI6fJ@E$UAGn3|ATRQZXjw`7ZSwrtdt%wg-RKblbh+$CPWZr8=W=r&puq>;Qb82dm4&TY2PDQn{8qWlwN&D@qMi3vmm8W()?dS67eQ2S+(o#Vz*a(Osc< zn|;gU&73lb?D*!29A`PXn4GY+8iLwwYO;A}bOh8Ie(mEX;Pk}*3-#bkVUnLe4cu#e z)g5pO7V-&?Rfzv(7fdrz$n!hx!+Zk;Pm0f9Dn@~OUW~n?zrzOT2)#0}5I#Z>_ zJu1n}^gsPvrSP7#?^#USy)k(Ng6+aPoM-ht2lAKL>hI8Fq_bO@8bncPgfFQdm`5Xb zTT74c)h8G6fGsaOD5QkCSJWcgTkczt9;M!gZ!4?Owp$rM{8F@U(jfEbN#l zAi9Gq2e%|8%|PWD`@E{W*A9=nO@wlZ-T689oJj{pebh?tg3xe=SCWJwFUx5ggMxR@rYKF3(+K-t6xemsC(q^?u#%BjZ9kCW5|>gDcU#E8?AdL+moe_wD2! zFF25g{wTUn;$dx}E`qkXR;VYNOrm0=+usc9Q zDHGLK8r{)(S0yU&s81Ugs$4G7PYZ%Ue;VyMUDt3QaaH3J(XGCH*As;zBhmL|RBtxl zF;{Kqh*cJ9@qUM=LPmmjr;6Rf%sY0CxlLIIPu*Q}q^}h&A21%af-f6QKesyjf%X&; zBF^iWIg^`l?9L6~o>E*qe5J8xW;I=g^8P5|0^-$^#bepyxPL_Pr<<(c<9Fqvny_mN z*M9!Bi0XS*D35isMvjB`g`LlJypr(*@$QMUh2AL{X z4cfl^I5S1YlaG~EwDe9vLrsKBxOrK|J;AeF4PyExqBN|^i0hgq^=_}E!+s~aUL9)w z{{3(qtoMVMv*3GSp}Vs|&Nf$4yF(JD7>x^gfzM82*P}GQ%@(clc~pbOf%z`wBRmyH zg=Jq9|8ZYJ5zwxZEy^db%o zMtDKP9~94b%0Ezhc!OHV9kn^FiI|7RMAU9?IXorEPH)H? z)ScbENbMd;+{c2-wsAc5Rm}Ez+W6tO zYj`7L);!~)s)cxKc%xpJ%90Nyzr*h%zjJGOYMUAJV z0vH6^m38r4{JqOi+%(L!Etafy5gnoeycsjePf=Cp+fCGc^SrSb03d93Ghcih9*ugD zH?g81K8`um&|NvP@chR?m_CKX+H^flC(WQ`8HbODKXvK(Xur+PqR66Jx zF;{EPx)uk%IK+(BG{qe$)Dk;k`U7ep8N{+hd|ih~a?qRwDKW=Nh9y1Vj5-Fg297Js zg@In&_(y!AL;@fxGNP|SxbtWD!F+mxSi~NMQ2OOBT>n&2j!#hjf_K5?KqkQ!e$}h3 z8fsBDMzWL71#W{Hb9UZ+SmN{n(80b0ukOG2Uzq7%$8o+}W|0NE8cqHk*9#3yawkIU zYLTb7A&`n+`!hWDlvf4*J45L)JwSG#RmKrX4A4@G`+4)0AYD&;_hsmH%s*=8)Sf`> zVsvL%H>IApFK%!UC&m(mqRkX;oBxP_* zq|_5h6V$Ik`jRumj%z(uep|+A?G>A z>chy7B5TIT-yZ{de+O^fv1O*NWQ-5G2Efm^OUET8G8_;7;GQdna>y^UsM-fy(OP_KJi ztNE43yGhf_7gGb&J&Dal-?HI>sc)G3I>aB{KB-N(btffMV;8x0D2T%4A;#4?Ja7!J z%gzf?gmTkls-#7C28h#0%p{)*%f6Ad-2}1zQrb`>j9TL2zm%AcK14^0ISNo-m^(SX z-{AvPCIHZW`3e08L%y*coqMq#6o&Ny;o`^!NsteSG50n7eJ*bzslbIgnOfCiH_4d$ zT^&D?(Xh%m1HmLUnxCqe3g`LpS74BCR}x+&ew8;(o?C_%1AMX0UwI$TJM^* zJRRgrkg+Z{v`^!gct3NFIzx3Usj*f1Tavh^lEje9J)GP>)uAU4wacQWN$namL|7*V zVra4yg_0$-gS`rF(}o`eDgNTQ*>hu-L&>s*ftSpi$C7&xu2lXSaR0(8fK7Ph~%2xupKJRN~MFbaYrm?Lm{z7-Q#yvybAhm6~Hy^TgaG?k7_)Vtn4~ zaxDd;2e+9yvz3ir(2@1X_RqViZG|(s3uAMj0r4E za{|Sfa_o0qbc;rGy0vY%Yp<_i;9ghI@rvATP4h~pTeQVaf}mw@EcJRd;3Vq^Hng&F zEG~ZO5Vn8`;%W&?Dl7f2*1}O+nC|&WhyM~r`~4AOlIg?vPS3@3*e$Q^C^rfgBI&Nl zbI+kG$1$)>7fp*1`Qy+AL?^krD*}xnDF-4rup0N#m%*yxZfsG0M>LYI*P`b?j{4 zAhDj1KEt-6x^~JyY53NYQ{5I7XIver?Ql1-FTJj*q1MlV_OmtbsK!XvkTI5oskf<{ z;&+sf=qE4CVkP&v_ZH5SV^E-&%Vif2-XB#D?`q{X$B+)y7Vu+Wd8}H|)qspZ(*~xg zaxmlN`9Wyaf!aFo1~JxlY1rnGKdZoCgrz7_%ISVl!%f=WMP(!e8S*mYTwRAaa z@t%6mqXPcAharQdl5?n=$FyE8w47B5m{y}f-4lzb6In9{X!zT)vNvGLVv)_Q?;gBI zcyM(vRmnv73hbq5X*cywllje!&-}$bK`{RpjZtyPO{P_6oK$U!k9n)Z6C4U}RB_j# zlOa7;fp3l#VNtnJKrB$w(wZHAeDeL2hQ2J|76(p_tY;tV%|Nm5HpMACR#`Knw|Lja z%+naF-+ze}>tm!kE)x?+p1cMjPyW*`0Ftmn;%0Qj`oEaEn1puYcS%|RsrUolF1F5P zloj2t#!U+E{AVu~n9ARiRVSv~$xLAxhJIo$Ho7{Y@ZfyOUrmq%`zjn9B}qc_B+UYP zDwt3V9-7#RZ$n+S*pgXA_(;z_m>dbkXiY5nzuI!jjUMK;D4Sd~l{GP~QA*|k{IY?6 zrjMT`^8;4IcMZI8yfERgO5jK*)(C}Qne*m5P1QtLt9xR*S?nnZ;Pxn(zWH6>Ag{r> z1^Jj{z@=8pAye$_T<)ErjF@aB@r~R23B2TZ=?ap=nvxmmnUoQGRD>TG==tx!VIP~j z3Oi+wQ;Pp0ngQ=$>L^504E`yR9E>SM6nP4-^7-q9y$b13Qkl}OV@!*)H*WKR$>0=f zk~dFlw_;1D^MN}RasTWKOYr?%yZ{q_)GXNCCUSh0&2P4Zq5&%3~Ygr41FH|E8psdD05c`2readDfdzx;*E%8}q$h z^fK9eCm1EdRT2_k!n|zM;mrC=N8UgBmd#~Q=cx7mNT&P{$uZwOuyG{A z^PCpp9&shi>+0i~c=$cDuuGM-8!hx5>#a*iPO<$RfZky$s-ZjJ?q z7oRma$dTlNpafJqek-NEw6hFFLcs%GUkN!|I-fkg z+b=iCN(G*e=kci|y94P!J)5y2NBUV3KvIU^2I~NrW@-_mC3ADHbz)6Rk`;N5T&h!2 zsBIPRL}n{%46iTnDr1ZKq#G<@W%R9SRQ~)Q8k;K|At0&=-_SO>t|`mb-%{PiLk=*I_@9#0q=MW}0 zZ{foV9mhi=yoo)gnfKTgJ&%H{MRTkdSpbOU*{J)8#HB0e2IU66^lNG!`l=5=1*4>Y zdEIHqS~#=*t;)`wZ28Z(Ucrf+b?Gd7JJIp$C-Dhp(l=8xIHWek3>N=cY^`vtEg>%Y zMU~L#h-CC6Z8Of(wzXy7O!+QqXEqEh&&XvU`F*_=A4TT@y9BL^FWzGoV_jU#8KCZC z|Ldj+_WFhKk|Q}q^@ihs=X<0*$We@!GHe9ot^RvO#8pV;lDHoz*=2ug>us1F`)W{~ zKOy|~_5FCh@5`YNIs{^EACB+26ej$8A|xf&%$IP=X?+__%sTRBKMK*C13R4;yd4QR zJ}VyXLw?$=R2~6N7q-RgKdCs()f8Q?=rLJ8-d*Z64kofwGD3iz9xxwda)3pYdvp3d zKy=Y(|G(hLM|o`cX$OHn*P`!n%T%<22*1<9-}!kJ6t{tdN}Oo#VZyTYofpAN4>2^q zJyhn1LMlH(Tf|&OuP&RFYl%#`mnzJpLIk`61DbJ;^Wzx4hpX~rfC=7a*cJE`@Sj+| z=t%Z%kxsYNBma2O!cQ^OjVC4Fjn?>hH9w)FXT&EzEB8s?1B&FFOD<$f=QpM@->P=hb!R@`W?plg z7c!gN4(Jt6kv4!`IxyB9+M&{NY1Jd*O>>3S9(m5YQFSzd^kxymcB;8Y{57oeE+po)IW>cdv@s9xUFEwV0dA^F#ImgnE#Dxw009&&Pgt z+Dp4UDEF&_fY68M*ZR`?G$SzQYm!+M#ljpWpAC&VwmKv9;6c2|wMK`HXMrVei^8)E z4zr^BmSd>%K_4mylP?lSf##1i#ia{3S3FEiB!h&U|zt~vkh!qHSw=oQWLYq)@y_7QI?&Z$~ z?pyeI!oU#j*O01#4^^}4bo(=J88`oy$JX6#w_ER@mmd4o}c z+@1MuBL1KBiEd<^X8JrIq)4l&W(j#7J&P~wWlILu#4NcL*! z5k6{ICmoXsR$3Sw>t}i&*@}b!PjL#d-4atLgX^|!8+XIrR-uSdS0S-Vj7OP!-dLb# zgmr3Jo4yS2EzZ`d8@W6nZb@BulHO>Z*UfYZ3lz-Kd1Ft+Ilhik9LAPv)eFhwdj!`dhRH82)QhP`_;Bw@_9v z5M#58%mfgVyT02xyHd;LeQw7RKjCo$07F`vHxmsb883_*6-dq5K1|9m4=IIeIkgn* z3u$#tFv`B{(Fgde!NrpEw*B-1ptq>e7^V&>CD{sOjJu6+rlBTyxQ65i^Zbw7cn+Ve z49eboeYx_VfT?y8Eoe!!Xkb{Pl`EE8M)cdML0+C4Z=;=SvA(m|S1U^Rl-jM@bqa4b zV8rf784WnYPUJ3Ar28pBR>72tUwPC60w$Xeqt9vOOgky!#iOobZ)yotPoIDXwtSb8 zGTp}&WZE9yzWORo{Z)K)CLf&#anQ=iv1g8aYRRjuI~mwT2ny!b8DBUgEqd|~17i1B zbGB@{Ih1#lX)j0g#rW~|y8tt%lNf*AkYv5XL`QMVTwbMEg;KnY*61qjo*(f%p(r0Y zbUtrbR{RM|X6GyC&D?BKSyC1bR%fn$p`b`4GA&=IkLk;V!s^J5w>0+2LQKGU3GCqy zwgpf~i~*7#F)>*4%j+E28wUqPg9#co!VF)pacf^|`{$qeFky|U}NI=f-sEh2BRBz*9{B$>=KFS|^|Ml=1NsowO zt(||#?{IUo6UO8SEs2=zt6HRJXxTWgOdEg8Dr<`!h)|@L!9eFS?j3``^9nwl&SrA| zu+Hb@kAWRDFA?KLwkx?wTW|T+D12=6%NUNt@_V2bnd%~+6DmRSzSU%WDdDedGql3n zOj`YJ5%?;v47a{fL3#GX7pts>ew{Aky_iD_t=`LV-@H)Nvx^|t zCUWaqYx&sqA~)*7a$;rV&lHA*1#+B0!B#AbGFGb3p23oEIrTB@_lUc;E3Up02%;Ic zL0?D$xy`-RhT}d{^JMBt@n%GFYc+i9*h4cWBU2_2uV`fo#Rq;JUa=nair|s2%|zvi zkZlKJ19>2`O>i{dKYJatn=fU4+`0F8wVtPgZbQS`jF^UYS>0vnKD6zjzuzQHu8-l7 zzg(?;x{IGa`L+GApyIeQz%~P?e7#sYU5=Vh{M6b(L2E|0d7NLi)7|#Lv?dx-q`Q?% zQ|7^dr_#Zr5;Zk*_*$xy8zE?CF`|+`C8sQlveI)8#E2cy+aE(QZHCJbxEaevL(_xNmuqt$~iJB-HGn-=~mv>Vnra?Nglrq zRM_^H@MN-t?}GlnC6vzp!H!T@MAp#X)nnl2NnC0e72SuB+Gd2R6qRZ}B4<0TIm8j0 zPbtS4u&JR(Ky&ZyA(Kr-67OozZ%Q&tibvGj&W}nr8b*HG7Uj36m05iB7)FRG5qU^0 z9=hlt2o1^S4PN}2wAusx8~VDw5f;{}WgXK_Xntn6r`j?E&v)ks+`ar)nOcvg(`SE@ zkdRo$^{yj6h(WdyYQFph4Z2}6qZJXHoKm4=rQCwJdq^A)(}! zT2P6oj*d0OuS`QcD}Prf0ISu=<9O|7B%-O6VTk82P4?}7^0vd8tiqIyuU(JjA@1J> zmN4D8VtB_No>@(96*qP7t2s*a%FB|2b&*-0sx-ICjsPa@<4N?mkF`O3bl#`qUFQp8X!I$U%EvaX&|^%(wYtIpbhkZ*TF-8< zTDw9roik+6u$cVfnH5#_7&78Jp1S<7rAX$EK}xo0dyB$}=`X&L_nMR5d`hj$MscxR zOYDQY!8XLy7;a^0X$T*Z>;((S-rc8>g0GL_8Q&PbvwCVSpOO*#_(Hy_0#0{jyw-y zcLKuv;a?k+OC-2~M5loT{eTlT7iNPe+e!ei)!SqUM+WW9;bmUJ*1vk)Zb7P(ou9w5-7;U83QpzJ(YOjghILcusHUU}S&F-?^84LpA$u^=!*p%O5DNd6-aMV(A6)dpwB zJ`$t-lkPzl68TT<6Tkac7bwj^0{>A!x7&cvZ8~{(OO+PA(mgZ1v;ocL5u4TYJqvvH z(`-I5XUNF8yNWf$ow$~Nd4$}8E+3*wo)JYdj%=fHwvwgzy{>4znzNUqy=lji-A?yf zT2XFRO5&h4^1t28rG38XjcGU+c3h~j)%uf|Hi+!gL@t|ku2{d?`q!7;(aOA=+{sH& zrD%5ir$t&09i2;lk8~hAESEKtWHqm$ncX*jNGlwNY=8-5mE^8beEgqbJx9(ua^xgd z(^l@9q$`}3Ln@Poo%<>mttVb9i;LojPXR~?J*CFMyltJyz{^kjn|JH~V*w!&+Bfs+ z6iVlaz?dqZ%RP+rD`b(Iq~jL@6CNhpGV+6F&W6s-Kd zqZ*hMSJD!bg$_CfSS}(#oo)~9{a$3jR&d%86Dl4) zA5L5g_ZtzrjwbFGBsxt|ty#(Fc3yDw)p%*@Q^fX4B{1s2{2*gEnEwAiL~S*?ws6wrORsbBW@-G-|nIhbk* zCEEMHy}|*RE!p=gaJWlAYQn28Nv?Oio{o=7M zRkU>`)N>Q<1G0Phueka+7Y0u~)+|u4vqa}V1k>&vS$r4cpDH;43H>*@xbB{-p^GB6B&>*HGq%Lrf zkfZkbuKfAe(oK&5zUBy6{owyNI?Jf2+BOOkGAJoMw9+Lo;7g};mvlEsNl6afDc#-O z-K{XvNSAbX*LV2-!h!{Boq5jv?0fHPdq3}ZSEBhM*8`9L0m)nfsymsZa3RmA!P|Kt zYMq=PV4ltLYUeG2E7W2Ac}cIqsz?8DIi>Kz;$AHfn8r69Cx&B#K@Ev1E^2u79gMkx zB$|7{46X7EtZ`a;j(`4>us$_55jYD~KvT=?kD1#v zU+NkUeQR6^&X4=X7t~8N<6h~QEM?PH`9;Rmc-**l($#UQ439}A`RLDDUu>UMMQ88i z65P9!OWmj|yu^@lxMJQ#g6F}17{c6{-}a9IKe4Lx@7goF=WRO>eG^sJLtB?Ny^Qw1 zU$M=XoKxjd^iW-A6)1lb^x^v3PmF@47SET5)v8Ld2~vN9pbv5dm?;?$hDTN`h+>;- z-u6FI0rt;j_~yGG|0z3J7+d92l9VM%dausIeT~SKNs1z6u;_mUB9xv(q4^LB@lX6W zEn^iRy1_tGvaRd0m_kHN>>8E=Yn%YdhnZl&p17XmM6+es?RDEnt~N46`BCErSkbpH zj6ozH3@TZA!}TBkm68*R(s653ZCtm737kw{v|o(M5a@f_@6^(~gFZyR+(sLp$loVF z?{2Vta&FqJ_P9PQ8O4(qxZ6bH#%K$H%|{R-{xC^3l6AJ1Qa6I34vzFw+DJ66yy`rsSpVKfE!fayCCcHRXL*k^rEHRQ!V*#tp&k;!`UQ*%3{cY*r1 zfq$LTRVx89hJAA-$u0PinUTr~=F{{w*hr01Qdg-F-7->s#etPV(KudR0HgdH`}cRh4+trx2V;zKiUJ_EZs(SdX^%%x$$_9l5h}U%Vgq& zVI&1o79L*w=KTyHG^pZO0aA2%x2M|m5=j!an)n>#H>(Dvi1V~qBN|O%K{|>4@~oqu z?tU-3rTQoelQ!GQ1qos81s-NAU4>?R0ok`?jPNgy#9}vVeejd<93x|idN&@=k_ib( zN59>ey(*=QGzG?foN5yRTE17|rOt4|q>Nxd`-)d#$zi4Cpc&T1+xY#Xh7!?FjV#xQ zSyozl9P0^e?N1YM{T!U@Y@2dQQec=|tA+QVd(vgmyCg1wT-P*aQo=6&!>`_#