Pipeshift DOCS
/
Documentation

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.
Read only

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.

ContractResponsibilityState 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));
}
Covered

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%
Note

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

StateSettleableReversible
Nonenonever listed
Activeyesyes
Haltednoback to active
Delistednoterminal

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.

Timing

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.

Limit

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:

ReasonMeaning
no-policyThe venue never configured one. The guard fails closed.
attestation-staleNo attestation, or the latest is older than the venue allows.
under-collateralisedCoverage 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.

Amounts

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.

CommandWhat 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. SessionSettled reports 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. InstructionSettled indexes 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

  1. Get allowlisted on the settlement engine, and on the netting engine if you intend to submit sessions.
  2. Confirm the securities you trade are listed and active in the registry. An unlisted underlying cannot be affirmed against.
  3. Confirm your cash token is accepted. An unaccepted token fails at affirm, not at settle.
  4. Have both counterparties approve the engine for the tokens they deliver. The engine moves value with transferFrom and holds no allowance it does not use.
  5. Affirm as soon as you match. Settle when funding is in place, or let a counterparty settle.
  6. For end of session flow, compute the session with netTrades, drop flat legs, and submit once.
Fees

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

  1. Verify the owner is the multisig, not the deploying key.
  2. List securities. Nothing settles until an underlying is listed and active.
  3. Accept cash tokens. An unlisted cash token cannot be affirmed against.
  4. 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.

SuiteCountRuns against
Settlement contracts, Foundry38EVM, including fuzz
Attestations, Foundry36EVM, including fuzz
SDK23The built package
Indexer30Pure functions over event arrays
End to end14A 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.

ErrorRaised 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

EventEmitted 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
Indexing

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

VariableUsed byNotes
PIPESHIFT_RPC_URLSDK, indexer, scriptsNode endpoint
PIPESHIFT_CHAIN_IDSDK, indexerRobinhood Chain is 4663
PIPESHIFT_OWNERDeploy scriptsMultisig that owns the registry and engines
PIPESHIFT_ASSET_REGISTRYSDK, indexer, attestationsDeployed registry address
PIPESHIFT_SETTLEMENT_ENGINESDK, indexerDeployed engine address
PIPESHIFT_NETTING_ENGINESDK, indexerDeployed engine address
PIPESHIFT_FROM_BLOCKIndexerFirst block to read, defaults to 0
PIPESHIFT_FORK_URLEnd to end runnerFork 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.