A Chainlink Runtime Environment (CRE) workflow that, in a single HTTP-triggered execution:
- Accepts
{ "token": "ETH" }(or BTC, LINK, BNB, SOL). - Reads the current USD price of that token from a Chainlink Data Feed on Sepolia via EVM Read.
- Resolves the block number at which the Data Feed answer was last updated by querying
AnswerUpdatedevent logs on the underlying OCR aggregator. - Writes a
Record { token, price, blockNumber, timestamp }to a deployed smart contract via EVM Write.
I ran the workflow with --broadcast and it successfully wrote the ETH/USD price to my contract on Sepolia.
Live transaction: 0x1a2171c7eb5202ea24c8d6d5abb3b6b4fc1fb2f00e3ebbd46e569ffa4b46065a
cre-price-snapshot/
├── contracts/
│ ├── interfaces/
│ │ └── ISnapshot.sol # ISnapshot interface (Record struct)
│ ├── abi/
│ │ ├── AggregatorV3Interface.ts
│ │ ├── PriceSnapshot.ts
│ │ └── index.ts
│ └── PriceSnapshot.sol # Consumer contract
├── deploy/
│ └── scripts/
│ └── deploy.ts # Deploy script (pure ethers v6)
├── price-snapshot-workflow/
│ ├── main.ts # CRE workflow logic
│ ├── package.json
│ ├── tsconfig.json
│ ├── workflow.yaml
│ ├── config.staging.json # Staging config (simulation)
│ └── config.production.json # Production config
├── hardhat.config.ts # Hardhat 3 config (compile)
├── package.json # Hardhat 3 deploy dependencies
├── tsconfig.json # TypeScript config for deploy
├── project.yaml # CRE project / RPC config
├── secrets.yaml # Secrets template (no real values)
├── .env.example # Environment variable template
├── .gitignore
└── README.md
git clone https://github.com/<your-username>/cre-price-snapshot
cd cre-price-snapshot
# Copy the env template and fill in your private key
cp .env.example .env
# Edit .env: set CRE_ETH_PRIVATE_KEY (64 hex chars, no 0x prefix)From the project root (cre-price-snapshot/):
npm install
npm run deploy:sepoliaExpected output:
✅ PriceSnapshot deployed successfully!
Contract address: 0x80AF3cea8aa1C86753b33508FcEe33E6C099EC28
The contract is already deployed at 0x80AF3cea8aa1C86753b33508FcEe33E6C099EC28 on Sepolia — you only need to re-deploy if you want your own instance.
Forwarder addresses for Ethereum Sepolia
Purpose Address Simulation ( cre ... --broadcast)0x15fC6ae953E024d975e77382eEeC56A9101f9F88Production (live DON) 0xF8344CFd5c43616a4366C34E3EEE75af79a74482The deploy script uses the simulation forwarder so the workflow can immediately broadcast transactions during
cre workflow simulate --broadcast. To migrate to production, callsetForwarder("0xF8344…")(owner-only) on the contract.
Update the config if you deployed a new instance:
# In price-snapshot-workflow/config.staging.json
# Set "snapshotContractAddress" to your new addresscd price-snapshot-workflow
bun install
cd ..cre login
cre whoami # verify authenticationFrom the project root (cre-price-snapshot/):
cre workflow simulate price-snapshot-workflow \
--target staging-settings \
--http-payload '{"token":"ETH"}'cre workflow simulate price-snapshot-workflow \
--target staging-settings \
--http-payload '{"token":"ETH"}' \
--broadcastYou can substitute any supported token symbol:
# BTC
cre workflow simulate price-snapshot-workflow --target staging-settings --http-payload '{"token":"BTC"}' --broadcast
# LINK
cre workflow simulate price-snapshot-workflow --target staging-settings --http-payload '{"token":"LINK"}' --broadcastinterface ISnapshot {
struct Record {
string token; // e.g. "ETH"
uint256 price; // raw Chainlink answer (8 decimal places)
uint256 blockNumber; // block at which the feed answer was last updated
uint256 timestamp; // Unix timestamp of the last update
}
}| Function | Description |
|---|---|
onReport(bytes metadata, bytes report) |
Entry-point called by the KeystoneForwarder after verifying report signatures (the forwarder check). Decodes the report bytes into a Record and stores it. |
latestRecord() |
Returns the most-recently stored record (any token). |
records(string token) |
Returns the latest record for a specific token. |
setForwarder(address) |
Owner-only. Update the trusted forwarder (e.g., simulation → production). |
The forwarder check is enforced in onReport:
if (msg.sender != s_forwarder) revert InvalidSender(msg.sender, s_forwarder);Without this check, anyone could call onReport directly with fake data and write arbitrary prices to the contract. The check ensures only Chainlink's verified execution path can write here — every stored price went through the CRE workflow's BFT-consensus-verified signing process before the contract accepted it.
HTTP POST → HTTP Trigger → onHttpTrigger callback
│
├─ EVM Read: latestRoundData() on Chainlink proxy
├─ EVM Read: aggregator() on proxy (get underlying OCR address)
├─ EVM Read: headerByNumber() (get current block)
├─ EVM Read: filterLogs() (AnswerUpdated event → block #)
└─ EVM Write: writeReport()
│
KeystoneForwarder
│
PriceSnapshot.onReport()
│
snapshot(Record) stored
This was the trickiest part of the assignment. latestRoundData() gives you a Unix timestamp for the last update but not a block number. To get the actual block:
- Call
aggregator()on the proxy — the proxy is a facade, the real price logic lives in the underlying OCR aggregator contract behind it. - Search that aggregator's
AnswerUpdated(int256,uint256,uint256)event logs over the last 5000 blocks. - The
blockNumberof the most recent log is the exact block where the price was last written to the chain.
This satisfies the requirement: "The blockNumber stored on-chain must be the block number at which the Data Feed answer was last updated."
Note: one gotcha I hit — the CRE SDK uses protobuf internally, so the
addressesandtopicsfields infilterLogsmust be base64-encoded, not plain hex strings.encodeCallMsghandles this automatically for contract calls, but forfilterLogsyou have to callhexToBase64()yourself.
| Token | Feed Address |
|---|---|
| ETH/USD | 0x694AA1769357215DE4FAC081bf1f309aDC325306 |
| BTC/USD | 0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43 |
| LINK/USD | 0xc59E3633BAAC79493d908e63626716e204A45EdF |
| BNB/USD | 0x14866185B1962B63C3Ea9E03Bc1da838bab34C19 |
| SOL/USD | 0xc6AEad5D74Ff33FfAFb9C77a3b1b2B7cF72FF32e |
To add more tokens, extend priceFeeds in config.staging.json. Feed addresses are listed at docs.chain.link/data-feeds/price-feeds/addresses.
The AnswerUpdated log search covers the last 5000 blocks (~17 hours on Sepolia at 12s/block). If a feed hasn't emitted an event in that window the workflow throws. A production version would paginate backward in chunks until it finds the most recent log, rather than failing with a fixed window.

