Reference
Integration
Everything about a stream is readable with a plain eth_call. No API key, no indexer, no backend, and no cooperation from us. This page is the working code to do it.
The read surface
Seven functions cover essentially every integration. All are views, all are free to call off-chain, and none require any permission.
- getStream(id)
- The whole struct: sender, schedule, token, deposited, withdrawn, flags.
- vestedOf(id)
- Released so far, including what has already been withdrawn.
- withdrawableOf(id)
- Claimable right now. Vested minus withdrawn.
- refundableOf(id)
- What cancelling right now would return to the sender.
- streamsOfHolder(a)
- The ids `a` currently collects. Follows transfers.
- streamsBySender(a)
- The ids `a` funded. Append-only, so it is a full history.
- ownerOf(id)
- Who can withdraw right now.
Two of these are unbounded
streamsOfHolder and streamsBySender loop over an entire list with no pagination. Off-chain, in an eth_call, that is free and fine. From a contract it is a liability: an address with enough streams pushes the call past the block gas limit. On-chain, use sentCount or balanceOf plus tokenOfOwnerByIndex and walk the list in bounded chunks.From a terminal, with cast
The fastest way to check anything. These are real commands against the live contract: stream ids start at 1, so 1 below is a stream that exists.
RPC=https://rpc.mainnet.chain.robinhood.com
QUIVER=0x2866D49f364a70383591a958354A89F6BDf050f2
# the full struct, in declaration order:
# sender, start, cliff, cancelable, canceled, token, end, deposited, withdrawn
cast call $QUIVER \
"getStream(uint256)((address,uint40,uint40,bool,bool,address,uint40,uint128,uint128))" \
1 --rpc-url $RPC
# the live numbers
cast call $QUIVER "vestedOf(uint256)(uint128)" 1 --rpc-url $RPC
cast call $QUIVER "withdrawableOf(uint256)(uint128)" 1 --rpc-url $RPC
cast call $QUIVER "refundableOf(uint256)(uint128)" 1 --rpc-url $RPC
# who collects it right now
cast call $QUIVER "ownerOf(uint256)(address)" 1 --rpc-url $RPCBoth sides of an address
# streams this address currently holds (reflects sales)
cast call $QUIVER "streamsOfHolder(address)(uint256[])" $ADDR --rpc-url $RPC
# streams this address funded (append-only history)
cast call $QUIVER "streamsBySender(address)(uint256[])" $ADDR --rpc-url $RPC
# the bounded version, safe to call from a contract
cast call $QUIVER "sentCount(address)(uint256)" $ADDR --rpc-url $RPCDecoding a revert
QUIVER reverts with custom errors, so a failure arrives as four bytes. Reading a stream that does not exist returns 0x77b9df30, which decodes to:
cast 4byte 0x77b9df30
# NoStream()The full list of errors and what triggers each is on the contract reference.
From a script, with viem
Robinhood Chain is not in viem/chains, so define it once. The id is 4663. This script runs as written.
import { createPublicClient, http, defineChain } from "viem";
const robinhood = defineChain({
id: 4663,
name: "Robinhood Chain",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.mainnet.chain.robinhood.com"] } },
});
const QUIVER = "0x2866D49f364a70383591a958354A89F6BDf050f2";
const abi = [
{
type: "function",
name: "getStream",
stateMutability: "view",
inputs: [{ name: "id", type: "uint256" }],
outputs: [{
type: "tuple",
components: [
{ name: "sender", type: "address" },
{ name: "start", type: "uint40" },
{ name: "cliff", type: "uint40" },
{ name: "cancelable", type: "bool" },
{ name: "canceled", type: "bool" },
{ name: "token", type: "address" },
{ name: "end", type: "uint40" },
{ name: "deposited", type: "uint128" },
{ name: "withdrawn", type: "uint128" },
],
}],
},
{
type: "function",
name: "withdrawableOf",
stateMutability: "view",
inputs: [{ name: "id", type: "uint256" }],
outputs: [{ type: "uint128" }],
},
] as const;
const client = createPublicClient({ chain: robinhood, transport: http() });
const stream = await client.readContract({
address: QUIVER,
abi,
functionName: "getStream",
args: [1n],
});
// viem decodes the tuple into named fields
console.log(stream.cancelable, stream.deposited, stream.withdrawn);
const claimable = await client.readContract({
address: QUIVER,
abi,
functionName: "withdrawableOf",
args: [1n],
});You do not have to hand-write the ABI
npm run abi rewrites lib/abi.ts from the Foundry artifact, and the frontend imports quiverAbi from there. A signature that changes in the contract then breaks the typecheck instead of breaking in production. Do the same on your side rather than transcribing signatures by hand.From another contract
Declare the struct yourself and the interface is self-contained. This compiles against Solidity 0.8.24, and the field order matters: it must match the contract exactly.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IQuiver {
struct Arrow {
address sender;
uint40 start;
uint40 cliff;
bool cancelable;
bool canceled;
address token;
uint40 end;
uint128 deposited;
uint128 withdrawn;
}
function getStream(uint256 id) external view returns (Arrow memory);
function vestedOf(uint256 id) external view returns (uint128);
function withdrawableOf(uint256 id) external view returns (uint128);
function ownerOf(uint256 id) external view returns (address);
}
contract StreamGate {
IQuiver public immutable quiver;
error StreamIsCancelable();
error NotYourStream();
constructor(address quiver_) {
quiver = IQuiver(quiver_);
}
/// Reverts unless the caller holds a stream its funder can no longer pull back.
function check(uint256 id) external view returns (uint128 remaining) {
IQuiver.Arrow memory a = quiver.getStream(id);
if (a.cancelable) revert StreamIsCancelable();
if (quiver.ownerOf(id) != msg.sender) revert NotYourStream();
return a.deposited - a.withdrawn;
}
}The cancelable check is the important line, not boilerplate. Any contract that treats a stream as collateral, or prices it, has to reject cancelable streams: their funder can cancel the unvested part out from under you at any moment, including in the block that follows yours.
Generating the interface instead
If you would rather not write it by hand, derive it from the ABI. That is how the interface above was checked.
cast interface ./quiver-abi.json -n IQuiverWatching for changes
If you would rather index than poll, four events carry the whole lifecycle: StreamCreated, Withdrawn, StreamCanceled and CancelRenounced. Add the ERC-721 Transfer event, since that is what changes who can withdraw.
cast logs --rpc-url $RPC --address $QUIVER \
"StreamCreated(uint256,address,address,address,uint128,uint40,uint40,uint40,bool)" \
--from-block 0id, sender and recipient are indexed on StreamCreated, so you can filter by any of them. Remember that recipient in that event is who the stream was minted to, not who holds it now. For the current holder, follow Transfer or just call ownerOf.
Reproducing the maths off-chain
To animate a counter between reads, mirror the contract's formula rather than calling it every second. This site does exactly that in lib/quiver.ts.
function vestedAt(a: Arrow, nowSec: number): bigint {
if (nowSec < a.cliff) return 0n;
if (nowSec >= a.end) return a.deposited;
const elapsed = BigInt(Math.max(0, nowSec - a.start));
const duration = BigInt(a.end - a.start);
if (duration === 0n) return a.deposited;
return (a.deposited * elapsed) / duration;
}
const withdrawable = vestedAt(a, now) - a.withdrawn;Use bigint, never floats, and integer division, so it truncates the way Solidity does. This is a display convenience only. The chain is the source of truth at the moment of withdrawal, and your number can be a few seconds off because the contract reads block.timestamp, not your clock.