KIV protocol

KIV records a market call before its outcome exists, then settles it against a Chainlink price feed on Robinhood Chain. It holds no funds, requests no approvals, and has no owner function that can change a result.

A call is four values: an asset's price feed, a target level, a direction, and a deadline. Writing it costs one transaction. After the deadline, anyone can settle it, and the contract compares the feed's reported price to the target and writes Hit or Miss permanently.

Everything a client needs is emitted as events. There is no off-chain database of record, no API key, and no paid data dependency.

Permission surface. The only thing a user ever signs is a call. The contract has no transferFrom, no approve flow, and no payable function.

Network

KIV is deployed on Robinhood Chain, an Arbitrum Nitro Layer 2 that settles to Ethereum and uses ETH for gas. It is fully EVM compatible, so Foundry, Hardhat, viem, ethers and wagmi work unmodified.

PropertyMainnetTestnet
Chain ID466346630
Gas tokenETHETH
Public RPChttps://rpc.mainnet.chain.robinhood.comhttps://rpc.testnet.chain.robinhood.com
Explorerrobinhoodchain.blockscout.comexplorer.testnet.chain.robinhood.com
KIV contractPublished at launchPublished at launch

Public RPC is rate limited. Fine for development and for a client reading one record at a time. For indexing or anything production-facing, use a provider endpoint — Alchemy, Chainstack, QuickNode and dRPC all support the chain.

Quickstart

Add the chain, point a client at it, and read a record. No account, no key.

client.ts
import { createPublicClient, http, defineChain } from 'viem'

export const robinhoodChain = defineChain({
  id: 4663,
  name: 'Robinhood Chain',
  nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
  rpcUrls: { default: { http: ['https://rpc.mainnet.chain.robinhood.com'] } },
  blockExplorers: {
    default: { name: 'Blockscout', url: 'https://robinhoodchain.blockscout.com' },
  },
})

export const client = createPublicClient({
  chain: robinhoodChain,
  transport: http(),
})

Data model

One struct, stored in an append-only array. The array index is the call ID, which is also what a public record URL points at.

Call
enum Status { Open, Hit, Miss }

struct Call {
    address author;      // who signed it
    address feed;        // Chainlink aggregator for the asset
    int256  target;      // price level, in the feed's decimals
    bool    above;       // true = "closes above", false = "below"
    uint64  deadline;    // unix seconds
    uint64  sealedAt;    // block.timestamp at creation — the proof
    int256  settledAt;   // price written at settlement, 0 while Open
    Status  status;
}

Why sealedAt is the whole product

A balance snapshot proves nothing, because balances can be borrowed for a single block. A call is different: it has to be published before the market answers. sealedAt is written by the chain, not by the author, so a record cannot be backdated by anyone — including whoever operates KIV.

Contract reference

makeCall

solidity
function makeCall(
    address feed,
    int256  target,
    bool    above,
    uint64  deadline
) external returns (uint256 id);

Appends a call and emits CallMade. Reverts if the deadline is not in the future, if the feed is not on the allow-list, or if the deadline is further out than MAX_HORIZON.

settle

solidity
function settle(uint256 id) external;

Callable by anyone once the deadline has passed. Reads the feed, writes the status and the settled price, and emits Settled. Reverts if the call is already settled, if the deadline has not passed, or if the feed has not updated since the deadline.

settleMany

solidity
function settleMany(uint256[] calldata ids) external;

Settles a batch, skipping any ID that is not yet settleable instead of reverting the whole transaction. This is what the scheduler calls.

Views

FunctionReturns
getCall(uint256 id)The full Call struct.
callCount()Total calls ever made. IDs run 0 … callCount-1.
record(address who)(uint32 hits, uint32 misses, uint32 open) for one author.
isSettleable(uint256 id)bool — deadline passed and the feed is fresh enough.

Events

solidity
event CallMade(
    uint256 indexed id,
    address indexed author,
    address indexed feed,
    int256 target,
    bool above,
    uint64 deadline,
    uint64 sealedAt
);

event Settled(
    uint256 indexed id,
    address indexed author,
    int256 price,
    bool hit
);

These two events are a complete replica of protocol state. Any client can rebuild every record and every hit rate from logs alone, with no dependency on KIV's servers.

Settlement

Settlement is deliberately dumb. It reads one number and compares it to another.

KivCalls.sol
function settle(uint256 id) public {
    Call storage c = calls[id];
    if (c.status != Status.Open)        revert AlreadySettled();
    if (block.timestamp < c.deadline)   revert TooEarly();

    (, int256 price,, uint256 updatedAt,) =
        AggregatorV3Interface(c.feed).latestRoundData();

    // the feed must have moved on since the deadline,
    // otherwise a frozen oracle could decide the result
    if (updatedAt < c.deadline)         revert StaleFeed();
    if (price <= 0)                     revert BadPrice();

    bool won  = c.above ? price > c.target : price < c.target;
    c.settledAt = price;
    c.status    = won ? Status.Hit : Status.Miss;

    unchecked { won ? tally[c.author].hits++ : tally[c.author].misses++; }
    emit Settled(id, c.author, price, won);
}

Who settles

KIV runs a scheduler that sweeps expired calls and batches them through settleMany. It is a convenience, not a dependency — the function is unrestricted, so a user, a rival client, or a bot can settle any call at any time after its deadline. If KIV shuts down tomorrow, every open record can still be resolved by anyone holding a few cents of ETH.

Ties

A call resolves on a strict inequality. If the settled price lands exactly on the target, an "above" call is a miss. This is stated plainly in the UI when a call is written, because a silent tie rule is the kind of thing people argue about later.

Price feeds

Robinhood Chain publishes a Chainlink feed for each Stock Token as well as feeds for major crypto pairs. Read the price on-chain; do not settle against an HTTP endpoint.

The multiplier trap. Stock Tokens handle dividends and splits through an on-chain multiplier exposed as uiMultiplier() under ERC-8056. Robinhood's /rhj/prices endpoint returns the underlying equity price, which is not the same number as the token price once a corporate action has occurred. The token's Chainlink feed already incorporates the multiplier. Settle against the feed and a split can never flip a hit into a miss.

Discovering assets

The public asset list is free and needs no key. Each entry carries a tradingCapabilities field describing whether the asset trades during market hours, extended hours and overnight — useful for validating that a deadline falls in a session where the asset actually prices.

shell
curl https://api.robinhood.com/rhj/assets

Feed decimals

Targets are stored in the feed's own decimals, which is typically 8. A target of 190.00 is submitted as 19000000000. Always call decimals() on the aggregator rather than assuming — the UI does this and so should any client.

Writing a call

makeCall.ts
import { parseUnits } from 'viem'
import { client, robinhoodChain } from './client'

const decimals = await client.readContract({
  address: feed, abi: aggregatorAbi, functionName: 'decimals',
})

const target   = parseUnits('190.00', decimals)
const deadline = BigInt(Math.floor(Date.parse('2026-09-25T21:00:00Z') / 1000))

const hash = await wallet.writeContract({
  address: KIV,
  abi: kivAbi,
  functionName: 'makeCall',
  args: [feed, target, true, deadline],
  chain: robinhoodChain,
})

const receipt = await client.waitForTransactionReceipt({ hash })

Pull the new ID out of the CallMade log rather than guessing it from callCount(), which can move between your read and your write.

Reading records

A profile is record(address) plus the author's logs. For a single wallet, filtering logs is enough; no indexer is needed until you are ranking thousands of addresses.

record.ts
const [hits, misses, open] = await client.readContract({
  address: KIV, abi: kivAbi, functionName: 'record', args: [who],
})

const logs = await client.getContractEvents({
  address: KIV,
  abi: kivAbi,
  eventName: 'CallMade',
  args: { author: who },
  fromBlock: DEPLOY_BLOCK,
})

const settled = hits + misses
const rate = settled ? hits / settled : null   // null, never 0 — an empty record is not a bad one

Display rule. Do not show a hit rate below five settled calls. Three out of three is noise, and presenting it as 100% turns a record into a marketing surface, which is the thing KIV exists to replace.

Errors

ErrorCauseFix
TooEarly()Deadline has not passed.Wait. isSettleable tells you when.
AlreadySettled()Someone settled it first.Read the Settled log; the result stands.
StaleFeed()Oracle has not updated since the deadline.Retry later. The call stays open, not void.
BadPrice()Feed returned zero or negative.Retry. Never settle off a fallback price.
FeedNotAllowed()Feed is not on the allow-list.Use a listed asset.
HorizonTooLong()Deadline beyond MAX_HORIZON.Shorten it. Long horizons outlive feeds.

Known limits

KIV is an independent project built on Robinhood Chain. It is not affiliated with, endorsed by, sponsored by, or partnered with Robinhood Markets, Inc. or any of its subsidiaries. Robinhood Chain and related marks belong to their respective owners.

Nothing in this documentation or in the product is investment, legal or tax advice, and nothing here is a recommendation to buy or sell any asset. A published call records an opinion; it is not a forecast endorsed by KIV. Hit rates describe past calls only and say nothing about future results. Tokenized equities carry significant risk including total loss of capital and are unavailable in some jurisdictions. Smart contracts can contain defects.