Skip to content

Latest commit

 

History

History
830 lines (640 loc) · 31.9 KB

File metadata and controls

830 lines (640 loc) · 31.9 KB

Verified-Claim Phase 1 — Generalized Entry & Dispatch — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the ViralPost-only ClaimLauncher/ClaimVerifier entry with a generalized, reason-tagged entry that carries a free-text claim + sources, migrate ViralPost onto it as reason = ViralPost, and re-prove the existing live mint on the new rails.

Architecture: One general submit(reason, claim, sources, …) on Base; a reason-agnostic claimId; a single reason-tagged payload bridged to ClaimVerifier, which decodes and dispatches on reason to a per-reason handler (only ViralPost exists after this plan). The verdict payload (GL→EVM) is unchanged, so VerdictReceiver and the relayer need no changes.

Tech Stack: Solidity (Foundry), GenLayer Python intelligent contracts (genlayer-test direct mode), Next.js/wagmi web app, GenLayer CLI for deploy, ethers/Supabase relayer (config only).

Reference: docs/design/2026-06-03-verified-claim-consensus.md (esp. §5 generalized entry, §8 dedup/claimId).


Key design decisions locked for this plan

  • sources is a single newline-separated string, not a Solidity string[]. This keeps the cross-chain ABI to simple types only (gl.evm.decode is proven for str, not for dynamic string arrays), and avoids array handling on both sides. The web app joins URLs with \n.
  • claimId = keccak256(abi.encode(uint8 reason, string canonicalPrimary)) where canonicalPrimary = the first source line, or the claim itself when there are no sources. _canonical strips the query (?…) and any trailing /. This is the §8 "one token per (reason, source)" key.
  • Request payload tuple (Base → GL): (bytes32 claimId, uint8 reason, string claim, string sources, string nameOverride, string symbolOverride, string descOverride, address launcher, uint256 graduationThreshold).
  • Verdict payload (GL → Base) is UNCHANGED: (bytes32 claimId, uint8 status, string name, string symbol, string description, string claimRef).
  • Reason enum order (shared Solidity ⇄ Python): 0 ViralPost, 1 RealWorldEvent, 2 GitHubRelease, 3 SportsResult, 4 OnchainMetric, 5 PriceTarget. Only ViralPost is handled in this plan; the rest decode but return FAILED_TRANSIENT ("not yet implemented") so an unsupported reason never mints and never hard-fails.
  • ViralPost's content (the post URL) travels in claim; sources is empty for ViralPost (the post URL is its own source).

File structure

File Responsibility Action
packages/contracts-evm/src/ClaimLauncher.sol generalized entry, claimId, payload, verdict callback Modify (significant)
packages/contracts-evm/test/ClaimLauncher.t.sol Foundry tests for the generalized launcher Modify (rewrite cases)
contracts-genlayer/ClaimVerifier.py decode generalized request, dispatch on reason, ViralPost handler Modify
contracts-genlayer/tests/direct/test_claim_verifier.py direct tests for decode/dispatch/ViralPost Modify
apps/web/src/hooks/useLaunchClaim.ts call new submit Modify
apps/web/src/lib/launch.ts map wizard state → new args; join sources Modify
apps/web/src/lib/contracts.ts new launcher ABI + address Modify
packages/contracts-evm/deployments/base-sepolia.json record redeploy Modify

No changes: VerdictReceiver.sol, TokenFactory.sol, TokenpostToken.sol, the relayer code (supabase/functions/relay/index.ts), the deploy script constructor (DeployClaimLoop.s.sol).


Part A — Generalized ClaimLauncher.sol

Task 1: Reason enum, generalized Claim struct, and _canonical helper

Files:

  • Modify: packages/contracts-evm/src/ClaimLauncher.sol

  • Test: packages/contracts-evm/test/ClaimLauncher.t.sol

  • Step 1: Write the failing test (append to ClaimLauncherTest)

function test_claimId_isReasonPlusCanonicalSource() public view {
    // ViralPost: no sources → primary = claim (the post URL), canonicalized (query+slash stripped)
    bytes32 expected = keccak256(abi.encode(uint8(0), "https://x.com/jack/status/1934567890"));
    assertEq(
        launcher.claimIdForClaim(0, "https://x.com/jack/status/1934567890", ""),
        expected
    );
    assertEq(
        launcher.claimIdForClaim(0, "https://x.com/jack/status/1934567890?s=20", ""),
        expected
    );
    assertEq(
        launcher.claimIdForClaim(0, "https://x.com/jack/status/1934567890/", ""),
        expected
    );
}

function test_claimId_usesFirstSourceLineWhenPresent() public view {
    // RealWorldEvent with a source → primary = first source line, canonicalized
    bytes32 expected = keccak256(abi.encode(uint8(1), "https://docs.base.org/airdrop"));
    assertEq(
        launcher.claimIdForClaim(1, "Base teased an airdrop", "https://docs.base.org/airdrop?utm=x\nhttps://news.site/x"),
        expected
    );
}
  • Step 2: Run test to verify it fails

Run: cd packages/contracts-evm && forge test --match-test test_claimId_ -vv Expected: FAIL — claimIdForClaim not defined.

  • Step 3: Implement enum, struct, helpers in ClaimLauncher.sol

Replace the Claim struct and add the Reason enum + helpers. The struct gains reason, claim, sources; tweetUrl is removed.

enum Reason { ViralPost, RealWorldEvent, GitHubRelease, SportsResult, OnchainMetric, PriceTarget }

struct Claim {
    Status status;
    address launcher;
    uint256 graduationThreshold;
    uint8 retryCount;
    uint8 reason;
    string claim;
    string sources;          // newline-separated URLs ("" allowed)
    string nameOverride;
    string symbolOverride;
    string descOverride;
}

Add these pure helpers (replace the old _extractTweetId/claimIdFor):

/// @notice Public helper so off-chain callers and tests can derive the claimId.
function claimIdForClaim(uint8 reason, string calldata claim, string calldata sources)
    external
    pure
    returns (bytes32)
{
    return keccak256(abi.encode(reason, _canonical(_primary(claim, sources))));
}

/// @dev first line of `sources`, or `claim` when `sources` is empty.
function _primary(string memory claim, string memory sources) internal pure returns (string memory) {
    bytes memory s = bytes(sources);
    if (s.length == 0) return claim;
    uint256 end = s.length;
    for (uint256 i = 0; i < s.length; i++) {
        if (s[i] == "\n") { end = i; break; }
    }
    bytes memory out = new bytes(end);
    for (uint256 i = 0; i < end; i++) out[i] = s[i];
    return string(out);
}

/// @dev strip query (from first '?') and any trailing '/'.
function _canonical(string memory url) internal pure returns (string memory) {
    bytes memory b = bytes(url);
    uint256 end = b.length;
    for (uint256 i = 0; i < b.length; i++) {
        if (b[i] == "?") { end = i; break; }
    }
    while (end > 0 && b[end - 1] == "/") end--;
    bytes memory out = new bytes(end);
    for (uint256 i = 0; i < end; i++) out[i] = b[i];
    return string(out);
}
  • Step 4: Run test to verify it passes

Run: forge test --match-test test_claimId_ -vv Expected: PASS (2 tests).

  • Step 5: Commit
git add packages/contracts-evm/src/ClaimLauncher.sol packages/contracts-evm/test/ClaimLauncher.t.sol
git commit -m "feat(launcher): reason enum, generalized claim struct, claimId from (reason, source)"

Task 2: Generalized submit + reason-tagged payload

Files:

  • Modify: packages/contracts-evm/src/ClaimLauncher.sol

  • Test: packages/contracts-evm/test/ClaimLauncher.t.sol

  • Step 1: Replace the _submit() test helper and write the submit test

Replace the existing _submit() helper (it used submitClaim) and add the test:

function _submit() internal returns (bytes32 claimId) {
    string memory url = "https://x.com/jack/status/1934567890";
    claimId = launcher.claimIdForClaim(0, url, "");
    vm.prank(user);
    launcher.submit{value: 1 ether}(0, url, "", "", "", "", 1 ether);
}

function test_submit_setsPending_andSends_andRefunds() public {
    uint256 balBefore = user.balance;
    bytes32 claimId = _submit();

    (ClaimLauncher.Status status, address l, uint256 thr, uint8 rc, uint8 reason,,,,,) =
        launcher.claims(claimId);
    assertEq(uint8(status), uint8(ClaimLauncher.Status.Pending));
    assertEq(l, user);
    assertEq(thr, 1 ether);
    assertEq(rc, 0);
    assertEq(reason, 0);

    assertEq(sender.sendCount(), 1);
    assertEq(sender.lastTarget(), verifier);
    assertEq(sender.lastValue(), 0.001 ether);
    assertEq(user.balance, balBefore - 0.001 ether);
}

function test_submit_rejectsEmptyClaim() public {
    vm.prank(user);
    vm.expectRevert(ClaimLauncher.InvalidClaim.selector);
    launcher.submit{value: 1 ether}(0, "", "", "", "", "", 1 ether);
}

function test_submit_rejectsDuplicateActive() public {
    _submit();
    vm.prank(user);
    vm.expectRevert(ClaimLauncher.ClaimActiveOrDone.selector);
    launcher.submit{value: 1 ether}(0, "https://x.com/jack/status/1934567890", "", "", "", "", 1 ether);
}

function test_submit_insufficientFeeReverts() public {
    sender.setFee(2 ether);
    vm.prank(user);
    vm.expectRevert(ClaimLauncher.InsufficientFee.selector);
    launcher.submit{value: 1 ether}(0, "https://x.com/jack/status/1934567890", "", "", "", "", 1 ether);
}
  • Step 2: Run to verify it fails

Run: forge test --match-test test_submit_ -vv Expected: FAIL — submit / InvalidClaim not defined.

  • Step 3: Implement submit, retryClaim, _send, errors

Replace submitClaim, the old _send, and the InvalidUrl error. New code:

error InvalidClaim();

function submit(
    uint8 reason,
    string calldata claim,
    string calldata sources,
    string calldata nameOverride,
    string calldata symbolOverride,
    string calldata descOverride,
    uint256 graduationThreshold
) external payable returns (bytes32 claimId) {
    if (bytes(claim).length == 0) revert InvalidClaim();
    claimId = keccak256(abi.encode(reason, _canonical(_primary(claim, sources))));

    Claim storage c = claims[claimId];
    if (c.status != Status.None && c.status != Status.Retryable) revert ClaimActiveOrDone();

    c.status = Status.Pending;
    c.launcher = msg.sender;
    c.graduationThreshold = graduationThreshold;
    c.reason = reason;
    c.claim = claim;
    c.sources = sources;
    c.nameOverride = nameOverride;
    c.symbolOverride = symbolOverride;
    c.descOverride = descOverride;

    _send(claimId, c);
    emit ClaimSubmitted(claimId, msg.sender, c.retryCount);
}

function _send(bytes32 claimId, Claim storage c) internal {
    bytes memory data = abi.encode(
        claimId, c.reason, c.claim, c.sources, c.nameOverride, c.symbolOverride, c.descOverride,
        c.launcher, c.graduationThreshold
    );
    (uint256 fee,) = bridgeSender.quoteSendToGenLayer(claimVerifier, data, lzOptions);
    if (msg.value < fee) revert InsufficientFee();
    bridgeSender.sendToGenLayer{value: fee}(claimVerifier, data, lzOptions);
    uint256 refund = msg.value - fee;
    if (refund > 0) {
        (bool ok,) = payable(msg.sender).call{value: refund}("");
        require(ok, "refund failed");
    }
}

Also remove the now-unused REASON constant and update _handleFailure to read c.reason for the mint (see Task 3). retryClaim stays as-is (it calls _send).

  • Step 4: Run to verify it passes

Run: forge test --match-test test_submit_ -vv Expected: PASS (4 tests).

  • Step 5: Commit
git add packages/contracts-evm/src/ClaimLauncher.sol packages/contracts-evm/test/ClaimLauncher.t.sol
git commit -m "feat(launcher): generalized submit + reason-tagged payload"

Task 3: Verdict callback mints with per-claim reason string

Files:

  • Modify: packages/contracts-evm/src/ClaimLauncher.sol

  • Test: packages/contracts-evm/test/ClaimLauncher.t.sol

  • Step 1: Update the VERIFIED→mint test to assert the reason string

function test_verified_mintsToken_withReasonString() public {
    bytes32 claimId = _submit(); // reason 0 = ViralPost
    bytes memory v =
        _verdict(claimId, 0, "Jack Signal", "JS", "A verified post about X.", "https://x.com/jack/status/1934567890");

    vm.prank(bridgeReceiver);
    launcher.processBridgeMessage(uint32(40305), verifier, v);

    address token = factory.tokenForClaim(claimId);
    assertTrue(token != address(0));
    assertEq(TokenpostToken(payable(token)).reason(), "ViralPost");

    (ClaimLauncher.Status status,,,,,,,,,) = launcher.claims(claimId);
    assertEq(uint8(status), uint8(ClaimLauncher.Status.Launched));
}
  • Step 2: Run to verify it fails

Run: forge test --match-test test_verified_ -vv Expected: FAIL (compile error — REASON removed, or wrong reason string).

  • Step 3: Implement reason→string mapping in processBridgeMessage

In processBridgeMessage, where it currently passes reason: REASON, replace with a lookup from the stored claim's reason byte:

function _reasonString(uint8 r) internal pure returns (string memory) {
    if (r == 0) return "ViralPost";
    if (r == 1) return "RealWorldEvent";
    if (r == 2) return "GitHubRelease";
    if (r == 3) return "SportsResult";
    if (r == 4) return "OnchainMetric";
    if (r == 5) return "PriceTarget";
    return "Unknown";
}

And in the status == 0 branch of processBridgeMessage, set reason: _reasonString(c.reason) in the CreateParams.

  • Step 4: Run the full launcher suite

Run: forge test --match-contract ClaimLauncherTest -vv Expected: PASS — update any remaining old cases (test_submitClaim_*, test_claimId_isKeccakOfTrailingDigits, test_claimId_rejectsNonNumericTail) by deleting them; they referenced the removed submitClaim/claimIdFor. The retained idempotency/gating tests (Task 7/8 block) compile unchanged because the struct tuple now has 10 members — fix their destructuring (Status,,,,,,,) → add the extra commas to match 10 fields.

  • Step 5: Commit
git add packages/contracts-evm/src/ClaimLauncher.sol packages/contracts-evm/test/ClaimLauncher.t.sol
git commit -m "feat(launcher): mint with per-claim reason string; drop ViralPost hardcoding"

Task 4: Full Foundry suite green

  • Step 1: Run the entire suite

Run: cd packages/contracts-evm && forge test -vv Expected: PASS — all contracts (was 48 tests; count changes with rewritten launcher cases). Fix any struct-tuple destructuring mismatches in ClaimLauncher.t.sol revealed here (every launcher.claims(id) now returns 10 fields).

  • Step 2: Commit if any test files changed
git add packages/contracts-evm/test
git commit -m "test(launcher): align all cases with 10-field Claim struct"

Part B — Generalized ClaimVerifier.py

Task 5: Decode the generalized request

Files:

  • Modify: contracts-genlayer/ClaimVerifier.py

  • Test: contracts-genlayer/tests/direct/test_claim_verifier.py

  • Step 1: Regenerate the request fixture with cast

The old REQUEST_HEX encodes the tweet-shaped tuple. Generate a new one for the generalized tuple (claimId, reason=0, claim=the URL, sources="", overrides "", launcher, grad):

Run:

cast abi-encode "f(bytes32,uint8,string,string,string,string,string,address,uint256)" \
  0x$(cast keccak $(cast abi-encode "g(uint8,string)" 0 "https://x.com/jack/status/1934567890" | sed 's/^0x//')) \
  0 "https://x.com/jack/status/1934567890" "" "" "" "" \
  0x000000000000000000000000000000000000a11c 1000000000000000000

Record the output as REQUEST_HEX (strip the leading 0x). Also recompute CLAIM_ID = cast keccak $(cast abi-encode "g(uint8,string)" 0 "https://x.com/jack/status/1934567890").

  • Step 2: Write the failing decode test (replace test_decodes_request_layout)
def test_decodes_generalized_request(direct_vm, direct_deploy, direct_alice):
    c = _deploy(direct_deploy)
    direct_vm.sender = direct_alice
    d = c.decode_request(REQUEST)
    assert d["claim_id"].lower() == CLAIM_ID
    assert d["reason"] == 0
    assert d["claim"] == URL
    assert d["sources"] == ""
    assert d["launcher"].lower() == "0x000000000000000000000000000000000000a11c"
    assert d["graduation_threshold"] == "1000000000000000000"
  • Step 3: Run to verify it fails

Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py::test_decodes_generalized_request -v Expected: FAIL — decode_request returns the old shape / reason KeyError.

  • Step 4: Update the request type + decode_request

In ClaimVerifier.py replace _REQUEST_T and decode_request:

_REQUEST_T = tuple[gl.evm.InplaceTuple, gl.evm.bytes32, u8, str, str, str, str, str, Address, u256]
@gl.public.view
def decode_request(self, message: bytes) -> dict:
    claim_id, reason, claim, sources, name_ovr, sym_ovr, desc_ovr, launcher, grad = \
        gl.evm.decode(_REQUEST_T, message)
    return {
        "claim_id": "0x" + bytes(claim_id).hex(),
        "reason": int(reason),
        "claim": claim,
        "sources": sources,
        "name_override": name_ovr,
        "symbol_override": sym_ovr,
        "desc_override": desc_ovr,
        "launcher": str(launcher),
        "graduation_threshold": str(int(grad)),
    }
  • Step 5: Run to verify it passes

Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py::test_decodes_generalized_request -v Expected: PASS.

  • Step 6: Commit
git add contracts-genlayer/ClaimVerifier.py contracts-genlayer/tests/direct/test_claim_verifier.py
git commit -m "feat(verifier): decode generalized reason-tagged request"

Task 6: Dispatch on reason; ViralPost handler reads claim; unsupported → transient

Files:

  • Modify: contracts-genlayer/ClaimVerifier.py

  • Test: contracts-genlayer/tests/direct/test_claim_verifier.py

  • Step 1: Write failing tests

def test_phase1_viralpost_dispatch_is_verified_stub(direct_vm, direct_deploy, direct_alice):
    c = _deploy(direct_deploy)            # phase2 False
    direct_vm.sender = direct_alice
    d = c.decode_verdict(c.preview_verdict(REQUEST))
    assert d["status"] == 0               # VERIFIED stub
    assert d["claim_ref"] == URL

def test_unsupported_reason_is_transient(direct_vm, direct_deploy, direct_alice):
    c = _deploy(direct_deploy)
    direct_vm.sender = direct_alice
    # reason 2 (GitHubRelease) — not implemented in Phase 1
    req = _request(reason=2, claim="org/repo v1.2.3", sources="https://github.com/org/repo/releases/tag/v1.2.3")
    d = c.decode_verdict(c.preview_verdict(req))
    assert d["status"] == 2               # FAILED_TRANSIENT (not-yet-implemented; never mints, never terminal)

Add a _request helper at the top of the test file that builds the generalized payload via the contract's own encoder so tests don't hand-encode:

def _request(reason, claim, sources="", name="", symbol="", desc="",
             launcher=LAUNCHER, grad=10**18, claim_id=None):
    # Encode through cast-compatible layout using the verifier's gl.evm at deploy time is overkill;
    # build with eth_abi to match Solidity abi.encode.
    from eth_abi import encode as _abi_encode
    from eth_utils import keccak as _keccak
    def _canon(u):
        u = u.split("?")[0]
        return u.rstrip("/")
    primary = (sources.split("\n")[0] if sources else claim)
    cid = claim_id or _keccak(_abi_encode(["uint8", "string"], [reason, _canon(primary)]))
    data = _abi_encode(
        ["bytes32", "uint8", "string", "string", "string", "string", "string", "address", "uint256"],
        [cid, reason, claim, sources, name, symbol, desc, launcher, grad],
    )
    return data

(pip install eth-abi eth-utils into .venv if missing.)

  • Step 2: Run to verify they fail

Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py -k "dispatch or unsupported" -v Expected: FAIL — dispatch not implemented.

  • Step 3: Implement dispatch in _build_verdict / _decide

Replace _build_verdict and _decide so they read the generalized fields and route on reason. Keep the existing ViralPost logic (_run_verification, _build_token_identity, _describe) and the Phase-1 stub, but drive them from claim (the post URL) instead of tweet_url. Constants:

REASON_VIRALPOST = 0
def _build_verdict(self, message: bytes) -> bytes:
    claim_id, reason, claim, sources, name_ovr, sym_ovr, desc_ovr, launcher, grad = \
        gl.evm.decode(_REQUEST_T, message)
    status, name, symbol, description = self._decide(
        bytes(claim_id), int(reason), claim, sources, name_ovr, sym_ovr, desc_ovr
    )
    return gl.evm.encode(_VERDICT_T, (claim_id, status, name, symbol, description, claim))

def _decide(self, claim_id: bytes, reason: int, claim: str, sources: str,
            name_ovr: str, sym_ovr: str, desc_ovr: str):
    if reason == REASON_VIRALPOST:
        return self._decide_viralpost(claim_id, claim, name_ovr, sym_ovr, desc_ovr)
    # Reasons not implemented in this phase: reply TRANSIENT so the claim is retryable,
    # never minting and never terminally rejected.
    return (FAILED_TRANSIENT, "", "", "")

Rename the old _decide body to _decide_viralpost(self, claim_id, claim, name_ovr, sym_ovr, desc_ovr) and inside it use claim wherever it previously used tweet_url (the post URL is now claim). Update the Phase-2 dedup guard formula to match the new on-chain claimId:

# claimId must equal keccak256(abi.encode(uint8 reason, canonical(primary)))
expected_cid = Keccak256(gl.evm.encode(tuple[gl.evm.InplaceTuple, u8, str],
                                       (REASON_VIRALPOST, _canonical(claim))).__bytes__()).digest()
if claim_id != expected_cid:
    return (REJECTED_EXTERNAL, "", "", "")

Add a _canonical pure helper mirroring the Solidity one:

def _canonical(url: str) -> str:
    return url.split("?")[0].rstrip("/")
  • Step 4: Run to verify they pass

Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py -k "dispatch or unsupported" -v Expected: PASS.

  • Step 5: Update the Phase-2 ViralPost tests to the generalized payload

Rewrite test_phase2_* to build requests via _request(reason=0, claim=URL) and keep their web/LLM injections. Re-point WRONG_CID/WITH_DESC through _request (e.g. _request(reason=0, claim=URL, claim_id=bytes(32)) for the mismatch case).

Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py -v Expected: PASS (all).

  • Step 6: Lint + commit

Run: ~/.local/bin/genvm-lint check contracts-genlayer/ClaimVerifier.py Expected: no errors.

git add contracts-genlayer/ClaimVerifier.py contracts-genlayer/tests/direct/test_claim_verifier.py
git commit -m "feat(verifier): reason dispatch; ViralPost reads claim; unsupported→transient"

Part C — Web app calls the new entry

Task 7: useLaunchClaim + launch.ts send the generalized args

Files:

  • Modify: apps/web/src/hooks/useLaunchClaim.ts, apps/web/src/lib/launch.ts, apps/web/src/lib/contracts.ts

  • Step 1: Write/adjust the unit test for arg mapping

In apps/web/src/lib/__tests__/ add launch.test.ts (or extend existing):

import { buildLaunchArgs } from "@/lib/launch";

it("maps viralpost wizard state to generalized args", () => {
  const a = buildLaunchArgs({
    reason: "ViralPost", claim: "https://x.com/jack/status/1934567890",
    sources: "", name: "", symbol: "", description: "", graduationThresholdEth: "5",
  });
  expect(a.reason).toBe(0);
  expect(a.claim).toBe("https://x.com/jack/status/1934567890");
  expect(a.sources).toBe("");
});

it("joins multiple source URLs with newlines", () => {
  const a = buildLaunchArgs({
    reason: "RealWorldEvent", claim: "Base teased an airdrop",
    sources: "https://docs.base.org/airdrop\n https://news.site/x ",
    name: "", symbol: "", description: "", graduationThresholdEth: "5",
  });
  expect(a.reason).toBe(1);
  expect(a.sources).toBe("https://docs.base.org/airdrop\nhttps://news.site/x");
});
  • Step 2: Run to verify it fails

Run: cd apps/web && pnpm vitest run src/lib/__tests__/launch.test.ts Expected: FAIL — reason/claim/sources not on LaunchArgs.

  • Step 3: Update LaunchArgs, buildLaunchArgs, WizardState, the hook, and the ABI

launch.ts — add a reason map and new shape:

export const REASON_INDEX: Record<string, number> = {
  ViralPost: 0, RealWorldEvent: 1, GitHubRelease: 2, SportsResult: 3, OnchainMetric: 4, PriceTarget: 5,
};

export interface WizardState {
  reason: string;
  claim: string;       // for ViralPost: the post URL
  sources: string;     // newline- or line-separated URLs (textarea)
  name: string; symbol: string; description: string; graduationThresholdEth: string;
}

export function buildLaunchArgs(s: WizardState): LaunchArgs {
  const sources = s.sources.split("\n").map((l) => l.trim()).filter(Boolean).join("\n");
  return {
    reason: REASON_INDEX[s.reason] ?? 0,
    claim: s.claim.trim(),
    sources,
    name: s.name.trim(),
    symbol: normalizeSymbol(s.symbol),
    description: s.description.trim(),
    graduationThresholdEth: s.graduationThresholdEth,
  };
}

useLaunchClaim.ts — new LaunchArgs + call submit:

export interface LaunchArgs {
  reason: number; claim: string; sources: string;
  name?: string; symbol?: string; description?: string; graduationThresholdEth: string;
}

const submit = (a: LaunchArgs) =>
  writeContractAsync({
    address: CONTRACTS.claimLauncher,
    abi: claimLauncherAbi,
    functionName: "submit",
    args: [a.reason, a.claim, a.sources, a.name ?? "", a.symbol ?? "", a.description ?? "", parseEther(a.graduationThresholdEth)],
    value: LZ_FEE_BUFFER,
  });

In contracts.ts, update claimLauncherAbi to include the new submit(uint8,string,string,string,string,string,uint256) signature (and keep retryClaim, processBridgeMessage, claims, claimIdForClaim). The address is updated in Task 9.

  • Step 4: Run to verify it passes

Run: pnpm vitest run src/lib/__tests__/launch.test.ts Expected: PASS.

  • Step 5: Update any callers / wizard components that pass the old tweetUrl field

Run: cd apps/web && pnpm tsc --noEmit Expected: PASS — fix LaunchWizard.tsx and any component still referencing WizardState.tweetUrl to use claim/sources (for the ViralPost flow, bind the post-URL input to claim).

  • Step 6: Commit
git add apps/web/src
git commit -m "feat(web): call generalized submit(reason, claim, sources, ...)"

Part D — Deploy & re-wire the live loop

These tasks mutate live Base Sepolia + GenLayer studionet state. Run them in order; do not batch (avoid the nonce race noted in prior sessions). Read the GenLayer CLI gotchas in genlayer-cli skill before Task 8.

Task 8: Redeploy the contracts

  • Step 1: Deploy fresh launcher + factory (the deploy script constructor is unchanged)

Run (from repo root, env per the script header — TREASURY, ADAPTER, BASE_BRIDGE_SENDER, BASE_BRIDGE_RECEIVER, LZ_OPTIONS):

cd packages/contracts-evm
forge script script/DeployClaimLoop.s.sol --rpc-url "$BASE_SEPOLIA_RPC" --broadcast

Record the new TokenFactory and ClaimLauncher addresses from the console output.

  • Step 2: Redeploy ClaimVerifier.py to studionet

Run:

genlayer network set studionet
genlayer deploy --contract contracts-genlayer/ClaimVerifier.py \
  --args 0xce87655D60dCa6CA76183DEDc8582766e5DE4e57 \
         0xcfBD25E80e075e7F34E79C7DaCbA2EAbD6Aca22f \
         <NEW_CLAIM_LAUNCHER>

Verify: genlayer receipt <tx> --stderr (clean) and genlayer schema <addr> (resolves). Record the new verifier address. Do not pass --rpc (breaks tx polling for built-in nets).

  • Step 3: Commit the new addresses

Update packages/contracts-evm/deployments/base-sepolia.json claimLoop block (factory, launcher, verifier). Commit:

git add packages/contracts-evm/deployments/base-sepolia.json
git commit -m "chore(deploy): record generalized launcher/factory/verifier addresses"

Task 9: Wire everything together (one tx at a time)

  • Step 1: Point the launcher at the verifier

Run: cast send <NEW_CLAIM_LAUNCHER> "setClaimVerifier(address)" <NEW_VERIFIER> --rpc-url "$BASE_SEPOLIA_RPC" --private-key "$PK" Verify: cast call <NEW_CLAIM_LAUNCHER> "claimVerifier()(address)" --rpc-url "$BASE_SEPOLIA_RPC" == new verifier.

  • Step 2: Gate the new factory to the new launcher (deploy script already set MintMode+caller on the fresh factory in Task 8, so verify rather than re-set)

Verify: cast call <NEW_FACTORY> "authorizedCaller()(address)" --rpc-url "$BASE_SEPOLIA_RPC" == new launcher.

  • Step 3: Confirm the GL verifier config points at the new launcher

The Task 8 deploy passed the launcher as a constructor arg; confirm: genlayer call <NEW_VERIFIER> get_config (or schema inspection) shows claim_launcher == new launcher (lowercased). If not, genlayer write <NEW_VERIFIER> set_config <bridge_sender> <new_launcher>.

  • Step 4: Update the relayer env + web app

  • Supabase secret: CLAIM_LAUNCHER = <NEW_CLAIM_LAUNCHER> (so the relayer filters GL outbox to the new launcher). Set via the Management API as in the prior session; redeploy the function.

  • apps/web/src/lib/contracts.ts: set claimLauncher and factory to the new addresses.

Commit web change:

git add apps/web/src/lib/contracts.ts
git commit -m "chore(web): repoint to generalized launcher + factory"

Part E — Re-prove the live loop (the real verification)

Task 10: Live ViralPost mint on the new rails

  • Step 1: Submit a real ViralPost claim against the new launcher

Use a fresh, public X post URL. Either drive tokenpost.vercel.app (after the web redeploy) or:

cast send <NEW_CLAIM_LAUNCHER> \
  "submit(uint8,string,string,string,string,string,uint256)" \
  0 "<POST_URL>" "" "" "" "" 1000000000000000000 \
  --value 0.02ether --rpc-url "$BASE_SEPOLIA_RPC" --private-key "$PK"

Record claimId = keccak256(abi.encode(uint8 0, canonical(POST_URL))).

  • Step 2: Watch it through to mint

Poll cast call <NEW_CLAIM_LAUNCHER> "claims(bytes32)" <claimId> until status → Launched (2), then cast call <NEW_FACTORY> "tokenForClaim(bytes32)(address)" <claimId>. Expected: a non-zero token address within ~10–12 min (forward LZ hop dominates; relayer delivers the verdict directly).

  • Step 3: Confirm the minted identity

cast call <token> "name()(string)", symbol(), description(), reason()reason() must read "ViralPost". This proves the generalized rails mint exactly as before.

  • Step 4: Final commit / record

Append the proven claimId + token address to deployments/base-sepolia.json and commit:

git add packages/contracts-evm/deployments/base-sepolia.json
git commit -m "chore(deploy): record generalized-rails ViralPost mint proof"
  • Step 5: Push
git push

Self-review against the design

  • §5 generalized entry → Tasks 1–3 (submit/claimId/payload), Task 5–6 (verifier decode/dispatch). ✓
  • §8 dedup (reason, source) → Task 1 _canonical/_primary, Task 2 claimId, Task 6 guard. ✓
  • Verdict payload unchanged / relayer untouched → confirmed in Key Decisions; Task 9 only sets the launcher filter env. ✓
  • ViralPost migrated, still mints live → Part E. ✓
  • Reason enum shared Solidity⇄Python → Task 1 (enum), Task 3 (_reasonString), Task 6 (REASON_VIRALPOST), Task 7 (REASON_INDEX) — all use the same 0–5 order. ✓
  • Unsupported reasons never mint / never hard-fail → Task 6 returns FAILED_TRANSIENT. ✓

Out of scope (own later plans): the subjective claim-truth pipeline (gate A, independent search, comparative consensus), the search proxy (§9), and the objective family (§7). This plan deliberately ships only the rails + ViralPost migration so each piece is independently provable.