Pipeshift
The settlement layer for tokenized equities on Robinhood Chain. Atomic delivery versus payment, multilateral netting, and one canonical registry every venue settles against.
Overview
Venues are built to match orders. Settlement is a different problem, and every venue that builds its own settlement path builds its own way to break. Pipeshift is that second half, extracted into a layer venues share.
Atomic DVP
The security leg and the cash leg move in one transaction. There is no state where one moved and the other did not.
Multilateral netting
A desk that buys 400 and sells 380 owes 20. Settlement work scales with participants, not with trades.
Canonical registry
One record per underlying, keyed by ticker and ISIN. A reissued token keeps its identity and its history.
What Pipeshift is not
- Not a venue. Matching is out of scope by design. An order book would put us in competition with the venues we serve.
- Not a custodian. The registry records who custodies. It does not custody.
- Not an oracle. No mark, no price feed, no view on value. The consideration is whatever the venue matched at.
The engines hold no positions between calls. Every function either moves value inside one transaction or reads state. There is no custody balance to drain and no idle inventory to lose.
Architecture
Three contracts, no external imports on the settlement path.
| Contract | Responsibility | State held |
|---|---|---|
AssetRegistry |
Canonical record per underlying, halt and delist controls, custodian of record | Securities and their listing state |
SettlementEngine |
Atomic DVP for matched trades, single and batched | Affirmed instructions, nothing else |
NettingEngine |
Multilateral netting sessions with on chain balance enforcement | Counters only |
Flow
venue matches a trade
|
|-- affirm(instruction) ------> instruction stored, nothing moves yet
| |
| v
| settle(id)
| |
| +----------------+----------------+
| v v
| security leg moves cash leg moves
| seller --> buyer buyer --> seller
| +----------------+----------------+
| v
| both legs land, or neither does
|
+-- many trades --> netTrades() --> settleSession(session)
|
v
one net transfer per party per leg
Trust assumptions
The registry owner can list, halt and delist securities. That key decides what is settleable, which makes it the most sensitive object in the system. Ownership transfer is two step: a nominee has to accept, so control cannot be handed to an address nobody holds.
Venues are allowlisted, and a venue can affirm instructions naming any two parties. It cannot move value: settlement still requires both parties to have approved the engine. The worst a malicious venue can do is fill the instruction table with entries that will never settle, bounded by their deadlines.
Anyone can call settle. This is deliberate. Settlement is not a privilege, and
letting a counterparty or a third party push it through removes the venue as a liveness
dependency.
Quickstart
Contracts
# clone with submodules, forge-std is pinned
git clone --recurse-submodules https://github.com/pipeshiftprotocol/pipeshift.git
cd pipeshift/contracts
forge test
SDK and CLI
cd ../sdk-ts
npm install
npm test
# netting report from a trade file, offline
node dist/cli.js net examples/session.json
Against a real node
# starts a local node, deploys, settles, tears it down
npm run e2e:full
# same suite against a fork of Robinhood Chain
npm run e2e:fork
Settlement
An instruction is a matched trade with an expiry. It names the security by canonical id, the cash token, both parties, both amounts, and the venue that matched it.
struct Instruction {
bytes32 security; // canonical registry id
address cash; // token used for the payment leg
address seller; // delivers the security leg
address buyer; // delivers the cash leg
uint256 quantity; // security base units
uint256 consideration; // cash base units
uint64 deadline; // unix seconds
address venue; // who matched it
}
The id is keccak256 over all eight fields, computed identically on chain and in
the SDK. Changing any field produces a different instruction, so an amended trade is a new
instruction rather than a mutation of an existing one.
Instruction lifecycle
None --affirm--> Affirmed --settle--> Settled
|
|--cancel--> Cancelled
|
+--deadline passes--> unsettleable
Affirmed is the only state from which settlement is possible. A settled
instruction cannot settle again: the second call reverts with the current status attached,
which makes replay a non issue rather than a mitigation.
Cancellation is available to the submitting venue and to either party. A counterparty who no longer wants to settle does not need the venue's cooperation to stop it.
Why affirm and settle are separate
A venue knows a trade is matched immediately. It does not know when both counterparties will be funded. Collapsing the two calls would force the venue to wait for funding before recording the match, which pushes the record of truth off chain. Splitting them means the match is recorded cheaply and immediately, and settlement is a separate permissionless call anyone can trigger once funding is in place.
Atomicity
The property the engine exists for: if the cash leg fails, the security leg does not move.
function _settle(bytes32 id) private {
Status status = _status[id];
if (status != Status.Affirmed) revert NotAffirmed(id, status);
Instruction memory ins = _instructions[id];
if (block.timestamp > ins.deadline) revert InstructionExpired(id, ins.deadline);
if (!registry.isSettleable(ins.security)) revert SecurityNotListed(ins.security);
_status[id] = Status.Settled; // before the transfers, on purpose
IERC20(securityToken).safeTransferFrom(ins.seller, ins.buyer, ins.quantity);
IERC20(ins.cash).safeTransferFrom(ins.buyer, ins.seller, ins.consideration);
}
The status advances before the transfers, which looks wrong and is correct. Both transfers revert on failure and a revert unwinds the status change with them. Writing it first costs nothing and removes any window in which a reentrant call could see a settled trade as still affirmed.
Batches inherit this. settleBatch reverts entirely if any instruction in it
fails, so a venue submitting twenty instructions gets twenty settlements or gets none and a
clear reason. Partial batches are the kind of outcome that becomes a reconciliation project
three days later.
Token behaviour
Some tokenized equity wrappers predate the ERC20 return value convention and return no data
at all from transfer. Requiring a bool from those tokens reverts a
transfer that actually succeeded.
function _succeeded(bool ok, bytes memory data) private pure returns (bool) {
if (!ok) return false;
if (data.length == 0) return true; // no return value counts as success
if (data.length != 32) return false;
return abi.decode(data, (bool));
}
A test settles a full trade against a token that returns nothing, because a compatibility claim without one is worthless.
Netting
Gross settlement moves value once per trade, twice counting both legs. A session with twelve thousand trades between forty desks moves twenty four thousand times, and most of that movement cancels.
Sessions
A session is one security, one cash token, and one net position per party. Deltas are signed: negative delivers, positive receives. The engine requires both legs to sum to zero and reports the residual when they do not, because applying an unbalanced session would create or destroy value.
// collection first, payout second
for each party with a negative delta: transfer in
for each party with a positive delta: transfer out
The reverse order would have the engine paying out before it is funded, which works only if someone else's balance happens to cover the gap. Collecting first means a short collection reverts the whole session and the engine never operates on credit.
A party whose net position is zero on both legs moves nothing. The SDK drops those legs
before submission with withoutFlatLegs, so a desk that traded all session and
ended flat costs no gas at settlement. In a busy session that is most participants.
Reproducibility
netTrades sorts legs by party address, so the same trade set always produces
the same session regardless of the order trades arrived in. Two venues computing the same
session independently get byte identical results, which is a precondition for aggregating
sessions across venues.
Compression
Real output from the CLI over a four trade file:
trades in 4
parties 3
parties moving 2
transfers gross 8
transfers net 4
compression 50.00%
And over a range with two settlements and fifteen thousand one hundred netted trades:
settlements 2
netted trades 15100
transfers 24
if settled gross 30204
compression 99.92%
Netting compresses movement, not obligation. Every party ends with exactly the position gross settlement would have given them. If that is not true, the session did not sum to zero and the engine rejected it.
Asset registry
Two venues can list the same underlying under different token addresses. Without a shared record, a trade matched on one venue and settled against the other's token looks valid to both and is wrong. The registry makes the mapping explicit and enforces it in both directions.
Canonical identity
function idOf(bytes12 ticker, bytes12 isin) public pure returns (bytes32) {
return keccak256(abi.encodePacked(ticker, isin));
}
Twelve bytes fits an ISIN exactly and any listed ticker comfortably. Deriving identity from the instrument rather than from the wrapper means a reissued token keeps the same canonical id, so settlement history stays attributable to the instrument.
One canonical id per underlying, and one token address per canonical id. Listing the same underlying twice reverts. Mapping a second id onto a token that already has one reverts.
Listing states
| State | Settleable | Reversible |
|---|---|---|
None | no | never listed |
Active | yes | yes |
Halted | no | back to active |
Delisted | no | terminal |
Halting is for corporate actions and market stops. Delisting is terminal, because a delisted instrument that could be un-delisted would leave open instructions in an ambiguous state, and ambiguity in a settlement layer is a defect rather than flexibility.
Both engines check isSettleable at settlement time, not at affirm time. An
instruction affirmed before a halt does not settle during it, which is the point of having
a halt.
Proof of reserves
A tokenized share is only worth its underlying if someone holds the underlying. The registry records who custodies. It does not record how much that custodian holds, or when it last counted.
struct Attestation {
uint256 reserves; // units held, in the security's decimals
uint64 asOf; // when the custodian counted
address custodian; // who signed for it
bytes32 evidence; // hash of the off chain statement
uint64 submittedAt; // when it reached the chain
}
The time a custodian counted and the time it reported are different numbers, and the gap between them is what a venue cares about. Storing only the block timestamp would let a custodian submit a month old count and have it read as current.
Statements must move forward. A backdated count cannot overwrite a newer one, and nobody can claim to have counted in the future. Only the custodian of record can attest, and reassigning the custodian in the registry moves that right immediately.
This cannot verify a custody statement. The custodian says what it holds and signs for it. The contract timestamps and attributes that claim. No contract on any chain can audit a vault. What changes is that a false statement becomes signed, timestamped and attributable.
Venue policy
Risk appetite is not a protocol constant. A desk settling institutional flow may refuse anything older than an hour. A retail venue may accept a daily count. So each venue states its own policy and the guard answers one question: may this venue settle this security right now.
guard.setPolicy(1 hours, 10_000); // hourly count, full backing
if (!guard.maySettle(venue, security)) return;
settlementEngine.settle(instructionId);
When a check fails, explain names the reason rather than making an operator guess:
| Reason | Meaning |
|---|---|
no-policy | The venue never configured one. The guard fails closed. |
attestation-stale | No attestation, or the latest is older than the venue allows. |
under-collateralised | Coverage is below the venue's floor in basis points. |
An unconfigured venue gets false, not a permissive default. On this question a
missing answer has to mean no. The owner cannot loosen another venue's policy either, which
is what makes the guard worth having.
TypeScript SDK
npm install @pipeshift/sdk
Netting a trade file
import { compressionOf, netTrades, withoutFlatLegs } from "@pipeshift/sdk";
const session = netTrades(security, usdc, trades);
const report = compressionOf(session, trades.length);
console.log(`${report.grossTransfers} gross, ${report.netTransfers} netted`);
await client.settleSession(withoutFlatLegs(session), trades.length);
Settling a matched trade
import { PipeshiftClient, validate } from "@pipeshift/sdk";
const client = new PipeshiftClient({ publicClient, walletClient, deployment });
const problems = validate(instruction, now);
if (problems.length > 0) throw new Error(problems.join(", "));
const { id } = await client.affirm(instruction);
await client.settle(id);
validate returns every problem rather than the first, so a venue can fix a whole
batch in one pass instead of discovering issues one revert at a time.
Read only clients
A client constructed without a walletClient can read everything and write
nothing. Write calls throw ReadOnlyClientError instead of failing at the RPC
layer.
Every amount is bigint in base units. The SDK never accepts a
number for an amount, because a float that reaches a settlement figure is a
position break waiting to be found.
CLI
Every command is offline and read only. Nothing signs a transaction or touches a key, so it is safe to point at production data while reasoning about a session.
| Command | What it does |
|---|---|
pipeshift net <file> | Collapses a trade file into one net position per party |
pipeshift id <file> | Computes the settlement id of an instruction |
pipeshift validate <file> | Checks an instruction against the rules the engine enforces |
pipeshift security <ticker> <isin> | Computes the canonical registry id for an underlying |
$ pipeshift net examples/session.json
security 0x8fa6e2b2d6e2f8f9a1c4d3e5b7a9c1e3f5d7b9a1c3e5f7d9b1a3c5e7f9d1b3a5
cash 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
trades in 4
parties 3
parties moving 2
transfers gross 8
transfers net 4
compression 50.00%
party quantity cash
0x1111111111111111111111111111111111111111 -20000000000000000000 3910000000
0x2222222222222222222222222222222222222222 20000000000000000000 -3910000000
Amounts in every input file are decimal strings in base units. A number literal is rejected rather than parsed.
Indexer
The engines emit two events. InstructionSettled when a matched trade moves both
legs, and SessionSettled when a netting session collapses many trades. The
indexer turns those into positions, per security totals and compression reports.
npm install @pipeshift/indexer
export PIPESHIFT_RPC_URL=https://rpc.mainnet.chain.robinhood.com
export PIPESHIFT_CHAIN_ID=4663
export PIPESHIFT_SETTLEMENT_ENGINE=0x...
export PIPESHIFT_NETTING_ENGINE=0x...
export PIPESHIFT_ASSET_REGISTRY=0x...
pipeshift-index fetch events.json 8400000
pipeshift-index summary events.json
fetch walks the range in chunks and halves a chunk whenever the provider rejects
it, so an endpoint with an undocumented limit degrades into more requests rather than an
error. A range past the head is clamped instead of rejected.
Two design notes
-
Sessions do not name their counterparties.
SessionSettledreports how many legs moved and how many trades that represented, not who was on each side. Sessions therefore contribute to volume and compression, never to positions. Splitting a session across desks would mean inventing counterparties the log does not name. -
Amounts come from storage, not from the log.
InstructionSettledindexes the parties but not the amounts. The indexer reads them back and caches per instruction id. A log whose amounts cannot be resolved is skipped rather than recorded with zeros.
Integrate a venue
- Get allowlisted on the settlement engine, and on the netting engine if you intend to submit sessions.
- Confirm the securities you trade are listed and active in the registry. An unlisted underlying cannot be affirmed against.
- Confirm your cash token is accepted. An unaccepted token fails at affirm, not at settle.
- Have both counterparties approve the engine for the tokens they deliver. The engine moves value with
transferFromand holds no allowance it does not use. - Affirm as soon as you match. Settle when funding is in place, or let a counterparty settle.
- For end of session flow, compute the session with
netTrades, drop flat legs, and submit once.
Settlement is permissionless: the caller pays gas. There is no protocol fee on the settlement path today, and any fee introduced later will be documented here before it exists on chain.
Deployment
The registry deploys first. Both engines take its address as an immutable constructor argument, so it cannot be swapped later. An engine that could be repointed at a different registry could be repointed at one that lists an attacker's token under a real ticker.
export PIPESHIFT_OWNER=0x... # multisig, not an EOA
export PIPESHIFT_RPC_URL=https://...
cd contracts
forge script script/Deploy.s.sol \
--rpc-url "$PIPESHIFT_RPC_URL" \
--broadcast \
--verify
Order after deploying
- Verify the owner is the multisig, not the deploying key.
- List securities. Nothing settles until an underlying is listed and active.
- Accept cash tokens. An unlisted cash token cannot be affirmed against.
- Register venues last, because a registered venue with nothing listed can do nothing.
Testing
Unit tests establish that the logic is right. They do not establish that the code works against a chain, which is a different claim and needs a different kind of test.
| Suite | Count | Runs against |
|---|---|---|
| Settlement contracts, Foundry | 38 | EVM, including fuzz |
| Attestations, Foundry | 36 | EVM, including fuzz |
| SDK | 23 | The built package |
| Indexer | 30 | Pure functions over event arrays |
| End to end | 14 | A real node, and a fork of Robinhood Chain |
Invariants the suites defend
- A failed cash leg leaves the security leg unmoved and the instruction still affirmed.
- A batch settles fully or reverts fully. No partial batch.
- A netting session that does not sum to zero on both legs is rejected, never partially applied.
- The netting engine holds no residual once a session closes.
- Token supply is conserved across settlement, checked by fuzzing on both engines.
- A halted security cannot settle, including instructions affirmed before the halt.
- An attestation cannot be backdated over a newer one, and cannot be dated in the future.
- A venue with no policy never settles.
Errors
Custom errors carry the values needed to act on them, rather than a string to grep.
| Error | Raised when |
|---|---|
NotAVenue(address) | A caller that is not allowlisted tried to affirm or submit a session |
UnknownInstruction(bytes32) | The id has never been affirmed |
AlreadyAffirmed(bytes32) | The same instruction was affirmed twice |
NotAffirmed(bytes32, Status) | Settlement or cancellation from a state that does not allow it |
InstructionExpired(bytes32, uint64) | The deadline has passed |
SecurityNotListed(bytes32) | The security is unlisted, halted or delisted |
CashNotAccepted(address) | The cash token is not on the allowlist |
SelfTrade(address) | Seller and buyer are the same address |
QuantityDoesNotNet(int256) | A session's security leg does not sum to zero, residual attached |
CashDoesNotNet(int256) | A session's cash leg does not sum to zero, residual attached |
DuplicateParty(address) | A session lists the same party twice |
TransferFailed(address, address, address, uint256) | A token transfer failed or returned a false value |
StaleAsOf(uint64, uint64) | An attestation is not newer than the stored one |
AsOfInFuture(uint64) | An attestation claims a count in the future |
NotCustodian(address, address) | Someone other than the custodian of record tried to attest |
Events
| Event | Emitted when |
|---|---|
InstructionAffirmed(bytes32, address, bytes32) | A venue records a match |
InstructionSettled(bytes32, address, address) | Both legs moved |
InstructionCancelled(bytes32, address) | A venue or a party stopped it |
SessionSettled(uint256, bytes32, uint256, uint256) | A netting session applied |
SecurityListed(bytes32, address, bytes12) | An underlying entered the registry |
SecurityHalted(bytes32, string) | Settlement stopped, with a reason |
Attested(bytes32, address, uint256, uint64) | A custodian published a count |
InstructionSettled indexes the parties, not the amounts. Amounts live in the
stored instruction and have to be read back. The indexer does this for you.
Environment
| Variable | Used by | Notes |
|---|---|---|
PIPESHIFT_RPC_URL | SDK, indexer, scripts | Node endpoint |
PIPESHIFT_CHAIN_ID | SDK, indexer | Robinhood Chain is 4663 |
PIPESHIFT_OWNER | Deploy scripts | Multisig that owns the registry and engines |
PIPESHIFT_ASSET_REGISTRY | SDK, indexer, attestations | Deployed registry address |
PIPESHIFT_SETTLEMENT_ENGINE | SDK, indexer | Deployed engine address |
PIPESHIFT_NETTING_ENGINE | SDK, indexer | Deployed engine address |
PIPESHIFT_FROM_BLOCK | Indexer | First block to read, defaults to 0 |
PIPESHIFT_FORK_URL | End to end runner | Fork a real network instead of a bare devnet |
Limits
Being explicit about scope is cheaper than being discovered.
- The contracts are unaudited.
- Settlement is all or nothing per instruction. Real inventory arrives late and in pieces, so partial settlement with explicit priority rules is planned, and each partial settlement will be atomic across both legs or it will not ship.
- Netting is per venue, because a venue submits the session it computed. Two venues trading the same underlying against overlapping desks still settle gross between themselves. Cross venue aggregation is where compression gets genuinely interesting and it is not built.
- Proof of reserves has no dispute mechanism. A custodian that misstates its holdings produces a signed and timestamped record, which is a matter for counsel rather than for a contract.
- A session is scoped to one security and one cash token. Netting across securities would require a price to be correct, and pricing is not our job.
- The indexer reads over plain JSON-RPC. There is no websocket subscription and no reorg handling: a range is read once, as it stood at the head when asked.