How the protocol works
Five calls, and the order they happen in:
-
createOption(nftContract, nftTokenId, strikePrice, premium, duration)— the writer escrows the NFT and names the terms. The NFT leaves their wallet in this call and is held by the contract until settlement. -
purchaseOption(optionId)— the buyer pays the premium and is minted the option, which is itself an ERC-721. -
exerciseOption(optionId)— the holder pays the strike and takes the NFT. -
cancelOption(optionId)— the writer takes the NFT back, but only while the option is still unsold. -
expireOption(optionId)— after expiry, returns the NFT to the writer. Permissionless: anybody may call it for anybody's option.
No lending, no margin, no liquidation. A buyer's worst case is the premium; the
writer's collateral is the NFT itself, and it is escrowed from the moment the
option is listed. That is the whole risk model.
| Network | Chain ID | Option contract | Settlement token | Gas |
|---|---|---|---|---|
| Robinhood Chain | 4663 | 0xD7705Dd07F0482eF9D9A4Bf7B18c6b76ca16d866 |
USDG (6 decimals) | ETH |
| Polygon | 137 | 0xD7705Dd07F0482eF9D9A4Bf7B18c6b76ca16d866 |
USDC (6 decimals) | POL |
The wallet
There is no connect step and no wallet popup in any of this, which means the
agent has to bring its own signer. Two viem clients, built once:
import { createPublicClient, createWalletClient, defineChain, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
const chain = defineChain({
id: 4663,
name: "Robinhood Chain",
nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" },
rpcUrls: { default: { http: [process.env.RPC_URL ?? "https://rpc.mainnet.chain.robinhood.com"] } },
});
const transport = http();
export const client = createPublicClient({ chain, transport });
export const wallet = createWalletClient({
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
chain,
transport,
});
Every recipe below uses those two names. viem takes any account object, so a
KMS or hardware signer drops in where privateKeyToAccount is — and it should,
because whatever holds that key holds the wallet: it can spend the balance to
zero and move every NFT the option contract is approved for. Fund a wallet for
the task rather than pointing an agent at the one holding everything, read the
key from the environment rather than a file in the repo, and prefer
approve(optionContract, tokenId) to setApprovalForAll — the blanket
approval outlives the option and covers the whole collection.
The five mistakes that do not revert
This is the part worth the length. An agent can fetch an ABI; what it cannot
infer from a function signature is where this protocol punishes a reasonable
guess.
Amounts are base units of a 6-decimal token, not
ether. A strike of 1500 is parseUnits("1500", 6).
Reach for parseEther and you have listed at roughly a trillion times the
intended price — and it does not revert. It lists, and nobody ever buys it.
duration is seconds from now; expiration is a timestamp. The
contract computes one from the other. Feeding a timestamp back in as a duration
either reverts on the 365-day cap or sets an expiry nobody meant.
Buying and exercising need two different approvals. The premium approval
does not cover the strike. They are different amounts, taken at different
times.
Proceeds are a pull, not a push. Premiums and strikes accrue to an internal
balance. Nothing reaches a wallet until somebody calls withdrawTokens(). A
writer who never calls it never sees a penny, and nothing anywhere says so.
status does not move on its own. It stays Active after the
expiration timestamp passes, until somebody actually settles the option. So
"is this still exercisable?" is status == 0 && block.timestamp < expiration,
never isExpired, which reports the settled state rather than the clock.
And one bonus, if you scan logs: write the OptionCreated signature from the
ABI, not from the Solidity emit. expiration is declared uint256 even
though a uint64 is emitted into it. Get that wrong and your topic0 is wrong,
and eth_getLogs returns nothing at all — silently, forever, with no error to
notice.
Find what is buyable
"What can I buy on Loxley right now?"
const total = await client.readContract({
abi: optionAbi, address: OPTION, functionName: "totalOptions",
});
const ids = Array.from({ length: Number(total) }, (_, i) => BigInt(i));
const results = await client.multicall({
contracts: ids.map((id) => ({
abi: optionAbi, address: OPTION, functionName: "getOption", args: [id],
})),
});
const now = BigInt(Math.floor(Date.now() / 1000));
const buyable = results.flatMap((entry, i) => {
if (entry.status !== "success") return [];
// (nftContract, nftTokenId, writer, holder, strikePrice,
// premium, expiration, premiumFee, strikeFee, status)
const [nft, tokenId, , holder, strike, premium, expiration, , , status] =
entry.result;
const isOpen =
status === 0 && holder === zeroAddress && expiration > now;
return isOpen ? [{ id: ids[i], nft, tokenId, strike, premium }] : [];
});
All three conditions, not just status. An option that has sold still reads Active, and so does one whose clock ran out but that nobody has settled yet — filtering on status alone returns both and every purchase attempt reverts.
Buy an option
"Buy option 42."
const [, , , , , premium] = await client.readContract({
abi: optionAbi, address: OPTION, functionName: "getOption", args: [optionId],
});
const approval = await wallet.writeContract({
abi: erc20Abi, address: TOKEN, functionName: "approve",
args: [OPTION, premium],
});
// The purchase reverts if this has not been mined yet.
await client.waitForTransactionReceipt({ hash: approval });
const hash = await wallet.writeContract({
abi: optionAbi, address: OPTION, functionName: "purchaseOption",
args: [optionId],
});
await client.waitForTransactionReceipt({ hash });
The premium is read off the contract rather than passed in, so the amount approved is exactly the amount owed. Approving the premium does not approve the strike — exercising later needs its own approval for a different, larger number.
Write an option against an NFT you own
"Write a 30-day call on my NFT, strike 2000, premium 50."
const strike = parseUnits("2000", 6); // 2000 USDG, six decimals
const premium = parseUnits("50", 6); // 50 USDG
const duration = 30n * 24n * 60n * 60n; // seconds, NOT a timestamp
const approval = await wallet.writeContract({
abi: erc721Abi, address: COLLECTION, functionName: "approve",
args: [OPTION, tokenId],
});
await client.waitForTransactionReceipt({ hash: approval });
const hash = await wallet.writeContract({
abi: optionAbi, address: OPTION, functionName: "createOption",
args: [COLLECTION, tokenId, strike, premium, duration],
});
// createOption returns the id, but a transaction receipt does not carry a
// return value. Read it out of the event instead.
const receipt = await client.waitForTransactionReceipt({ hash });
const [created] = parseEventLogs({
abi: optionAbi, eventName: "OptionCreated", logs: receipt.logs,
});
const optionId = created.args.optionId;
parseUnits with the token's own decimals, never parseEther. And the NFT leaves your wallet the moment this lands: it is escrowed until somebody exercises, cancels or expires the option.
Exercise and take the NFT
"Exercise option 42 — I want the NFT."
const [, , , , strike, , , , , status] = await client.readContract({
abi: optionAbi, address: OPTION, functionName: "getOption", args: [optionId],
});
if (status !== 0) throw new Error("Already settled.");
const approval = await wallet.writeContract({
abi: erc20Abi, address: TOKEN, functionName: "approve",
args: [OPTION, strike], // the strike, not the premium
});
await client.waitForTransactionReceipt({ hash: approval });
const hash = await wallet.writeContract({
abi: optionAbi, address: OPTION, functionName: "exerciseOption",
args: [optionId],
});
await client.waitForTransactionReceipt({ hash });
Only the current holder may exercise, and only before expiry. This is the second approval, for the strike; the one taken at purchase covered the premium and nothing more.
Settle expiries and withdraw proceeds
"Clean up my expired options and collect what I am owed."
// Return lapsed collateral to its writers. Permissionless: anyone may call
// this for anyone's option, and it costs the caller only gas.
const stale = await client.multicall({
contracts: openIds.map((id) => ({
abi: optionAbi, address: OPTION,
functionName: "optionCanBeExpired", args: [id],
})),
});
for (const [i, entry] of stale.entries()) {
if (entry.status === "success" && entry.result) {
const hash = await wallet.writeContract({
abi: optionAbi, address: OPTION,
functionName: "expireOption", args: [openIds[i]],
});
await client.waitForTransactionReceipt({ hash });
}
}
// Proceeds are a pull. Nothing has moved into your wallet until this runs.
const owed = await client.readContract({
abi: optionAbi, address: OPTION, functionName: "tokenBalance",
args: [wallet.account.address],
});
if (owed > 0n) {
await wallet.writeContract({
abi: optionAbi, address: OPTION, functionName: "withdrawTokens",
});
}
Two separate things that both get forgotten. Expiring returns the NFT to the writer; withdrawing moves the accumulated premiums and strikes. Neither happens on its own, and a writer who never calls withdrawTokens never sees a penny.
Finding options in the first place
There is no listings endpoint and you do not need one. totalOptions()
returns the next id to be issued, so every option that has ever existed is
0 .. totalOptions() - 1, and getOption gives you each one's current
state. At larger scale, scan OptionCreated from block
43187788 instead — and chunk it, because some public RPCs
cap eth_getLogs at 10,000 blocks and error rather than truncate.
One thing that surprises people: createOption accepts any ERC-721. There
is no onchain allowlist. An option written against an arbitrary collection is
valid, live and exercisable — it just will not be rendered by the Loxley web
interface, which shows only collections it has listed. If your counterparty is
another agent reading the chain, that does not matter at all.
Rules if you are acting for somebody else
Worth encoding these wherever your agent's instructions live:
- Never send a transaction the user did not ask for. Reading, simulating and quoting are free. Writing is not.
-
Quote in human units.
1500000000is not a price anybody can check. -
Say what a call commits them to.
createOptiongives up custody of an NFT for the full duration once it sells;purchaseOptionspends the premium with no way back. - The protocol is experimental and unaudited. Do not present it as safe.
- Access is restricted by jurisdiction. The interface is not offered to US persons or in several other territories, and that restriction is the user's to observe.
Get it as a skill
Everything above is packaged as a single Markdown file with Agent Skills front
matter, so a harness that has skills loads it on demand and one that does not
reads it as plain reference material.
mkdir -p .claude/skills/loxley-nft-options
curl -sSL https://loxley.lol/agents/skill.md -o .claude/skills/loxley-nft-options/SKILL.md
If that returns a 451, it is working as designed. The interface is
geofenced by jurisdiction and the skill is served from behind that gate, so the
download fails from a restricted country, from a datacentre IP, and from most
VPNs — which is to say from most places an agent actually runs.
GitHub is not geofenced, and the same file is committed there:
mkdir -p .claude/skills/loxley-nft-options
curl -sSL https://raw.githubusercontent.com/TreasureProject/NFTOptions/main/docs/skills/loxley-nft-options/SKILL.md -o .claude/skills/loxley-nft-options/SKILL.md
That copy is generated from the same deployment records as the served one and
drift-checked in CI, so it is the same document rather than a snapshot of it.
Failing both, save this article as your SKILL.md: the addresses, the call
sequences and the caveats above are the whole of it, and none of them need
network access back to the site at all.
Nothing here is financial advice, and neither is anything you build on it.
Top comments (0)