Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/lit-actions/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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: {
Expand Down
78 changes: 78 additions & 0 deletions docs/lit-actions/patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions examples/compliance-transfer-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions examples/cross-chain-token/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions examples/cross-chain-token/scripts/_env.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
2 changes: 1 addition & 1 deletion examples/cross-chain-token/scripts/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down
7 changes: 4 additions & 3 deletions examples/multi-source-price-oracle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 2 additions & 3 deletions examples/multi-source-price-oracle/action/priceOracle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions examples/multi-source-price-oracle/scripts/_env.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
5 changes: 2 additions & 3 deletions examples/prediction-market-oracle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 3 additions & 4 deletions examples/prediction-market-oracle/action/marketOracle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions examples/prediction-market-oracle/scripts/_env.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
Loading