QUIVER

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.

bash · read everything about a stream
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 $RPC

Both sides of an address

bash · what an address collects, and what it funded
# 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 $RPC

Decoding 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:

bash
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.

typescript · read a stream with viem
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

This repo generates it. 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.

solidity · a gate that only accepts irrevocable streams
// 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.

bash
cast interface ./quiver-abi.json -n IQuiver

Watching 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.

bash · every stream ever created
cast logs --rpc-url $RPC --address $QUIVER \
  "StreamCreated(uint256,address,address,address,uint128,uint40,uint40,uint40,bool)" \
  --from-block 0

id, 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.

typescript · the same three branches as the contract
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.