Olinea documentation
Olinea is a USDC vault on Arc whose releases are authorized by SLH-DSA-SHA2-128s (FIPS 205) signatures verified on-chain by Arc's post-quantum precompile. There is no owner, no admin key, no pause and no upgrade path.
Overview
The vault holds USDC through the ERC-20 interface at 0x3600000000000000000000000000000000000000 and releases it only for a 32-byte hash-based key fixed at deployment. That key is not an elliptic-curve key: breaking ECDSA does not forge a release.
Two details carry the design:
-
The precompile returns
falsefor a forged signature without reverting. A contract that checks only whether the call succeeded accepts every forgery. See Security model. -
The key signs exactly
keccak256(abi.encode(chainId, vault, to, amount, nonce)), so an authorization cannot be replayed on another chain, in another vault, for another recipient or amount, or twice in the same vault. See Authorization digest.
true before it moves anything.
Full size
Status
| Component | Status |
|---|---|
| PQ precompile ABI | Mapped from Circle's public arc-node source, then verified against Arc mainnet: real signature true; tampered signature, wrong key and wrong message all false |
| OlineaVault.sol | 19 Foundry tests green; its exact authorization digest verified against the live mainnet precompile (3/3) |
| OlineaFactory.sol | 12 Foundry tests green; one transaction per vault, no owner and nothing to administer |
| Vault console | Live against Arc mainnet — key derivation, the precompile proof and every read; the create, deposit and release path is verified end to end on a local chain |
| Arc mainnet deployment | Factory deployed — runbook |
| Independent audit | None |
Quickstart
Verifying is a read call — free, no wallet. You can check the whole authorization path against Arc mainnet before deploying anything.
From the browser
The vault console does the same without a terminal: 24 words become an SLH-DSA key in the browser, the key is proven free against Arc's precompile, and every release is checked on-chain before it is sent. Nothing is uploaded.
The console has one rule the CLI does not. Its 24 words go through PBKDF2 then HKDF — salt olinea/slh-dsa/v1, info vault/<index> — to make the 48-byte seed slh_dsa_sha2_128s.keygen takes.
noble calls that keygen a library hook, not a FIPS 205 derivation. So the version is pinned and the salt is versioned: a backup made under a later salt will not open a vault made under this one. A different index gives an unrelated vault from the same backup.
# the vault and its test suite
git clone https://github.com/thesithunyein/olinea
cd olinea/contracts && forge test # 31 tests
# post-quantum tooling
cd ../scripts && npm install
node pq.mjs keygen # SLH-DSA keypair -> scripts/keys/pq-key.json
node pq.mjs conformance # real mainnet precompile, no wallet
Keep the key. keygen writes it to scripts/keys/pq-key.json (gitignored). Lose it and the funds in that vault are unrecoverable — there is no admin escape hatch.
OlineaVault reference
Constructor
constructor(IUSDC usdc_, bytes memory verifyingKey_, address verifier_)
// usdc_ Arc USDC ERC-20 (6 decimals)
// verifyingKey_ 32-byte SLH-DSA-SHA2-128s verifying key; any other length reverts
// verifier_ address(0) -> the canonical Arc precompile 0x1800..0004
verifier_ exists only so tests can inject a fake verifier. Production passes address(0).
Read
| Function | Returns |
|---|---|
usdc() | The USDC ERC-20 address the vault holds |
pqVerifier() | The address used for verification — the canonical precompile unless overridden |
verifyingKey() | The 32-byte key that authorizes releases |
balance() | USDC held, in the 6-decimal ERC-20 unit |
nonceUsed(uint256) | Whether a nonce has been consumed |
authorizationDigest(address to, uint256 amount, uint256 nonce) | The exact 32 bytes a release must be signed over |
Write
| Function | Effect |
|---|---|
deposit(uint256 amount) | Pulls USDC from the caller. Open to anyone; a deposit grants no rights over the balance |
release(address to, uint256 amount, uint256 nonce, bytes signature) | Transfers USDC for a valid authorization. Anyone may relay; the relayer gains nothing |
// canonical signatures, for cast, viem or ethers
deposit(uint256)
release(address,uint256,uint256,bytes)
Events and errors
event Deposited(address indexed from, uint256 amount);
event Released(address indexed to, uint256 amount, uint256 nonce, bytes32 digest);
error InvalidVerifyingKeyLength(uint256 length);
error InvalidRecipient();
error ZeroAmount();
error InsufficientBalance(uint256 requested, uint256 available);
error NonceAlreadyUsed(uint256 nonce);
error PrecompileCallFailed();
error InvalidPostQuantumSignature();
error TransferFailed();
InvalidPostQuantumSignature is the one you will see when an authorization is
wrong. PrecompileCallFailed means the call itself reverted — malformed input, not
a bad signature.
Authorization digest
The signing key never touches the chain. It signs one 32-byte digest per release:
digest = keccak256(abi.encode(block.chainid, address(vault), to, amount, nonce))
All five values are inside the digest, so a signature is valid for exactly one release: another chain changes chainid, another vault changes the address, and a replay in the same vault fails on the consumed nonce. Nonces are per-vault and need not be sequential — only unique.
The digest is the message, with no extra hashing by the caller: the vault passes abi.encodePacked(digest), 32 bytes.
PQ precompile reference
| Property | Value |
|---|---|
| Address | 0x1800000000000000000000000000000000000004 (genesis-fixed) |
| Selector | 0xbf4db8ba — verifySlhDsaSha2128s(bytes,bytes,bytes) |
| Arguments | (bytes verifyingKey /*32 B*/, bytes message /*any length*/, bytes signature /*7856 B*/) |
| Returns | bool — false for an invalid signature |
| Gas | 444,122–448,502 gas per release, measured on two releases |
| Cost | 0.00888–0.00897 USDC at Arc's base fee of about 20 Gwei |
When it reverts
Only malformed input reverts: Input too short, Invalid selector, Invalid verifying key length, Invalid signature length. An invalid signature is a value, not an exception — that asymmetry is the whole footgun.
Calling it correctly
// WRONG — true for every forgery
(bool ok, ) = PQ.staticcall(data);
require(ok, "verify failed");
// CORRECT — decode the boolean the precompile returned
(bool ok, bytes memory ret) = PQ.staticcall(data);
require(ok && ret.length == 32 && abi.decode(ret, (bool)), "invalid PQ signature");
The precompile is in Arc's execution layer, not upstream EVM, so a local test suite needs Arc Foundry (arc-anvil --network arc) — prebuilt for Linux and macOS (Apple Silicon) only. Olinea's suite injects the verifier and proves the real precompile against mainnet instead.
Units on Arc. USDC appears twice on one balance: natively with 18 decimals for gas, and as ERC-20 at 0x3600000000000000000000000000000000000000 with 6 for transfers. Mixing them is a 1012 error. Olinea only uses the 6-decimal view; gas is 2 × 10⁻⁸ USDC per unit.
CLI reference
node pq.mjs keygen
node pq.mjs authorize --vault 0x.. --to 0x.. --amount 1000000 --nonce 1 [--check]
node pq.mjs conformance [--vault 0x..]
| Command | What it does |
|---|---|
keygen | Generates an SLH-DSA-SHA2-128s keypair and writes it to scripts/keys/pq-key.json |
authorize | Prints the digest, the signature for one release and a ready-to-run cast send line. --check verifies it against the live mainnet precompile |
conformance | Runs three cases — valid digest, tampered signature, different digest — against the real precompile, and exits non-zero on failure, so it works as a pre-deploy gate |
Set ARC_RPC for a different endpoint. The default is https://rpc.drpc.mainnet.arc.io, because Arc's own RPC hosts are not reachable from every jurisdiction.
Security model
What the contract guarantees:
- One key, nothing else. No owner, no admin, no pause, no upgrade, no delegate. Nothing moves USDC except a valid post-quantum authorization.
- Listed in the digest. Chain id, vault address, recipient, amount and nonce are all signed, so an authorization is bound to a single release.
- Nonces are consumed before the transfer, so a valid authorization cannot be replayed even by re-entering the same transaction.
- Verification is on-chain. No oracle, no committee, no off-chain signer and no trusted relayer: the relayer pays gas and gets nothing.
- A forgery is not a revert. The vault decodes the returned boolean. The suite includes
NaiveVault.sol— the same vault with the "call succeeded" bug — to prove it drains on a forgery whileOlineaVaultdoes not.
What it does not do:
- It does not make the vault's USDC denylist-resistant — see below.
- It does not protect a compromised signing device. The key is the authorization; sign only what you mean to sign.
- It does not recover funds.
Limitations
Unaudited. The contracts are new code. A test suite and mainnet evidence are not a substitute for review. Do not put amounts you cannot lose into an unaudited vault.
- No recovery. Lose the key and the funds are unrecoverable by design — the property, not an oversight, and why there is nothing to phish.
- Circle's denylist still applies. A forged signature cannot move funds, but the USDC token can still block transfers. This is not denylist resistance.
- Verification is not free. 444,122–448,502 gas across two measured releases, 0.00888–0.00897 USDC at Arc's base fee of about 20 Gwei. The 7,856 bytes of signature calldata are a fixed part of that.
- Signatures are large. 7,856 bytes of calldata per release, not compressible. That is the price of hash-based signatures.
- Signing is slow by design. Seconds per signature, not milliseconds — SLH-DSA-SHA2-128s trades speed for small keys and conservative assumptions.
- Reads need an RPC. Code and assets are served from one origin, but chain reads are not: the console and the CLI talk to public Arc endpoints. If both are down, a page can only show what it last read.
What happens when it goes wrong
The questions an auditor asks first, each one settled by a test rather than an adjective. The long version, with the files, is in SECURITY.md.
- Circle denies the address. The transfer inside a release reverts, so nothing moves — and because the call rolls back, the nonce is not spent, so the same signed authorization works when the deny lifts. No contract routes around the issuer, including this one.
- The deployer key is lost. The factory has no owner, no pause and no upgrade path, and every vault is an independent contract that never calls it back. Nothing that exists stops working; the cost is one redeploy.
- SLH-DSA has to be replaced. A vault cannot rotate its key — rotation needs an authority, and there is none. Migration is a release into a replacement vault signed by the old key; the verifier is a constructor argument, so a new precompile is a deployment, not a rewrite.
- A key that was never written down. One vault on mainnet is authorized by a keypair generated offline, with no words, and the machine that holds it holds the only copy. That vault is named in
SECURITY.mdrather than left to be discovered.
The site is part of this: every page, module, font and geometry it loads is served from one origin, so a cold load names no host that could be slow, wrong or gone. That is asserted in scripts/check-site.mjs, not promised.
Deploying a vault
Step 1 is free: generate the key and check its digest against the live precompile before anything is on chain. Steps 2 onwards need a funded Arc account — gas is paid in USDC.
export ARC_RPC=https://rpc.drpc.mainnet.arc.io
export ARC_PRIVATE_KEY=0x.. # a few USDC for gas
USDC=0x3600000000000000000000000000000000000000
# 1. key + conformance (free, no wallet)
cd scripts && node pq.mjs keygen && node pq.mjs conformance
# 2. deploy (verifier = address(0) -> the real precompile)
cd ../contracts && forge create src/OlineaVault.sol:OlineaVault \
--rpc-url $ARC_RPC --private-key $ARC_PRIVATE_KEY --broadcast \
--constructor-args $USDC 0x<your-32-byte-vk> 0x0000000000000000000000000000000000000000
# 3. fund it
cast send $USDC "approve(address,uint256)" $VAULT 2000000 --rpc-url $ARC_RPC --private-key $ARC_PRIVATE_KEY
cast send $VAULT "deposit(uint256)" 1000000 --rpc-url $ARC_RPC --private-key $ARC_PRIVATE_KEY
# 4. authorize a release (prints the signature and a cast send line)
node ../scripts/pq.mjs authorize --vault $VAULT --to $RECIPIENT --amount 250000 --nonce 1 --check
# 5. verify the vault's balance afterwards
cast call $VAULT "balance()(uint256)" --rpc-url $ARC_RPC
The verifying key is immutable — to change keys, deploy a new vault. Treat the deployed key as the whole security boundary from the first USDC that lands.
