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
17 changes: 17 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ pieces — smart contracts, deploy scripts, off-chain clients — that the
single-snippet examples in [`docs/lit-actions/examples.mdx`](../docs/lit-actions/examples.mdx)
don't cover.

## Lit Actions (direct call)

These call the Lit Action endpoint directly — a caller submits the request and
gets back a signed result.

| Example | What it shows |
| --- | --- |
| [`compliance-transfer-gate`](./compliance-transfer-gate) | An ERC-20 whose every transfer is gated on the Chainalysis on-chain sanctions oracle. The action reads the oracle on Ethereum mainnet and signs an authorization the token contract — on any chain — verifies. Keyless. |
Expand All @@ -13,6 +18,18 @@ don't cover.
| [`prediction-market-oracle`](./prediction-market-oracle) | AI consensus for prediction-market resolution. Polls Perplexity (required, web-grounded) plus OpenAI and Anthropic (optional second opinions); only signs when every configured model agrees. |
| [`lit-solver-vault`](./lit-solver-vault) | Policy-gated key custody for intent-system solvers/fillers. Inventory lives in a vault; the only key that releases a fill is a Lit Action that screens recipient binding, notional cap, allowlist, and a kill switch. The bot can request fills but can't drain the vault, and `exit` always recovers inventory without Lit. Ships a zero-dependency mock demo plus a live **Across** testnet relayer that fills a real Sepolia→Base-Sepolia intent. Keyless. |

## Lit Triggers (event-driven)

These add the [Lit Triggers](https://triggers.litprotocol.com) service: a
webhook, cron schedule, or EVM chain event fires the action automatically —
no caller. See [`lit-triggers/`](./lit-triggers).

| Example | Trigger | What it shows |
| --- | --- | --- |
| [`lit-triggers/release-attestation`](./lit-triggers/release-attestation) | webhook | Verify a GitHub release webhook (HMAC over the raw body) and anchor the release on-chain via a keyless signer. |
| [`lit-triggers/uptime-insurance`](./lit-triggers/uptime-insurance) | schedule | Parametric insurance: an autonomous ETH payout from a keyless pool when a monitored service is down. |
| [`lit-triggers/chainlink-feed-mirror`](./lit-triggers/chainlink-feed-mirror) | chain_event | Relay a Chainlink price feed to a chain Chainlink doesn't support, with no trusted relayer. |

If you're looking for a one-file recipe (sign a message, decrypt a secret,
fetch a price and sign it), start with the docs page. Examples here are for
flows that need more than one file to actually run.
24 changes: 24 additions & 0 deletions examples/lit-triggers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Lit Triggers examples

Full, runnable examples where a [Lit Triggers](https://triggers.litprotocol.com)
trigger — a webhook, a cron schedule, or an EVM chain event — drives a Lit
Action that fetches data, signs, and transacts with a key no human holds.

These differ from the examples in the [parent folder](../), which call the Lit
Action endpoint directly. Everything here additionally depends on the
lit-triggers service: `setup.js` authorizes your machine in the browser and
creates the trigger. See the [Lit Triggers docs](https://github.com/LIT-Protocol/chipotle/tree/main/docs/lit-triggers)
for concepts and the API.

| Example | Trigger | What it shows |
| --- | --- | --- |
| [`release-attestation`](./release-attestation) | webhook | Verify a GitHub release webhook (HMAC over the raw body) and anchor the release on-chain via a keyless signer. |
| [`uptime-insurance`](./uptime-insurance) | schedule | Parametric insurance: an autonomous ETH payout from a pool key nobody holds when a monitored service is down. |
| [`chainlink-feed-mirror`](./chainlink-feed-mirror) | chain_event | Relay a Chainlink price feed to a chain Chainlink doesn't support, with no trusted relayer. |

Each ships a hardened action, a one-shot `setup` script (action CID, scoped
key, contract deploy, trigger creation), a `deploy` script, and an end-to-end
client.

> Want an agent to wire one up for you? Point it at
> [`https://triggers.litprotocol.com/SKILL.md`](https://triggers.litprotocol.com/SKILL.md).
46 changes: 46 additions & 0 deletions examples/lit-triggers/chainlink-feed-mirror/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ===========================================================================
# REQUIRED — fill these in before running `npm run setup`
# ===========================================================================

# Account-level (master) Lit API key — setup mints the scoped usage key from it.
# https://dashboard.chipotle.litprotocol.com
LIT_API_KEY=

# EOA with Base Sepolia gas. Deploys PriceConsumer and funds the relayer wallet.
DEPLOYER_PRIVATE_KEY=


# ===========================================================================
# Defaults — change only if you know why
# ===========================================================================

LIT_API_BASE=https://api.chipotle.litprotocol.com
TRIGGERS_BASE=https://triggers.litprotocol.com

# Destination chain: where the mirrored price is written.
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
DEST_CHAIN_ID=84532

# Source feed: a Chainlink aggregator emitting AnswerUpdated on a chain the
# chain-event listener supports (ethereum/base/arbitrum/bsc/polygon). Setup
# resolves the underlying aggregator from this proxy. Default: ETH/USD on Base.
FEED_SOURCE_CHAIN=base
FEED_SOURCE_RPC=https://mainnet.base.org
FEED_SOURCE_PROXY=0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70

# Gas to fund the relayer wallet with on the destination chain.
RELAYER_FUND_ETH=0.0005


# ===========================================================================
# Auto-filled by `npm run setup` — leave blank
# ===========================================================================

LIT_USAGE_API_KEY=
ACTION_WALLET_ADDRESS=
LIT_TRIGGERS_AGENT_TOKEN=
ACTION_IPFS_CID=
GROUP_ID=
FEED_AGGREGATOR=
PRICE_CONSUMER_BASE_SEPOLIA=
TRIGGER_ID=
4 changes: 4 additions & 0 deletions examples/lit-triggers/chainlink-feed-mirror/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
artifacts/
cache/
.env
122 changes: 122 additions & 0 deletions examples/lit-triggers/chainlink-feed-mirror/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Chainlink Feed Mirror

**Relay a Chainlink price feed to a chain Chainlink doesn't serve — with no
trusted relayer holding keys.** A [lit-triggers](https://triggers.litprotocol.com)
chain-event trigger fires on a Chainlink aggregator's `AnswerUpdated` event on a
supported source chain; the Lit Action reads the new price and writes it to a
`PriceConsumer` contract on any destination chain, signed by a wallet derived
from the action's IPFS CID (the consumer pins it as `updater`).

## Why this is interesting

Chainlink publishes feeds on major chains but not on every L2 / appchain.
Bridging a feed normally means trusting a relayer (a key, a federation) to copy
the value faithfully. Here the value originates from a **verifiable on-chain
Chainlink event**, and the relay is signed by a **keyless wallet tied to this
exact action code** — so the trust assumption is "this content-addressed action
copies the event it was triggered on," not "this relayer operator is honest."

> The chain-event **trigger** only supports `ethereum`/`base`/`arbitrum`/`bsc`/`polygon`
> as the source, but the action **body** can write to any EVM chain via `destRpcUrl`.

```
Chainlink aggregator lit-triggers (chain_event) Lit network PriceConsumer (dest chain)
(Base mainnet) ────────────────────────── ─────────── ──────────────────────────
AnswerUpdated ──────────► run: main(params)
(price, roundId, ts) │ decoded.arg0/1/2
│ check dest RPC chainId == destChainId
│ sign + send setPrice ────────────────────────► setPrice(answer,roundId,ts)
│ from relayer wallet require(msg.sender==updater) ✓
▼ require(newer round) ✓
run history
```

## Hardening

- **Source re-verification (the important one).** The action does **not** trust
the price the trigger hands it. A chain-event trigger supplies a decoded log,
but anyone holding the usage key could execute the action with a fabricated
`decoded` payload. So the action applies the hostname-pinned RPC trust-anchor
pattern (see [Lit Action Patterns](../../../docs/lit-actions/patterns.mdx)):
it takes only the `transaction_hash` and `log_index`, re-fetches that receipt
from a **hostname-pinned, https-only** source RPC (`SOURCE.rpcHost`), and
verifies the log was emitted by the expected **`SOURCE.aggregator`** with the
`AnswerUpdated` topic and enough confirmations.
It decodes the price from that verified log. The aggregator/chain/host are baked
constants — editing them changes the action CID + signer, so a modified action
can't write to the existing `PriceConsumer`. A fabricated price is simply
ignored (and a wrong-emitter tx is rejected).
- **Destination pin.** `destChainId` is required; the action calls `eth_chainId`
on `destRpcUrl` and refuses to write unless it matches — a swapped RPC can't
redirect the relayed price to another chain.
- **Updater pin.** `PriceConsumer.setPrice` reverts unless `msg.sender` is the
relayer (the action's derived wallet).
- **Stale-round reject.** `setPrice` accepts only strictly-newer `roundId`s
(gated by an explicit `initialized` flag so a `roundId == 0` write can't bypass
it), matching Chainlink semantics.

## Files

| Path | Purpose |
| --- | --- |
| `action/feedMirror.js` | The Lit Action: read the decoded `AnswerUpdated`, verify the dest chain, write `setPrice`. |
| `contracts/PriceConsumer.sol` | Mirrored feed; only the pinned relayer can write, only newer rounds. |
| `scripts/setup.js` | One-shot: action CID → group → scoped key → derive + fund relayer → deploy consumer → resolve Chainlink aggregator → authorize → create the chain-event trigger. |
| `scripts/deploy.js` | Hardhat deploy of `PriceConsumer`, pinning the relayer as `updater`. |
| `scripts/mirror.js` | End-to-end client: `--simulate` (deterministic) or watch the real trigger. |
| `scripts/_env.js` | Tiny shared `.env` reader / upserter. |

## Walkthrough

```bash
cp .env.example .env # set LIT_API_KEY (master) + DEPLOYER_PRIVATE_KEY
npm install
npm run setup # opens a browser — click "Authorize agent"

# Deterministic check of the relay logic (no waiting for Chainlink):
npm run mirror -- --simulate

# Or watch the real trigger fire on the next on-chain AnswerUpdated:
npm run mirror
```

`setup` deploys `PriceConsumer`, resolves the Chainlink ETH/USD aggregator on
Base mainnet (from its proxy), and creates a chain-event trigger watching its
`AnswerUpdated`. The real trigger fires whenever Chainlink next updates (a price
deviation or its heartbeat — can take minutes), so `--simulate` feeds the same
action a synthetic `AnswerUpdated` through a throwaway webhook to show the relay
immediately, then reads the consumer back:

```
PriceConsumer 0x… — current roundId 0
Creating temporary webhook trigger with the same action...
queued: {"run_id":"…","status":"queued"}
run status: success
action result: {"ok":true,"source_chain":"base","relayer":"0x…","answer":"200000000000","roundId":"1","updatedAt":"…","txHash":"0x…"}
(temporary trigger deleted)
PriceConsumer now — roundId 1, latestAnswer 200000000000
✓ Chainlink price relayed on-chain by the keyless relayer wallet.
```

## Targeting different feeds / chains

- **Source feed:** set `FEED_SOURCE_PROXY` to any Chainlink price-feed proxy on a
supported source chain (and `FEED_SOURCE_CHAIN` / `FEED_SOURCE_RPC` to match).
Setup resolves the underlying aggregator that emits `AnswerUpdated`.
- **Destination:** point `BASE_SEPOLIA_RPC_URL` / `DEST_CHAIN_ID` at any EVM
chain and deploy `PriceConsumer` there. The action writes wherever `destRpcUrl`
points — as long as its chainId matches `destChainId`.

## Production considerations

- **Relayer gas.** The relayer wallet pays gas on the destination chain for
every update; keep it funded, and consider that a busy feed emits often.
- **Decimals & feed identity.** This stores the raw `int256` answer. A real
consumer should also record the feed's `decimals()` and which feed/pair it is,
and expose a Chainlink-compatible `latestRoundData()` if downstream contracts
expect it.
- **Updater rotation.** `updater` is immutable; rotating the action means
redeploying or adding a governance-gated setter.
- **Liveness.** The mirror is only as fresh as the source feed's updates plus
the trigger's confirmation depth. Downstream contracts should treat
`updatedAt` as the freshness bound and reject stale prices.
115 changes: 115 additions & 0 deletions examples/lit-triggers/chainlink-feed-mirror/action/feedMirror.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Lit Action: relay a Chainlink price update to a chain Chainlink does not
// natively serve, driven by a lit-triggers CHAIN_EVENT trigger.
//
// HARDENING (why this doesn't just trust the trigger): a chain-event trigger
// hands the action a decoded log, but the action is also executable by anyone
// holding the usage key (e.g. via a direct call or a webhook trigger), who
// could supply arbitrary `decoded` values. So this action does NOT trust the
// supplied decode. It re-fetches the transaction receipt from a HOSTNAME-PINNED
// source RPC, confirms the log was emitted by the expected Chainlink aggregator
// with the AnswerUpdated topic, waits for confirmations, and decodes the price
// from the verified log itself. The relay signs only what it independently read
// on the source chain. Editing this file changes the action's IPFS CID and
// therefore its signer, so the PriceConsumer (which pins the relayer) rejects a
// modified action.
//
// AnswerUpdated(int256 indexed current, uint256 indexed roundId, uint256 updatedAt)
// -> topics[1] = current (price), topics[2] = roundId, data = updatedAt
//
// Baked-in source policy. These are constants, not params, so a hostile caller
// can't point the action at an attacker-controlled "aggregator" that emits a
// fake AnswerUpdated. To target a different feed/chain, edit these (which
// changes the CID + signer, forcing a PriceConsumer redeploy).
const SOURCE = {
chainId: 8453, // Base mainnet
aggregator: "0x1e0b2c3896338fbb201c4f0a27c6904801dca06b", // ETH/USD aggregator (lowercase)
rpcHost: /^mainnet\.base\.org$/i, // TLS-pinned host; swap for your provider's host
minConfirmations: 2,
};
const ANSWER_UPDATED_TOPIC = ethers.utils.id("AnswerUpdated(int256,uint256,uint256)");

// default_params (set on the trigger):
// srcRpcUrl source-chain RPC; its host must match SOURCE.rpcHost, https only
// destRpcUrl destination chain RPC
// destChainId REQUIRED expected destination chain id (action refuses to write elsewhere)
// consumer PriceConsumer address on the destination chain
// gasLimit optional; explicit so signing never depends on gas estimation
// dryRun when true, sign the tx but don't broadcast

const main = async (params) => {
const event = (params && params.event) || {};
if (event.source !== "chain_event") {
return { ok: false, error: "expected chain_event", got: event.source || null };
}
const txHash = event.transaction_hash;
const logIndex = event.log_index;
if (!txHash || logIndex === undefined || logIndex === null) {
return { ok: false, error: "missing transaction_hash / log_index" };
}

// 1. Re-fetch and verify the log from the pinned source RPC.
const srcUrl = params.srcRpcUrl || "";
let srcHost;
try { srcHost = new URL(srcUrl).host; } catch { return { ok: false, error: "bad srcRpcUrl" }; }
if (!/^https:/i.test(srcUrl) || !SOURCE.rpcHost.test(srcHost)) {
return { ok: false, error: "source RPC host not allowed", host: srcHost };
}
const srcProvider = new ethers.providers.JsonRpcProvider(srcUrl);
const srcNet = await srcProvider.getNetwork();
if (srcNet.chainId !== SOURCE.chainId) {
return { ok: false, error: "source chain mismatch", expected: SOURCE.chainId, got: srcNet.chainId };
}
const receipt = await srcProvider.getTransactionReceipt(txHash);
if (!receipt) return { ok: false, error: "receipt not found", txHash };
const log = receipt.logs.find((l) => Number(l.logIndex) === Number(logIndex));
if (!log) return { ok: false, error: "log not found at index", logIndex };
if (log.address.toLowerCase() !== SOURCE.aggregator) {
return { ok: false, error: "unexpected log emitter", emitter: log.address };
}
if (!log.topics || log.topics[0] !== ANSWER_UPDATED_TOPIC) {
return { ok: false, error: "not an AnswerUpdated log" };
}
const head = await srcProvider.getBlockNumber();
if (head - receipt.blockNumber < SOURCE.minConfirmations) {
return { ok: false, error: "insufficient confirmations", need: SOURCE.minConfirmations };
}

// 2. Decode from the VERIFIED log (not from event.decoded).
const answer = ethers.BigNumber.from(log.topics[1]).fromTwos(256); // int256 current
const roundId = ethers.BigNumber.from(log.topics[2]); // uint256 roundId
const updatedAt = ethers.BigNumber.from(log.data); // uint256 updatedAt

// 3. Write to the destination, with a REQUIRED chain-id pin.
if (!params.destChainId) return { ok: false, error: "destChainId required" };
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
const destProvider = new ethers.providers.JsonRpcProvider(params.destRpcUrl);
const destNet = await destProvider.getNetwork();
if (destNet.chainId !== Number(params.destChainId)) {
return { ok: false, error: "dest_chain_mismatch", expected: Number(params.destChainId), got: destNet.chainId };
}
const signer = wallet.connect(destProvider);
const iface = new ethers.utils.Interface([
"function setPrice(int256 answer, uint256 roundId, uint256 updatedAt)",
]);
const data = iface.encodeFunctionData("setPrice", [answer, roundId, updatedAt]);
const txReq = { to: params.consumer, data, gasLimit: ethers.BigNumber.from(params.gasLimit || "150000") };

const out = {
ok: true,
source_chain: SOURCE.chainId,
aggregator: SOURCE.aggregator,
relayer: wallet.address,
answer: answer.toString(),
roundId: roundId.toString(),
updatedAt: updatedAt.toString(),
src_tx: txHash,
};

if (params.dryRun) {
const populated = await signer.populateTransaction(txReq);
return { ...out, dryRun: true, signedTx: await signer.signTransaction(populated) };
}
const tx = await signer.sendTransaction(txReq);
const rcpt = await tx.wait();
return { ...out, txHash: rcpt.transactionHash, block: rcpt.blockNumber };
};
Loading
Loading