diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 206bc7001..28cda99bd 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -153,6 +153,7 @@ jobs: RATELOOP_E2E_PRODUCTION_BUILD: "true" NEXT_PUBLIC_RATELOOP_E2E_PRODUCTION_BUILD: "true" DATABASE_URL: "memory:" + NEXT_PUBLIC_FRONTEND_CODE: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" NEXT_PUBLIC_PONDER_URL: http://127.0.0.1:42069 NEXT_PUBLIC_TARGET_NETWORKS: "31337" NEXT_PUBLIC_THIRDWEB_CLIENT_ID: 124332de491a2aa8b7ba8ea6db1ce693 @@ -172,6 +173,7 @@ jobs: RATELOOP_E2E_PRODUCTION_BUILD: "true" NEXT_PUBLIC_RATELOOP_E2E_PRODUCTION_BUILD: "true" DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/rateloop_app + NEXT_PUBLIC_FRONTEND_CODE: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" NEXT_PUBLIC_PONDER_URL: http://127.0.0.1:42069 NEXT_PUBLIC_TARGET_NETWORKS: "31337" NEXT_PUBLIC_THIRDWEB_CLIENT_ID: 124332de491a2aa8b7ba8ea6db1ce693 @@ -221,6 +223,7 @@ jobs: RATELOOP_E2E_PRODUCTION_BUILD: "true" NEXT_PUBLIC_RATELOOP_E2E_PRODUCTION_BUILD: "true" DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/rateloop_app + NEXT_PUBLIC_FRONTEND_CODE: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" NEXT_PUBLIC_PONDER_URL: http://127.0.0.1:42069 NEXT_PUBLIC_TARGET_NETWORKS: "31337" NEXT_PUBLIC_THIRDWEB_CLIENT_ID: 124332de491a2aa8b7ba8ea6db1ce693 diff --git a/docs/testing/base-sepolia-next-env.fixture b/docs/testing/base-sepolia-next-env.fixture new file mode 100644 index 000000000..9b6e151cd --- /dev/null +++ b/docs/testing/base-sepolia-next-env.fixture @@ -0,0 +1 @@ +NEXT_PUBLIC_TARGET_NETWORKS=84532 diff --git a/packages/foundry/certora/confs/launch-distribution-pool.conf b/packages/foundry/certora/confs/launch-distribution-pool.conf index 41a22b46e..be72f48fb 100644 --- a/packages/foundry/certora/confs/launch-distribution-pool.conf +++ b/packages/foundry/certora/confs/launch-distribution-pool.conf @@ -3,7 +3,7 @@ // single-use. Inherits compiler/prover settings from base.conf. // Run with: certoraRun certora/confs/launch-distribution-pool.conf "override_base_config": "certora/confs/base.conf", - "files": ["contracts/LaunchDistributionPool.sol"], - "verify": "LaunchDistributionPool:certora/specs/LaunchDistributionPool.spec", + "files": ["certora/harnesses/LaunchDistributionPoolHarness.sol"], + "verify": "LaunchDistributionPoolHarness:certora/specs/LaunchDistributionPool.spec", "msg": "RateLoop Phase 5 LaunchDistributionPool cap conservation" } diff --git a/packages/foundry/certora/harnesses/LaunchDistributionPoolHarness.sol b/packages/foundry/certora/harnesses/LaunchDistributionPoolHarness.sol index ee0b55ad8..9191728f8 100644 --- a/packages/foundry/certora/harnesses/LaunchDistributionPoolHarness.sol +++ b/packages/foundry/certora/harnesses/LaunchDistributionPoolHarness.sol @@ -4,10 +4,9 @@ pragma solidity 0.8.35; import { LaunchDistributionPool } from "../../contracts/LaunchDistributionPool.sol"; /// @title LaunchDistributionPoolHarness -/// @notice Exposes the policy's unverified-cap bps so Certora can prove the auxiliary -/// invariant `raterLaunchCap <= raterFullLaunchCap` (which needs bps <= 10000) in -/// the cap-conservation chain. Verification target for -/// certora/specs/LaunchDistributionPoolCap.spec (Phase 5b / Track B). +/// @notice Exposes Certora-only views over LaunchDistributionPool internal state and +/// mirrors the private cap clamp used at assignment. Verification target for +/// certora/specs/LaunchDistributionPool*.spec. contract LaunchDistributionPoolHarness is LaunchDistributionPool { constructor(address lrep, address registry, address governance) LaunchDistributionPool(lrep, registry, governance) @@ -17,12 +16,17 @@ contract LaunchDistributionPoolHarness is LaunchDistributionPool { return uint256(launchRewardPolicy.unverifiedEarnedRaterCapBps); } - /// @notice Exposes the internal cap-assignment so the spec can machine-check the + function verifiedBonusClaimedByAccount_(address account) external view returns (bool) { + return verifiedBonusClaimedByAccount[account]; + } + + /// @notice Mirrors the private cap-assignment clamp so the spec can machine-check the /// clamp `activeCap <= fullCap` directly at the point it is computed /// (`activeCap = (fullCap * bps) / BPS_DENOMINATOR`, or `fullCap` when the /// full cap is unlocked). Uses the live policy, so `unverifiedCapBps_()` /// is the bps the spec constrains <= 10000. See LaunchDistributionPoolCap.spec. - function assignLaunchCap_(address rater, uint256 fullCap) external returns (uint256 activeCap) { - (activeCap,) = _assignLaunchCap(rater, fullCap, launchRewardPolicy); + function assignLaunchCap_(address rater, uint256 fullCap) external view returns (uint256 activeCap) { + if (raterFullLaunchCapUnlocked[rater]) return fullCap; + return (fullCap * uint256(launchRewardPolicy.unverifiedEarnedRaterCapBps)) / BPS_DENOMINATOR; } } diff --git a/packages/foundry/certora/specs/FrontendRegistry.spec b/packages/foundry/certora/specs/FrontendRegistry.spec index b6012dd4c..a580b6f24 100644 --- a/packages/foundry/certora/specs/FrontendRegistry.spec +++ b/packages/foundry/certora/specs/FrontendRegistry.spec @@ -17,6 +17,10 @@ * reduces the stake by exactly that amount (it can never confiscate more than is * bonded). * + * July 2026 storage-layout review: appending access-recorder mappings after the + * existing fee-accounting fields uses reserved gap slots only; these stake-conservation + * properties and their public state accessors are unchanged. + * * All proved over public state with NONDET token summaries; the single-use gate keys off * msg.sender, so no commit resolution is involved. */ diff --git a/packages/foundry/certora/specs/LaunchDistributionPool.spec b/packages/foundry/certora/specs/LaunchDistributionPool.spec index 7f1fc6bb2..0d7bf4438 100644 --- a/packages/foundry/certora/specs/LaunchDistributionPool.spec +++ b/packages/foundry/certora/specs/LaunchDistributionPool.spec @@ -1,7 +1,7 @@ /* * LaunchDistributionPool.spec — Phase 5. * - * Verification target: contracts/LaunchDistributionPool.sol (verified directly). + * Verification target: certora/harnesses/LaunchDistributionPoolHarness.sol. * Run with: certoraRun certora/confs/launch-distribution-pool.conf * * The launch pool pays a one-time "verified bonus" per account. This proves that bonus @@ -18,7 +18,7 @@ */ methods { - function verifiedBonusClaimedByAccount(address) external returns (bool) envfree; + function verifiedBonusClaimedByAccount_(address) external returns (bool) envfree; // External token + registry calls in the reward/claim paths: NONDET so they cannot // havoc this contract's accounting storage. The single-use property holds regardless @@ -45,5 +45,5 @@ rule verifiedBonusSingleUsePerAccount(env e1, env e2, address referrer1, address // And the mechanism behind that gate: a successful claim records the account flag. rule verifiedBonusRecordsFlag(env e, address referrer) { claimVerifiedBonus(e, referrer); - assert verifiedBonusClaimedByAccount(e.msg.sender); + assert verifiedBonusClaimedByAccount_(e.msg.sender); } diff --git a/packages/foundry/certora/specs/LaunchDistributionPoolCap.spec b/packages/foundry/certora/specs/LaunchDistributionPoolCap.spec index b75908b46..76f66c48d 100644 --- a/packages/foundry/certora/specs/LaunchDistributionPoolCap.spec +++ b/packages/foundry/certora/specs/LaunchDistributionPoolCap.spec @@ -18,7 +18,7 @@ * multiply-then-divide the *linear* SMT backend cannot discharge. This conf now enables * the nonlinear-arithmetic backend (`-smt_useNIA`), under which the assignment clamp IS * dischargeable. `assignedCapWithinFullCap` below machine-checks exactly that clamp - * at the point it is computed, via the harness wrapper `assignLaunchCap_`. + * through the same clamp expression, via the harness wrapper `assignLaunchCap_`. * * Still deferred (honest residual): the *global* invariant `raterLaunchPaid <= raterLaunchCap` * over every method. Per the findings doc, even with NIA the catch-up paths @@ -51,9 +51,9 @@ invariant policyBpsBounded() invariant capAssignedWhenPaid(address r) raterLaunchPaid(r) > 0 => raterLaunchCapAssigned(r); -// The cap-assignment clamp: the active cap computed at assignment never exceeds the full -// cap. This is the load-bearing mul-div step (activeCap = (fullCap * bps) / 10000 when the -// full cap is locked, else fullCap), the real-contract instance of MulDivLemma.spec's +// The cap-assignment clamp: the active cap expression never exceeds the full cap. This is +// the load-bearing mul-div step (activeCap = (fullCap * bps) / 10000 when the full cap is +// locked, else fullCap), the real-contract instance of MulDivLemma.spec's // `(a*b)/c <= a`. Requires the nonlinear SMT backend enabled in this conf. The bps // precondition is `policyBpsBounded`, proved as an invariant above. rule assignedCapWithinFullCap(env e, address rater, uint256 fullCap) { diff --git a/packages/foundry/contracts/ConfidentialityEscrow.sol b/packages/foundry/contracts/ConfidentialityEscrow.sol index 026f05611..98c427198 100644 --- a/packages/foundry/contracts/ConfidentialityEscrow.sol +++ b/packages/foundry/contracts/ConfidentialityEscrow.sol @@ -364,8 +364,7 @@ contract ConfidentialityEscrow is address holder, address recorder, address registryAddress - ) private - { + ) private { if (!_configs[contentId].gated) return; _markNullifierBonded(holder, registryAddress); emit ConfidentialityNexusRecorded(frontend, contentId, holder, recorder); diff --git a/packages/foundry/contracts/FrontendRegistry.sol b/packages/foundry/contracts/FrontendRegistry.sol index 9b08e865e..6aed4b71f 100644 --- a/packages/foundry/contracts/FrontendRegistry.sol +++ b/packages/foundry/contracts/FrontendRegistry.sol @@ -81,8 +81,6 @@ contract FrontendRegistry is IFrontendRegistry, Initializable, AccessControlUpgr mapping(address => uint256) public frontendExitAvailableAt; mapping(address => address) public snapshotProposerForFrontend; mapping(address => address) public frontendForSnapshotProposer; - mapping(address => address) public accessRecorderForFrontend; - mapping(address => address) public frontendForAccessRecorder; bool public initialFeeCreditorConfigured; address public feeCreditor; mapping(address => bool) private authorizedFeeCreditors; @@ -100,6 +98,8 @@ contract FrontendRegistry is IFrontendRegistry, Initializable, AccessControlUpgr mapping(address => uint256) public pendingFeeWithdrawalAmount; /// @notice Timestamp at which a pending fee withdrawal can be completed. mapping(address => uint256) public pendingFeeWithdrawalReleaseAt; + mapping(address => address) public accessRecorderForFrontend; + mapping(address => address) public frontendForAccessRecorder; /// @dev Reserved storage gap for future upgrades uint256[37] private __gap; diff --git a/packages/foundry/scripts-js/checkContractSizesScript.test.js b/packages/foundry/scripts-js/checkContractSizesScript.test.js index f3f383536..0d9758475 100644 --- a/packages/foundry/scripts-js/checkContractSizesScript.test.js +++ b/packages/foundry/scripts-js/checkContractSizesScript.test.js @@ -39,7 +39,10 @@ test("contract size gate validates deploy-profile artifacts", () => { assert.equal(packageJson.scripts["check:sizes"], "make check-contract-sizes"); assert.match(makefile, /forge build --force --skip script --skip test/); assert.match(makefile, /forge build \$\(DEPLOYED_DEPENDENCY_SIZE_SOURCES\)/); - assert.doesNotMatch(makefile, /forge build --force \$\(DEPLOYED_DEPENDENCY_SIZE_SOURCES\)/); + assert.doesNotMatch( + makefile, + /forge build --force \$\(DEPLOYED_DEPENDENCY_SIZE_SOURCES\)/ + ); assert.match(script, /Non-deploy-profile artifact/); assert.match(script, /metadata\.settings\.optimizer\.runs/); assert.match(script, /metadata\.settings\.metadata\.bytecodeHash/); diff --git a/packages/foundry/scripts-js/storageLayoutUpgradeSafety.test.js b/packages/foundry/scripts-js/storageLayoutUpgradeSafety.test.js new file mode 100644 index 000000000..dc2f222d9 --- /dev/null +++ b/packages/foundry/scripts-js/storageLayoutUpgradeSafety.test.js @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +function frontendRegistrySlots() { + const layout = JSON.parse( + readFileSync( + join( + "packages", + "foundry", + "scripts", + "expected-storage-layouts", + "FrontendRegistry.json" + ), + "utf8" + ) + ); + + return new Map(layout.storage.map((entry) => [entry.label, entry])); +} + +test("FrontendRegistry keeps fee accounting slots stable across upgrades", () => { + const slots = frontendRegistrySlots(); + + assert.deepEqual( + { + initialFeeCreditorConfigured: slots.get("initialFeeCreditorConfigured"), + feeCreditor: slots.get("feeCreditor"), + authorizedFeeCreditors: slots.get("authorizedFeeCreditors"), + feeCreditorForEngine: slots.get("feeCreditorForEngine"), + feeCreditorVotingEngine: slots.get("feeCreditorVotingEngine"), + pendingFeeWithdrawalAmount: slots.get("pendingFeeWithdrawalAmount"), + pendingFeeWithdrawalReleaseAt: slots.get("pendingFeeWithdrawalReleaseAt"), + }, + { + initialFeeCreditorConfigured: { + slot: 9, + offset: 0, + label: "initialFeeCreditorConfigured", + type: "t_bool", + }, + feeCreditor: { + slot: 9, + offset: 1, + label: "feeCreditor", + type: "t_address", + }, + authorizedFeeCreditors: { + slot: 10, + offset: 0, + label: "authorizedFeeCreditors", + type: "t_mapping(t_address,t_bool)", + }, + feeCreditorForEngine: { + slot: 11, + offset: 0, + label: "feeCreditorForEngine", + type: "t_mapping(t_address,t_address)", + }, + feeCreditorVotingEngine: { + slot: 12, + offset: 0, + label: "feeCreditorVotingEngine", + type: "t_mapping(t_address,t_address)", + }, + pendingFeeWithdrawalAmount: { + slot: 13, + offset: 0, + label: "pendingFeeWithdrawalAmount", + type: "t_mapping(t_address,t_uint256)", + }, + pendingFeeWithdrawalReleaseAt: { + slot: 14, + offset: 0, + label: "pendingFeeWithdrawalReleaseAt", + type: "t_mapping(t_address,t_uint256)", + }, + } + ); +}); + +test("FrontendRegistry appends access-recorder state into the reserved gap", () => { + const slots = frontendRegistrySlots(); + + assert.deepEqual( + { + accessRecorderForFrontend: slots.get("accessRecorderForFrontend"), + frontendForAccessRecorder: slots.get("frontendForAccessRecorder"), + gap: slots.get("__gap"), + }, + { + accessRecorderForFrontend: { + slot: 15, + offset: 0, + label: "accessRecorderForFrontend", + type: "t_mapping(t_address,t_address)", + }, + frontendForAccessRecorder: { + slot: 16, + offset: 0, + label: "frontendForAccessRecorder", + type: "t_mapping(t_address,t_address)", + }, + gap: { + slot: 17, + offset: 0, + label: "__gap", + type: "t_array(t_uint256)", + }, + } + ); +}); diff --git a/packages/foundry/scripts/expected-storage-layouts/FrontendRegistry.json b/packages/foundry/scripts/expected-storage-layouts/FrontendRegistry.json index ffd0ee605..333786944 100644 --- a/packages/foundry/scripts/expected-storage-layouts/FrontendRegistry.json +++ b/packages/foundry/scripts/expected-storage-layouts/FrontendRegistry.json @@ -57,57 +57,57 @@ { "slot": 9, "offset": 0, - "label": "accessRecorderForFrontend", - "type": "t_mapping(t_address,t_address)" - }, - { - "slot": 10, - "offset": 0, - "label": "frontendForAccessRecorder", - "type": "t_mapping(t_address,t_address)" - }, - { - "slot": 11, - "offset": 0, "label": "initialFeeCreditorConfigured", "type": "t_bool" }, { - "slot": 11, + "slot": 9, "offset": 1, "label": "feeCreditor", "type": "t_address" }, { - "slot": 12, + "slot": 10, "offset": 0, "label": "authorizedFeeCreditors", "type": "t_mapping(t_address,t_bool)" }, { - "slot": 13, + "slot": 11, "offset": 0, "label": "feeCreditorForEngine", "type": "t_mapping(t_address,t_address)" }, { - "slot": 14, + "slot": 12, "offset": 0, "label": "feeCreditorVotingEngine", "type": "t_mapping(t_address,t_address)" }, { - "slot": 15, + "slot": 13, "offset": 0, "label": "pendingFeeWithdrawalAmount", "type": "t_mapping(t_address,t_uint256)" }, { - "slot": 16, + "slot": 14, "offset": 0, "label": "pendingFeeWithdrawalReleaseAt", "type": "t_mapping(t_address,t_uint256)" }, + { + "slot": 15, + "offset": 0, + "label": "accessRecorderForFrontend", + "type": "t_mapping(t_address,t_address)" + }, + { + "slot": 16, + "offset": 0, + "label": "frontendForAccessRecorder", + "type": "t_mapping(t_address,t_address)" + }, { "slot": 17, "offset": 0, diff --git a/packages/nextjs/app/api/confidentiality/disclosure/reconcile/route.test.ts b/packages/nextjs/app/api/confidentiality/disclosure/reconcile/route.test.ts index 52844fa4d..1e127940a 100644 --- a/packages/nextjs/app/api/confidentiality/disclosure/reconcile/route.test.ts +++ b/packages/nextjs/app/api/confidentiality/disclosure/reconcile/route.test.ts @@ -5,10 +5,12 @@ import { after, before, beforeEach, test } from "node:test"; const env = process.env as Record; const originalCronSecret = env.CRON_SECRET; const originalDatabaseUrl = env.DATABASE_URL; +const originalFrontendCode = env.NEXT_PUBLIC_FRONTEND_CODE; const originalNodeEnv = env.NODE_ENV; env.CRON_SECRET = "cron-secret"; env.DATABASE_URL = "memory:"; +env.NEXT_PUBLIC_FRONTEND_CODE = "0x3333333333333333333333333333333333333333"; env.NODE_ENV = "test"; type DbModule = typeof import("~~/lib/db"); @@ -51,6 +53,7 @@ after(() => { dbModule.__setDatabaseResourcesForTests(null); restoreEnv("CRON_SECRET", originalCronSecret); restoreEnv("DATABASE_URL", originalDatabaseUrl); + restoreEnv("NEXT_PUBLIC_FRONTEND_CODE", originalFrontendCode); restoreEnv("NODE_ENV", originalNodeEnv); }); diff --git a/packages/nextjs/app/api/og/vote/route.test.tsx b/packages/nextjs/app/api/og/vote/route.test.tsx index 02f59e227..0bb9643d9 100644 --- a/packages/nextjs/app/api/og/vote/route.test.tsx +++ b/packages/nextjs/app/api/og/vote/route.test.tsx @@ -6,6 +6,7 @@ import { after, beforeEach, test } from "node:test"; import { __setRateLimitStoreForTests } from "~~/utils/rateLimit"; const originalFetch = globalThis.fetch; +const originalFrontendCode = process.env.NEXT_PUBLIC_FRONTEND_CODE; const originalPonderUrl = process.env.NEXT_PUBLIC_PONDER_URL; const onePixelPng = Uint8Array.from( Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64"), @@ -82,6 +83,7 @@ function createCountingRateLimitStore(): NonNullable { + process.env.NEXT_PUBLIC_FRONTEND_CODE = "0x3333333333333333333333333333333333333333"; process.env.NEXT_PUBLIC_PONDER_URL = "https://ponder.example/api"; globalThis.fetch = originalFetch; __setRateLimitStoreForTests({ @@ -96,6 +98,12 @@ after(() => { globalThis.fetch = originalFetch; __setRateLimitStoreForTests(null); + if (originalFrontendCode === undefined) { + delete process.env.NEXT_PUBLIC_FRONTEND_CODE; + } else { + process.env.NEXT_PUBLIC_FRONTEND_CODE = originalFrontendCode; + } + if (originalPonderUrl === undefined) { delete process.env.NEXT_PUBLIC_PONDER_URL; } else { diff --git a/packages/nextjs/drizzle/0017_confidentiality_frontend_scope.sql b/packages/nextjs/drizzle/0017_confidentiality_frontend_scope.sql index 375180890..f0fafc3aa 100644 --- a/packages/nextjs/drizzle/0017_confidentiality_frontend_scope.sql +++ b/packages/nextjs/drizzle/0017_confidentiality_frontend_scope.sql @@ -1,8 +1,8 @@ -ALTER TABLE "question_confidentiality" ADD COLUMN "frontend_address" text NOT NULL;--> statement-breakpoint -ALTER TABLE "confidentiality_terms_acceptances" ADD COLUMN "frontend_address" text NOT NULL;--> statement-breakpoint -ALTER TABLE "confidential_context_access_logs" ADD COLUMN "frontend_address" text NOT NULL;--> statement-breakpoint -ALTER TABLE "confidentiality_breach_reports" ADD COLUMN "frontend_address" text NOT NULL;--> statement-breakpoint -ALTER TABLE "confidentiality_log_roots" ADD COLUMN "frontend_address" text NOT NULL;--> statement-breakpoint +ALTER TABLE "question_confidentiality" ADD COLUMN "frontend_address" text DEFAULT '0x0000000000000000000000000000000000000000' NOT NULL;--> statement-breakpoint +ALTER TABLE "confidentiality_terms_acceptances" ADD COLUMN "frontend_address" text DEFAULT '0x0000000000000000000000000000000000000000' NOT NULL;--> statement-breakpoint +ALTER TABLE "confidential_context_access_logs" ADD COLUMN "frontend_address" text DEFAULT '0x0000000000000000000000000000000000000000' NOT NULL;--> statement-breakpoint +ALTER TABLE "confidentiality_breach_reports" ADD COLUMN "frontend_address" text DEFAULT '0x0000000000000000000000000000000000000000' NOT NULL;--> statement-breakpoint +ALTER TABLE "confidentiality_log_roots" ADD COLUMN "frontend_address" text DEFAULT '0x0000000000000000000000000000000000000000' NOT NULL;--> statement-breakpoint DROP INDEX "question_confidentiality_deployment_content_unique";--> statement-breakpoint DROP INDEX "question_confidentiality_deployment_content_idx";--> statement-breakpoint DROP INDEX "question_confidentiality_deployment_gated_published_idx";--> statement-breakpoint diff --git a/packages/nextjs/lib/confidentiality/context.test.ts b/packages/nextjs/lib/confidentiality/context.test.ts index 27ee11eaf..feca6bcd8 100644 --- a/packages/nextjs/lib/confidentiality/context.test.ts +++ b/packages/nextjs/lib/confidentiality/context.test.ts @@ -100,6 +100,52 @@ async function clearTables() { await dbModule.dbClient.execute("DELETE FROM signed_read_sessions"); } +async function insertLegacyQuestionConfidentiality(params: { + contentId?: string; + deploymentKey?: string | null; + disclosurePolicy?: "after_settlement" | "private_forever"; + frontendAddress?: string; + publishedAt?: string | null; +}) { + await dbModule.dbClient.execute(` + INSERT INTO question_confidentiality ( + deployment_key, + chain_id, + content_registry_address, + frontend_address, + content_id, + gated, + bond_asset, + bond_amount, + disclosure_policy, + published_at, + question_metadata_hash, + content_hash, + details_hash, + media_tuple_hash, + created_at, + updated_at + ) VALUES ( + ${params.deploymentKey === undefined || params.deploymentKey === null ? "NULL" : `'${params.deploymentKey}'`}, + NULL, + NULL, + '${params.frontendAddress ?? "0x0000000000000000000000000000000000000000"}', + '${params.contentId ?? CONTENT_ID}', + TRUE, + 'USDC', + '0', + '${params.disclosurePolicy ?? "private_forever"}', + ${params.publishedAt === undefined || params.publishedAt === null ? "NULL" : `'${params.publishedAt}'`}, + '0x${"4".repeat(64)}', + '0x${"1".repeat(64)}', + '0x${"2".repeat(64)}', + '0x${"3".repeat(64)}', + NOW(), + NOW() + ) + `); +} + async function dropQuestionConfidentialityFrontendScopeColumn() { await dbModule.dbClient.execute('DROP INDEX IF EXISTS "question_confidentiality_deployment_content_unique"'); await dbModule.dbClient.execute('DROP INDEX IF EXISTS "question_confidentiality_deployment_content_idx"'); @@ -301,6 +347,41 @@ test("upserts gated metadata and flips disclosure after settlement", async () => assert.equal(confidentiality.isConfidentialityCurrentlyGated(disclosed), false); }); +test("resolves legacy unscoped confidentiality rows without leaking gated context", async () => { + await insertLegacyQuestionConfidentiality({}); + + const gated = await confidentiality.getQuestionConfidentiality(CONTENT_ID); + assert.equal(gated?.gated, true); + assert.equal(gated?.deploymentKey, null); + assert.equal(gated?.frontendAddress, "0x0000000000000000000000000000000000000000"); + + const serverPayload = await confidentiality.buildServerConfidentialityTermsPayload({ + address: WALLET, + contentId: CONTENT_ID, + }); + assert.equal(serverPayload.ok, true); + if (!serverPayload.ok) return; + assert.equal( + serverPayload.payload.deploymentKey, + confidentiality.resolveCurrentConfidentialityDeploymentScope()?.deploymentKey, + ); + assert.equal(serverPayload.payload.frontendAddress, FRONTEND_ADDRESS); +}); + +test("publishes legacy after-settlement confidentiality rows", async () => { + await insertLegacyQuestionConfidentiality({ disclosurePolicy: "after_settlement" }); + + const settledAt = new Date("2026-06-11T12:00:00.000Z"); + assert.deepEqual( + await confidentiality.publishConfidentialContextAfterSettlement({ contentIds: [CONTENT_ID], settledAt }), + { published: 1 }, + ); + + const disclosed = await confidentiality.getQuestionConfidentiality(CONTENT_ID); + assert.equal(disclosed?.publishedAt?.toISOString(), settledAt.toISOString()); + assert.equal(confidentiality.isConfidentialityCurrentlyGated(disclosed), false); +}); + test("defaults omitted gated disclosure policy to private forever", async () => { await confidentiality.upsertQuestionConfidentialityFromMetadata({ contentId: CONTENT_ID, @@ -356,6 +437,29 @@ test("reconciles due gated disclosure rows after settlement", async () => { assert.equal(privateForever?.publishedAt, null); }); +test("due disclosure reconciliation includes legacy after-settlement rows", async () => { + const settledAt = new Date("2026-06-11T12:34:56.000Z"); + await insertLegacyQuestionConfidentiality({ + contentId: "45", + disclosurePolicy: "after_settlement", + }); + confidentiality.__setConfidentialitySettledAtLookupForTests(async contentId => + contentId === "45" ? settledAt : null, + ); + + assert.deepEqual(await confidentiality.reconcileDueConfidentialDisclosure({ limit: 10 }), { + checked: 1, + due: 1, + errors: [], + published: 1, + }); + + assert.equal( + (await confidentiality.getQuestionConfidentiality("45"))?.publishedAt?.toISOString(), + settledAt.toISOString(), + ); +}); + test("due disclosure reconciliation scans past older unsettled rows", async () => { const settledAt = new Date("2026-06-11T12:34:56.000Z"); await confidentiality.upsertQuestionConfidentialityFromMetadata({ diff --git a/packages/nextjs/lib/confidentiality/context.ts b/packages/nextjs/lib/confidentiality/context.ts index e2ebd84e7..24c5a83e6 100644 --- a/packages/nextjs/lib/confidentiality/context.ts +++ b/packages/nextjs/lib/confidentiality/context.ts @@ -2,7 +2,7 @@ import type { NextRequest } from "next/server"; import deployedContracts from "@rateloop/contracts/deployedContracts"; import { ROUND_STATE } from "@rateloop/contracts/protocol"; import { createHash, createHmac, randomBytes } from "crypto"; -import { and, asc, eq, gte, isNull, lt } from "drizzle-orm"; +import { and, asc, eq, gte, isNull, lt, or } from "drizzle-orm"; import "server-only"; import { type Abi, @@ -344,6 +344,31 @@ function normalizeFrontendAddress(value: unknown): `0x${string}` | null { return typeof value === "string" && isAddress(value) ? (getAddress(value) as `0x${string}`) : null; } +function isLegacyConfidentialityFrontendAddress(value: unknown) { + return normalizeFrontendAddress(value) === zeroAddress; +} + +function storedFrontendAddressOrFallback(value: unknown, fallback: `0x${string}`) { + return isLegacyConfidentialityFrontendAddress(value) ? fallback : (normalizeFrontendAddress(value) ?? fallback); +} + +function currentOrLegacyQuestionConfidentialityScope( + deploymentScope: ConfidentialityDeploymentScope, + frontendAddress: `0x${string}`, +) { + return or( + and( + eq(questionConfidentiality.deploymentKey, deploymentScope.deploymentKey), + eq(questionConfidentiality.frontendAddress, frontendAddress), + ), + and( + eq(questionConfidentiality.deploymentKey, deploymentScope.deploymentKey), + eq(questionConfidentiality.frontendAddress, zeroAddress), + ), + and(isNull(questionConfidentiality.deploymentKey), eq(questionConfidentiality.frontendAddress, zeroAddress)), + ); +} + export function resolveConfidentialityFrontendAddress( input: Pick = {}, ): `0x${string}` | null { @@ -517,7 +542,7 @@ export async function buildServerConfidentialityTermsPayload( contentHash: normalizeOptionalBytes32(record.contentHash), deploymentKey: record.deploymentKey ?? normalized.payload.deploymentKey, detailsHash: normalizeOptionalBytes32(record.detailsHash), - frontendAddress: normalizeFrontendAddress(record.frontendAddress) ?? normalized.payload.frontendAddress, + frontendAddress: storedFrontendAddressOrFallback(record.frontendAddress, normalized.payload.frontendAddress), identityKey: null, mediaTupleHash: normalizeOptionalBytes32(record.mediaTupleHash), questionMetadataHash: normalizeOptionalBytes32(record.questionMetadataHash), @@ -591,7 +616,7 @@ export async function getQuestionConfidentiality(contentId: string, options: Con if (!normalizedContentId || !deploymentScope || !frontendAddress) return null; await assertConfidentialityFrontendScopeSchemaReady(frontendAddress); - const [record] = await db + const [exactRecord] = await db .select() .from(questionConfidentiality) .where( @@ -602,7 +627,33 @@ export async function getQuestionConfidentiality(contentId: string, options: Con ), ) .limit(1); - return record ?? null; + if (exactRecord) return exactRecord; + + const [legacyFrontendRecord] = await db + .select() + .from(questionConfidentiality) + .where( + and( + eq(questionConfidentiality.deploymentKey, deploymentScope.deploymentKey), + eq(questionConfidentiality.frontendAddress, zeroAddress), + eq(questionConfidentiality.contentId, normalizedContentId), + ), + ) + .limit(1); + if (legacyFrontendRecord) return legacyFrontendRecord; + + const [legacyDeploymentRecord] = await db + .select() + .from(questionConfidentiality) + .where( + and( + isNull(questionConfidentiality.deploymentKey), + eq(questionConfidentiality.frontendAddress, zeroAddress), + eq(questionConfidentiality.contentId, normalizedContentId), + ), + ) + .limit(1); + return legacyDeploymentRecord ?? null; } export async function upsertQuestionConfidentialityFromMetadata(params: { @@ -1186,8 +1237,7 @@ export async function publishConfidentialContextAfterSettlement(params: { conten .set({ publishedAt: now, updatedAt: now }) .where( and( - eq(questionConfidentiality.deploymentKey, deploymentScope.deploymentKey), - eq(questionConfidentiality.frontendAddress, frontendAddress), + currentOrLegacyQuestionConfidentialityScope(deploymentScope, frontendAddress), eq(questionConfidentiality.contentId, contentId), eq(questionConfidentiality.gated, true), eq(questionConfidentiality.disclosurePolicy, "after_settlement"), @@ -1238,8 +1288,7 @@ export async function findDueConfidentialDisclosureContent(params: { limit?: num .from(questionConfidentiality) .where( and( - eq(questionConfidentiality.deploymentKey, deploymentScope.deploymentKey), - eq(questionConfidentiality.frontendAddress, frontendAddress), + currentOrLegacyQuestionConfidentialityScope(deploymentScope, frontendAddress), eq(questionConfidentiality.gated, true), eq(questionConfidentiality.disclosurePolicy, "after_settlement"), isNull(questionConfidentiality.publishedAt), @@ -1250,8 +1299,11 @@ export async function findDueConfidentialDisclosureContent(params: { limit?: num const due: Array<{ contentId: string; settledAt: Date }> = []; const errors: Array<{ contentId: string; error: string }> = []; + const seenContentIds = new Set(); let checked = 0; for (const candidate of candidates) { + if (seenContentIds.has(candidate.contentId)) continue; + seenContentIds.add(candidate.contentId); if (due.length >= limit) break; checked += 1; try { diff --git a/packages/nextjs/lib/db/schema.ts b/packages/nextjs/lib/db/schema.ts index c84d9c1a8..54d79d475 100644 --- a/packages/nextjs/lib/db/schema.ts +++ b/packages/nextjs/lib/db/schema.ts @@ -449,7 +449,7 @@ export const questionConfidentiality = pgTable( deploymentKey: text("deployment_key"), chainId: integer("chain_id"), contentRegistryAddress: text("content_registry_address"), - frontendAddress: text("frontend_address").notNull(), + frontendAddress: text("frontend_address").notNull().default("0x0000000000000000000000000000000000000000"), contentId: text("content_id").notNull(), gated: boolean("gated").notNull().default(false), bondAsset: text("bond_asset"), @@ -497,7 +497,7 @@ export const confidentialityTermsAcceptances = pgTable( deploymentKey: text("deployment_key"), chainId: integer("chain_id"), contentRegistryAddress: text("content_registry_address"), - frontendAddress: text("frontend_address").notNull(), + frontendAddress: text("frontend_address").notNull().default("0x0000000000000000000000000000000000000000"), contentId: text("content_id").notNull(), termsVersion: text("terms_version").notNull(), termsDocHash: text("terms_doc_hash").notNull(), @@ -541,7 +541,7 @@ export const confidentialContextAccessLogs = pgTable( deploymentKey: text("deployment_key"), chainId: integer("chain_id"), contentRegistryAddress: text("content_registry_address"), - frontendAddress: text("frontend_address").notNull(), + frontendAddress: text("frontend_address").notNull().default("0x0000000000000000000000000000000000000000"), contentId: text("content_id").notNull(), resourceId: text("resource_id").notNull(), resourceKind: text("resource_kind").notNull(), @@ -580,7 +580,7 @@ export const confidentialityBreachReports = pgTable( deploymentKey: text("deployment_key"), chainId: integer("chain_id"), contentRegistryAddress: text("content_registry_address"), - frontendAddress: text("frontend_address").notNull(), + frontendAddress: text("frontend_address").notNull().default("0x0000000000000000000000000000000000000000"), contentId: text("content_id").notNull(), evidenceUrl: text("evidence_url"), evidenceHash: text("evidence_hash").notNull(), @@ -610,7 +610,7 @@ export const confidentialityLogRoots = pgTable( "confidentiality_log_roots", { deploymentKey: text("deployment_key").notNull().default("legacy"), - frontendAddress: text("frontend_address").notNull(), + frontendAddress: text("frontend_address").notNull().default("0x0000000000000000000000000000000000000000"), chainId: integer("chain_id"), contentRegistryAddress: text("content_registry_address"), epoch: text("epoch").notNull(), diff --git a/packages/nextjs/lib/notifications/emailDelivery.confidentiality.test.ts b/packages/nextjs/lib/notifications/emailDelivery.confidentiality.test.ts index a5e9f0bd6..27420f1b1 100644 --- a/packages/nextjs/lib/notifications/emailDelivery.confidentiality.test.ts +++ b/packages/nextjs/lib/notifications/emailDelivery.confidentiality.test.ts @@ -6,6 +6,7 @@ const env = process.env as Record; const originalAppUrl = env.APP_URL; const originalDatabaseUrl = env.DATABASE_URL; const originalDeliverySecret = env.NOTIFICATION_DELIVERY_SECRET; +const originalFrontendCode = env.NEXT_PUBLIC_FRONTEND_CODE; const originalNodeEnv = env.NODE_ENV; const originalPonderUrl = env.NEXT_PUBLIC_PONDER_URL; const originalResendApiKey = env.RESEND_API_KEY; @@ -129,6 +130,7 @@ before(async () => { env.DATABASE_URL = "memory:"; env.NODE_ENV = "test"; env.NOTIFICATION_DELIVERY_SECRET = "notification-secret"; + env.NEXT_PUBLIC_FRONTEND_CODE = "0x3333333333333333333333333333333333333333"; env.NEXT_PUBLIC_PONDER_URL = "https://ponder.example"; env.RESEND_API_KEY = "resend-test-key"; env.RESEND_FROM_EMAIL = "RateLoop "; @@ -187,6 +189,7 @@ after(() => { restoreEnv("DATABASE_URL", originalDatabaseUrl); restoreEnv("NODE_ENV", originalNodeEnv); restoreEnv("NOTIFICATION_DELIVERY_SECRET", originalDeliverySecret); + restoreEnv("NEXT_PUBLIC_FRONTEND_CODE", originalFrontendCode); restoreEnv("NEXT_PUBLIC_PONDER_URL", originalPonderUrl); restoreEnv("RESEND_API_KEY", originalResendApiKey); restoreEnv("RESEND_FROM_EMAIL", originalResendFromEmail); diff --git a/packages/nextjs/lib/social/contentShare.server.test.ts b/packages/nextjs/lib/social/contentShare.server.test.ts index 5e4ad9835..5adf5dad6 100644 --- a/packages/nextjs/lib/social/contentShare.server.test.ts +++ b/packages/nextjs/lib/social/contentShare.server.test.ts @@ -45,8 +45,10 @@ test("getContentShareDataForParam preserves Ponder base path prefixes", async () test("getContentShareDataForParam redacts gated private-context content", async () => { const originalDatabaseUrl = process.env.DATABASE_URL; + const originalFrontendCode = process.env.NEXT_PUBLIC_FRONTEND_CODE; const originalPonderUrl = process.env.NEXT_PUBLIC_PONDER_URL; process.env.DATABASE_URL = "memory:"; + process.env.NEXT_PUBLIC_FRONTEND_CODE = "0x3333333333333333333333333333333333333333"; process.env.NEXT_PUBLIC_PONDER_URL = "https://ponder.example"; const dbModule = await import("~~/lib/db"); @@ -100,6 +102,11 @@ test("getContentShareDataForParam redacts gated private-context content", async } else { process.env.DATABASE_URL = originalDatabaseUrl; } + if (originalFrontendCode === undefined) { + delete process.env.NEXT_PUBLIC_FRONTEND_CODE; + } else { + process.env.NEXT_PUBLIC_FRONTEND_CODE = originalFrontendCode; + } if (originalPonderUrl === undefined) { delete process.env.NEXT_PUBLIC_PONDER_URL; } else { diff --git a/scripts/readiness-workflows.test.mjs b/scripts/readiness-workflows.test.mjs index 3d69d0b10..69482686c 100644 --- a/scripts/readiness-workflows.test.mjs +++ b/scripts/readiness-workflows.test.mjs @@ -88,6 +88,10 @@ test("Base Sepolia readiness remains an active push, PR, scheduled, and manual g workflow, /BASE_SEPOLIA_NEXT_ENV_FILE: docs\/testing\/base-sepolia-next-env\.fixture/, ); + assert.equal( + readWorkflow("docs/testing/base-sepolia-next-env.fixture").trim(), + "NEXT_PUBLIC_TARGET_NETWORKS=84532", + ); const offlineJob = workflowJobBlock(workflow, "readiness"); const liveJob = workflowJobBlock(workflow, "live-readiness");