Status: spec (architect, cycle 2).
Goal: an account can open a continuous, real-time stream of UBI to another account at a chosen
rate; the recipient's balance drips upward and the sender's locked deposit drips downward live (read
at wall-clock now, exactly like the protocol's 1-UBI/hour emission). Streams can be stopped early.
This extends the UBI drip from protocol→human into human→human — the "constantly rewarding" flow.
This builds directly on M1's deterministic, time-based balance model and its EIP-155 transaction path.
Opening a stream locks a deposit from the sender's spendable balance into the stream. The recipient
accrues rate × elapsed, capped at deposit. The stream auto-completes when the deposit is drained
at t_end = started_at + deposit / rate. This makes streams solvent by construction — a stream can
never pay out more than was locked, so there is no insolvency/over-draw attack and no need to predict the
sender's future balance. (Uncollateralized "stream-through" of live UBI inflow is a deferred follow-up.)
To keep standard wallets (MetaMask) able to sign stream operations, an op is a normal EIP-155 tx sent
to the reserved system address 0x0000000000000000000000000000000000005742 ("StreamHub") with ABI-
encoded calldata. The node recognizes this address and parses the calldata; M1's "reject non-empty
calldata" rule is relaxed only for txs to StreamHub. No general EVM is introduced — the node decodes a
small fixed set of 4-byte selectors. value on these txs is 0 (the deposit is an argument, not msg.value,
so wallets needn't fund gas-vs-value confusion; see open question Q1).
Selectors (Solidity-style, so wallets/tooling can encode them):
openStream(address to, uint256 ratePerSec, uint256 deposit)→ selectorkeccak("openStream(address,uint256,uint256)")[..4]stopStream(uint256 id)→ selector ofstopStream(uint256)
The node assigns a sequential id and emits it (see RPC). Reads use custom ubi_* methods (below); balances
reflect streams through the standard eth_getBalance.
The live balance gains net stream flow, all integer math:
balance(a, now) = settled_balance(a)
+ emission_since_settle(a, now) # 1 UBI/hr if verified (M1)
+ Σ accrued(s, now) for s in incoming_active(a) # streams paying a
# (sender's deposit already removed at open)
accrued(s, now) = min( s.rate * (min(now, s.end_or_stop) - s.started_at), s.deposit_remaining )
Two nodes computing balance(a, now) agree to the base unit. No floats.
Opening a stream mints two ERC-721 tokens so both parties see it in a standard wallet:
- the recipient token →
tokenId = streamId, owner =to(the canonical "incoming stream" claim); - the sender receipt →
tokenId = streamId | SENDER_FLAG(top bit,1 << 255), owner =from.
This keeps ownership stateless and deterministic — ownerOf(tokenId) decodes the flag: high bit set
→ sender token of stream tokenId & ~SENDER_FLAG, owner from; else recipient token, owner to.
balanceOf(addr) = (# streams where addr is to) + (# where addr is from). StreamHub
(0x…5742) doubles as the ERC-721 collection "ubi2 Streams" (USTREAM). The node answers the ERC-721
view methods as a precompile inside eth_call on StreamHub (M1's eth_call → 0x relaxed there
only): supportsInterface, name, symbol, balanceOf(owner), ownerOf(tokenId), tokenURI(tokenId).
tokenURI returns a fully on-chain data:application/json;base64,… document whose image is an inline
data:image/svg+xml;… card (see "Streams as NFTs" below). Open emits two mints:
Transfer(0x0, to, streamId) and Transfer(0x0, from, streamId|SENDER_FLAG). Both tokens are soulbound
in M2 (transfer reverts); transferable streams where the flow follows the NFT owner are deferred.
StreamId = u64 // sequential, assigned at open
Stream {
id: StreamId,
from: Address,
to: Address,
rate: u128, // base units per second
deposit: u128, // total locked at open
drawn: u128, // amount already settled to `to` (folded into to.settled_balance)
started_at: u64, // unix secs
status: Active | Stopped(at: u64) | Completed,
}
State additions: a streams: Map<StreamId, Stream> registry + per-account indexes outgoing: [StreamId],
incoming: [StreamId], and a next_stream_id counter. Account keeps M1 fields.
- Validate:
from != to,rate > 0,deposit > 0,deposit % rate == 0recommended (cleant_end; else last tick is partial — allowed, capped by deposit), andrate ≤ MAX_RATE(anti-grief bound). settle(from, now); requirefrom.settled_balance ≥ deposit; subtractdepositfromfrom.settled_balance(lock it).- Create
Stream{ id: next_stream_id++, ..., drawn: 0, started_at: now, status: Active }; index it. - Fail-closed: any error leaves state untouched (no partial writes).
owed = min(s.rate * (min(now, end_or_stop(s)) - s.started_at), s.deposit) - s.drawnsettle(to, now);to.settled_balance += owed;s.drawn += owed.- If
s.drawn == s.deposit→status = Completed.
- Only
from(orto?) may stop — M2: onlyfrommay cancel; either party may let it run. (Q2) settle_stream(s, now)to pay accrued; setstatus = Stopped(now); refunds.deposit - s.drawntofrom.settled_balance(aftersettle(from, now)).
A stream needs no tx to complete: once now ≥ t_end, accrued is capped at deposit, so reads already
show the final value. A lazy settle_stream on the next interaction (or block tick sweep) folds it in and
marks Completed + (no refund; fully drawn).
- Write (via
eth_sendRawTransactionto StreamHub, signed by the wallet):openStream,stopStreamparsed from calldata. The tx receipt's logs include the assignedid(emit aStreamOpened/StreamStoppedlog soeth_getTransactionReceiptcarries it; standard EVM log shape). - Read (custom, for our SDK/wallet — third-party wallets only need balances):
ubi_getStreams(address)→{ outgoing: [StreamView], incoming: [StreamView] }ubi_getStream(id)→StreamViewStreamViewincludesid, from, to, rate, deposit, drawn, started_at, status, accrued_now, t_end.
eth_getBalancecontinues to reflect everything live (now including net streams).- ERC-721 view precompile in
eth_call(StreamHub only): decode the selector and return ABI-encoded results forsupportsInterface(bytes4)(true for0x01ffc9a7ERC165 +0x80ac58cdERC721 +0x5b5e139fERC721Metadata),name()→"ubi2 Streams",symbol()→"USTREAM",balanceOf(address)(count of streams owned),ownerOf(uint256)(stream recipient; revert for unknown/none), andtokenURI(uint256)(the data-URI document below). These let MetaMask "Import NFT" (StreamHub + id) and render the card.
Why: a stream becomes a first-class object the user can see and inspect in any standard wallet, with its live conditions rendered on the token itself. No external metadata host — everything is on-chain.
data:application/json;base64,<base64( json )> where json is:
attributes give marketplaces/wallets structured traits; the image is the human-readable card.
A 500×500 card using the ubi2 palette (bg #0b0b0f, panel #14141b, accent #6ee7b7, muted #9a9aa6),
system/monospace fonts only (no external fonts/images so it renders anywhere). Layout, top → bottom:
- Header — "ubi2" wordmark (accent "2") + "STREAM" label; right-aligned status pill (green
Active / amber Stopped / muted Completed) and
#<id>. - Flow row —
from(0xAB…12) →to(0xF3…66), truncated, with an arrow / drip-dots between them. - Headline — large mono " UBI" streamed so far, sub-line "of UBI".
- Progress bar — filled to
drawn/deposit(%), accent fill on a muted track. - Stats row — Rate "1.00 UBI / hr" · Started · Ends (or "ran dry"/"stopped").
- Footer — "real-time stream · chain 21826".
Optional: a subtle SMIL
<animate>drip accent that degrades gracefully where animation isn't rendered. The template is a pure function of the stream's fields + the read-timenow(so "streamed/progress" reflect the momenttokenURIis called). Keep the SVG small and escaped/valid. The card takes asideparam (incoming vs outgoing, derived from the SENDER_FLAG): same data, with a small "Incoming"/"Outgoing" badge and the viewer's own address emphasized — so each party's token reads naturally.
open_streammints two tokens:streamId→to, andstreamId | SENDER_FLAG→from; emit aTransfer(0x0, owner, tokenId)for each.ownerOf(tokenId)decodes the flag (sender token →from, recipient token →to);balanceOfcounts both sides;tokenURIrenders the card with the matchingsidebadge.- Soulbound in M2: no
transferFrom/approvestate; if called, the call/tx reverts with "soulbound". stop/completedo not burn either token (they stay as a record); the status on both cards flips.
- SDK:
openStream({to, ratePerSec, deposit})andstopStream(id)that build + send the StreamHub tx (encode selector + args; sign with the user's key/provider);getStreams(address); reuseprojectBalanceso streamed balances also tick per-frame. - Wallet: a "Send a stream" form (recipient, rate e.g. UBI/hour, and deposit or duration → deposit), plus Active streams lists (Outgoing / Incoming) with live-ticking accrued amounts and a Stop button on outgoing. The connected account's headline balance already reflects incoming streams.
- NFT surfacing: render the same on-chain card (fetch
tokenURI, show the SVG) next to each stream, and an "Add NFT to MetaMask" helper (wallet_watchAssetwithtype: ERC721, StreamHub address + id) so the user one-clicks the stream into their standard wallet.
- Collateralization limit: inherent (D1) — a stream can't exceed its deposit. ✅ M2
- Rate control:
MAX_RATEbound +rate > 0. ✅ M2 (basic) - Circuit breakers / multi-sig / conditional streams: deferred to a later milestone.
- Opening a stream locks the sender's deposit (sender spendable drops by
depositimmediately) and is signable/sendable as an EVM tx to StreamHub by a standard signer. - The recipient's
eth_getBalanceincreases atratein real time while the stream is active, and the sender's spendable balance does not double-count the locked deposit. - A stream pays out at most
deposit— aftert_end, the recipient's accrued from the stream stops at exactlydeposit(solvency; no over-draw). stopStreamby the sender pays the recipient the accrued-to-stop amount and refunds the unused deposit to the sender; totals are conserved (no UBI created or lost), to the base unit.- Reproducibility (I2): stream balances are identical across two nodes and across random timelines
(property test over
(rate, deposit, started_at, now, stop?)). ubi_getStreamsreturns correct in/out streams with liveaccrued_now; the wallet shows a stream dripping and lets the sender stop it.- NFT (both sides): opening a stream mints two ERC-721s —
streamId→recipient andstreamId|SENDER_FLAG→sender (bothTransfer(0x0,…)in the receipt);supportsInterface(0x80ac58cd)→ true;ownerOf(streamId)→recipient andownerOf(streamId|SENDER_FLAG)→sender;balanceOfcounts the right side for each party; transfer attempts revert ("soulbound"). - On-chain card:
tokenURI(id)returns a validdata:application/json;base64doc whose decoded JSON hasname,attributes, and animagedata:image/svg+xmlthat parses as valid SVG and shows the live streamed amount + progress + status (verified by decoding in a test). MetaMask "Import NFT" (StreamHub + id) renders it.
- Q1: deposit as a calldata arg vs.
msg.value. Decision: calldata arg (D2) — keeps gas/value semantics simple on devnet where gas isn't charged; the node debitsdepositfrom the sender on open. - Q2: may the recipient also cancel? Decision (M2): no — only the sender cancels; either party may let it run to completion. Revisit with conditional streams.
- Q3:
StreamIdasu64vsH256. Decision:u64sequential — simpler, fits logs as a topic.
1:many / many:1 fan-out, uncollateralized stream-through of live UBI inflow, conditional/programmable streams, circuit breakers, stream composition (split/merge). Multi-node consensus arrives with M3.
{ "name": "ubi2 Stream #<id>", "description": "A real-time UBI stream from <from> to <to> at <rate> UBI/hr. Fully on-chain on ubi2.", "image": "data:image/svg+xml;base64,<base64( svg )>", "attributes": [ { "trait_type": "Status", "value": "Active|Stopped|Completed" }, { "trait_type": "From", "value": "0x…" }, { "trait_type": "To", "value": "0x…" }, { "trait_type": "Rate", "value": "1.00 UBI/hr" }, { "trait_type": "Deposit", "value": "24.00 UBI" }, { "trait_type": "Streamed", "value": "6.50 UBI" }, { "display_type": "boost_percentage", "trait_type": "Progress", "value": 27 }, { "display_type": "date", "trait_type": "Started", "value": <unix> }, { "display_type": "date", "trait_type": "Ends", "value": <unix> } ] }