From a6c726f4a0bded4e37daa6340eea65e6d5491d38 Mon Sep 17 00:00:00 2001 From: ChrisWorkBot Date: Fri, 29 May 2026 12:46:00 -0700 Subject: [PATCH] docs: make Lit Action examples standalone --- docs/lit-actions/examples.mdx | 8 +- docs/lit-actions/patterns.mdx | 78 +++++++++++++++++++ examples/compliance-transfer-gate/README.md | 3 +- examples/cross-chain-token/README.md | 6 +- examples/cross-chain-token/scripts/_env.js | 6 +- examples/cross-chain-token/scripts/setup.js | 2 +- examples/multi-source-price-oracle/README.md | 7 +- .../action/priceOracle.js | 5 +- .../multi-source-price-oracle/scripts/_env.js | 3 +- examples/prediction-market-oracle/README.md | 5 +- .../action/marketOracle.js | 7 +- .../prediction-market-oracle/scripts/_env.js | 3 +- 12 files changed, 102 insertions(+), 31 deletions(-) diff --git a/docs/lit-actions/examples.mdx b/docs/lit-actions/examples.mdx index 7cb93c2f..f2b95cf7 100644 --- a/docs/lit-actions/examples.mdx +++ b/docs/lit-actions/examples.mdx @@ -40,7 +40,7 @@ async function main({ pkpId, secret }) { } ``` -Store the returned `ciphertext` anywhere — IPFS, a smart contract, a database — and retrieve the plaintext only when needed using the Decrypt action below. +Store the returned `ciphertext` anywhere — IPFS, a smart contract, a database — and retrieve the plaintext only when needed from an action that is permitted to use the same PKP. --- @@ -206,7 +206,7 @@ The Chainalysis oracle is free and keyless — it's just a smart contract at `0x The signature uses `Lit.Actions.getLitActionPrivateKey()` — an identity derived from the action's IPFS CID. See [Action-Identity Signing](./patterns#action-identity-signing--immutable-proofs). -The trust anchor is a hardcoded hostname whitelist. Anyone calling the action supplies `screeningRpcUrl` via `js_params`, so a caller-supplied `chainId` check would just be theater (pair a malicious RPC with a matching chain id, gate passes). Instead the action checks the URL's hostname against `eth-mainnet.g.alchemy.com` — TLS guarantees we're actually talking to Alchemy. Trust shifts to "Alchemy is honest about Ethereum mainnet." +The trust anchor is a hardcoded hostname whitelist. Anyone calling the action supplies `screeningRpcUrl` via `js_params`, so a caller-supplied `chainId` check would just be theater (pair a malicious RPC with a matching chain id, gate passes). Instead the action checks the URL's hostname against `eth-mainnet.g.alchemy.com` — TLS guarantees we're actually talking to Alchemy. Trust shifts to "Alchemy is honest about Ethereum mainnet." See [Hostname-Pinned RPC Trust Anchors](./patterns#hostname-pinned-rpc-trust-anchors). ```javascript // js_params: { @@ -384,7 +384,7 @@ To move the median an attacker needs to influence two of three sources at the sa ## 10. Resolve a Prediction Market by AI Consensus -Poll multiple LLM providers in parallel with the same yes/no question and only sign the resolution when every model agrees. Same multi-source idea as example 9, but the parallel sources are AI models instead of price feeds — and the aggregation is *strict agreement* rather than a median, because the output is categorical YES/NO/UNCLEAR. +Poll multiple LLM providers in parallel with the same yes/no question and only sign the resolution when every model agrees. This uses the [Multi-Source Consensus](./patterns#multi-source-consensus) pattern with AI providers as the parallel sources. The aggregation is *strict agreement* rather than a median, because the output is categorical YES/NO/UNCLEAR. **Perplexity Sonar is required** because its built-in web search lets it answer questions about events that happened after a frontier model's training cutoff. **OpenAI and Anthropic are optional second opinions** — independent training corpora mean a confident-but-wrong frontier answer is unlikely to be confirmed by another frontier model. Configuring all three gives you 3-of-3 agreement before anything reaches the chain. @@ -464,7 +464,7 @@ Honest caveats: frontier models share training corpora, so a wrong answer that's Deploy the same `BridgeToken` contract on two chains. The holder calls `burn` on chain A, which destroys the local supply and emits `BurnInitiated(from, recipient, amount, destChainId, nonce)`. A Lit Action reads that event via `eth_getTransactionReceipt` against a hostname-whitelisted RPC, validates it, and signs a mint authorization for chain B. Anyone can submit the mint — the signature is the authorization, not the caller. -Same content-addressed trust property as the compliance gate: the signer key comes from `Lit.Actions.getLitActionPrivateKey()`, which derives the key from the action's IPFS CID. Edit the action by a byte and the signer changes, and every deployed `BridgeToken` refuses the modified action. The trust collapses from "trust this federation of relayers" to "trust this exact piece of code." +The signer key comes from `Lit.Actions.getLitActionPrivateKey()`, which derives the key from the action's IPFS CID. Edit the action by a byte and the signer changes, and every deployed `BridgeToken` refuses the modified action. The trust collapses from "trust this federation of relayers" to "trust this exact piece of code." See [Action-Identity Signing](./patterns#action-identity-signing--immutable-proofs) and [Hostname-Pinned RPC Trust Anchors](./patterns#hostname-pinned-rpc-trust-anchors). ```javascript // js_params: { diff --git a/docs/lit-actions/patterns.mdx b/docs/lit-actions/patterns.mdx index 4a4c6824..ff97f48d 100644 --- a/docs/lit-actions/patterns.mdx +++ b/docs/lit-actions/patterns.mdx @@ -3,6 +3,84 @@ title: "Patterns" description: "Common design patterns for Lit Actions: writing gating logic in plain JavaScript, using action-identity signing to produce immutable proofs, and structuring encrypt/decrypt flows around PKP wallets." --- +## Hostname-Pinned RPC Trust Anchors + +When a Lit Action reads chain state through a caller-supplied RPC URL, do not trust a caller-supplied `chainId` by itself. A malicious caller can point the action at a fake RPC and make that RPC consistently report whatever chain id, receipt, log, or contract state helps the attack. + +Use a source-level policy instead: + +1. Hardcode the expected RPC hostnames in the action source. +2. Require `https://` so TLS binds the request to that hostname. +3. Reject redirects, so a whitelisted host cannot bounce the action to attacker-controlled JSON-RPC. +4. For multi-chain actions, map each supported `chainId` to its allowed host and any chain-specific safety policy, such as minimum confirmations. + +```javascript +const RPC_HOSTS = { + 84532: { host: /^base-sepolia\.g\.alchemy\.com$/i, minConfirmations: 5 }, + 421614: { host: /^arb-sepolia\.g\.alchemy\.com$/i, minConfirmations: 5 }, +}; + +function assertAllowedRpc(chainId, rpcUrl) { + const policy = RPC_HOSTS[Number(chainId)]; + if (!policy) throw new Error(`chainId ${chainId} not whitelisted`); + + const parsed = new URL(rpcUrl); + if (parsed.protocol !== "https:") throw new Error("RPC URL must use https://"); + if (!policy.host.test(parsed.hostname)) throw new Error("RPC host not whitelisted"); + + return policy; +} + +async function rpc(url, method, params) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + redirect: "error", + }); + const body = await res.json(); + if (body.error) throw new Error(body.error.message); + return body.result; +} +``` + +Because the whitelist is part of the action code, changing providers or adding chains changes the IPFS CID and therefore the action-derived signer address. That is a feature: contracts that verify action-identity signatures are explicitly trusting this exact code and these exact external data sources. + +--- + +## Multi-Source Consensus + +When a Lit Action signs data from outside the chain it is serving, decide how independent sources should agree before any signature is produced. The right aggregation depends on the data shape: + +- **Discrete facts** such as `isSanctioned(address) -> bool`, event presence, or yes/no market outcomes should usually require strict agreement across independent sources. +- **Continuous values** such as market prices should usually use a median or trimmed mean plus a maximum-spread check, because honest sources will naturally differ by small amounts. +- **Fallback availability** should fail closed: if too few sources respond, return an unsigned denial instead of signing from a single source. + +Hardcode the source list, minimum source count, and spread/agreement policy in the action. If a caller can lower `MIN_SOURCES`, widen a spread cap, or choose arbitrary sources via `js_params`, the policy is no longer the trust anchor. + +```javascript +const SOURCES = ["source-a", "source-b", "source-c"]; +const MIN_SOURCES = 2; + +async function requireStrictAgreement(fetchSource) { + const settled = await Promise.allSettled(SOURCES.map(fetchSource)); + const ok = settled + .filter((r) => r.status === "fulfilled" && r.value != null) + .map((r) => r.value); + + if (ok.length < MIN_SOURCES) return { authorized: false, reason: "too few sources" }; + if (!ok.every((value) => value === ok[0])) { + return { authorized: false, reason: "sources disagree", values: ok }; + } + + return { authorized: true, value: ok[0] }; +} +``` + +For continuous values, sort the successful observations, take the median, and refuse to sign if `(max - min) / median` exceeds a hardcoded spread cap. + +--- + ## Gating Logic — AKA Access Control Conditions The older Datil / Naga Lit SDK offered a fluent builder for declaring access control conditions (ACCs): structured objects that describe *who* may decrypt ciphertext or call an action. A typical condition using the SDK looks like this: diff --git a/examples/compliance-transfer-gate/README.md b/examples/compliance-transfer-gate/README.md index 7ba6357b..26b38017 100644 --- a/examples/compliance-transfer-gate/README.md +++ b/examples/compliance-transfer-gate/README.md @@ -207,8 +207,7 @@ decoupled. The current action trusts one provider (Alchemy). If Alchemy itself ever lied about an `isSanctioned` reading — buggy upgrade, hijacked endpoint, deliberate fraud — the gate would happily sign a malicious transfer. To eliminate that -single point of failure, apply the multi-source pattern used in -[`../multi-source-price-oracle`](../multi-source-price-oracle): fan the +single point of failure, apply the multi-source consensus pattern: fan the `eth_call` out to two or three independently-hostnamed providers (Alchemy + Infura + QuickNode) and only sign when they all return the same bool. Edit the action to keep three regexes instead of one and require all three URLs diff --git a/examples/cross-chain-token/README.md b/examples/cross-chain-token/README.md index bf9bdc23..dac7968b 100644 --- a/examples/cross-chain-token/README.md +++ b/examples/cross-chain-token/README.md @@ -103,9 +103,9 @@ trust whatever it reports. That's broken: anyone with the usage API key could supply a hostile RPC that returns a fake "BurnInitiated" log for a burn that never happened, and the action would happily sign a mint. -The fix is the same as in [`compliance-transfer-gate`](../compliance-transfer-gate): -the action enforces a **per-chain hostname whitelist** baked into the -source, plus **mandatory `https://`** so a plain-HTTP URL can't be paired +The fix is the hostname-pinned RPC trust-anchor pattern: the action enforces +a **per-chain hostname whitelist** baked into the source, plus **mandatory +`https://`** so a plain-HTTP URL can't be paired with a path-level MITM. For `srcChainId=84532` (Base Sepolia) the URL must resolve to `base-sepolia.g.alchemy.com`; for `srcChainId=421614` (Arb Sepolia) it must be `arb-sepolia.g.alchemy.com`. Hostnames are anchored diff --git a/examples/cross-chain-token/scripts/_env.js b/examples/cross-chain-token/scripts/_env.js index 0bec5de9..33874df0 100644 --- a/examples/cross-chain-token/scripts/_env.js +++ b/examples/cross-chain-token/scripts/_env.js @@ -1,9 +1,7 @@ // Minimal .env reader / upserter shared across the cross-chain-token scripts. // -// Same shape as the helper used by the other examples — see -// ../../compliance-transfer-gate/scripts/_env.js for the full rationale. -// Reproduced here so each example folder is self-contained and you can -// clone just one of them without copying siblings. +// Kept inline so this example folder is self-contained and you can clone it +// without copying siblings. const fs = require("fs"); const path = require("path"); diff --git a/examples/cross-chain-token/scripts/setup.js b/examples/cross-chain-token/scripts/setup.js index b1597d16..c6f221f8 100644 --- a/examples/cross-chain-token/scripts/setup.js +++ b/examples/cross-chain-token/scripts/setup.js @@ -207,7 +207,7 @@ function rpcForNetwork(network) { } // --------------------------------------------------------------------------- -// Lit Chipotle REST helpers — same shape as the other examples'. +// Lit Chipotle REST helpers. // --------------------------------------------------------------------------- async function call(base, apiKey, path, init = {}) { diff --git a/examples/multi-source-price-oracle/README.md b/examples/multi-source-price-oracle/README.md index d4d617c5..5b087e7e 100644 --- a/examples/multi-source-price-oracle/README.md +++ b/examples/multi-source-price-oracle/README.md @@ -16,9 +16,10 @@ single venue can manipulate. ## Why median, not strict equality -The [`compliance-transfer-gate`](../compliance-transfer-gate) example uses -strict byte-equality across three sources because the underlying data -(an `isSanctioned(addr) → bool`) doesn't drift between observations. +Strict byte-equality works for discrete facts because the underlying data +doesn't drift between observations. For example, an `isSanctioned(addr) → bool` +answer should be identical across independent reads of the same canonical +source. Spot prices drift between exchanges every second, so the same approach would never accept a reading. Median instead: diff --git a/examples/multi-source-price-oracle/action/priceOracle.js b/examples/multi-source-price-oracle/action/priceOracle.js index a20827d1..a5a873fc 100644 --- a/examples/multi-source-price-oracle/action/priceOracle.js +++ b/examples/multi-source-price-oracle/action/priceOracle.js @@ -3,9 +3,8 @@ // sign an attestation the PriceOracle contract can verify. // // Median-of-three is the right aggregation for live market prices: -// * Different venues disagree by a few cents at any moment, so the strict -// byte-equality used by the multi-RPC consensus example would never -// pass. +// * Different venues disagree by a few cents at any moment, so strict +// byte-equality would never pass for this kind of continuous value. // * Median naturally rejects one outlier — a single exchange returning a // stale, frozen, or manipulated price doesn't shift the result. // * We also check the spread between the lowest and highest reported diff --git a/examples/multi-source-price-oracle/scripts/_env.js b/examples/multi-source-price-oracle/scripts/_env.js index ea93d0ed..ae942c08 100644 --- a/examples/multi-source-price-oracle/scripts/_env.js +++ b/examples/multi-source-price-oracle/scripts/_env.js @@ -1,7 +1,6 @@ // Minimal .env reader / upserter shared across all the scripts. // -// See the other example folders for the same helper — kept inline here -// so each example folder is fully self-contained. +// Kept inline so this example folder is fully self-contained. const fs = require("fs"); const path = require("path"); diff --git a/examples/prediction-market-oracle/README.md b/examples/prediction-market-oracle/README.md index 0f5caff7..3d5f27e9 100644 --- a/examples/prediction-market-oracle/README.md +++ b/examples/prediction-market-oracle/README.md @@ -4,9 +4,8 @@ models and only signing the answer when they all agree.** This is the "use AI on-chain via Lit" example. Single-frontier-model -resolution is too easy to hallucinate; this example uses the same -multi-source pattern as [`../multi-source-price-oracle`](../multi-source-price-oracle), -but the parallel sources are AI providers instead of price feeds. +resolution is too easy to hallucinate; this example uses a multi-source +consensus pattern where the parallel sources are AI providers. Strict agreement (rather than median) is used here because the output is categorical YES/NO/UNCLEAR — there's nothing to take a median of. diff --git a/examples/prediction-market-oracle/action/marketOracle.js b/examples/prediction-market-oracle/action/marketOracle.js index 9e7934e8..a3d37994 100644 --- a/examples/prediction-market-oracle/action/marketOracle.js +++ b/examples/prediction-market-oracle/action/marketOracle.js @@ -6,10 +6,9 @@ // hallucinate. Three independent models with different training and // (importantly) one of them — Perplexity — grounded in live web search // have to all return the same single-word answer before we attest -// anything on-chain. Same multi-source pattern as the -// multi-source-price-oracle example, applied to AI sources instead of -// price feeds. We require strict agreement here (rather than a median) -// because the output is categorical YES/NO/UNCLEAR. +// anything on-chain. This is multi-source consensus applied to AI sources: +// strict agreement is required (rather than a median) because the output is +// categorical YES/NO/UNCLEAR. // // Required: Perplexity (web-grounded baseline — Sonar Pro indexes the web // at query time, so it can answer questions about events that happened diff --git a/examples/prediction-market-oracle/scripts/_env.js b/examples/prediction-market-oracle/scripts/_env.js index ea93d0ed..ae942c08 100644 --- a/examples/prediction-market-oracle/scripts/_env.js +++ b/examples/prediction-market-oracle/scripts/_env.js @@ -1,7 +1,6 @@ // Minimal .env reader / upserter shared across all the scripts. // -// See the other example folders for the same helper — kept inline here -// so each example folder is fully self-contained. +// Kept inline so this example folder is fully self-contained. const fs = require("fs"); const path = require("path");