Core Protocol
Solana made execution fast. The edges around execution (offline authorization, signing isolation, and receiver privacy) remain exposed. Sirius Labs secures them with three composable layers, each closing a gap the previous one leaves open.
MORA
Pay on Solana with zero internet at transaction time.
Lock funds once into an escrow. Sign compact offline vouchers (84-byte message + 64-byte signature). Settle on-chain when reconnected.
AirSign
The signing key never touches a networked device.
MORA vouchers are signed on a permanently air-gapped device. The signature travels back via fountain-coded QR codes. No USB, no Bluetooth, no attack surface.
GrayBox
Accept payments without revealing wallet identity.
Each payment is routed to a one-time x25519 ECDH stealth address derived per transfer. The recipient's identity stays off-chain entirely.
Optional · ZK Shielded Pool
Break the deposit-withdrawal link entirely.
The stack is composable with Groth16 ZK shielded pools (e.g. Cloak) for full unlinkability. Independent 3rd-party integration, not required to use MORA, AirSign, or GrayBox.
Each layer is independently deployable. Use MORA alone, add AirSign for signing isolation, add GrayBox for receiver privacy, or run the full stack.
Architecture
The three layers are independently deployable but designed to compose. Each closes a distinct attack surface. In a full-stack payment they are applied in this order: GrayBox first (recipient setup), then AirSign (offline signing), then MORA (on-chain settlement).
Layer Stack
| Order | Layer | Closes | Output |
|---|---|---|---|
| 1 | GrayBox | Deanonymization: recipient identity never appears on-chain | One-time x25519 ECDH stealth address |
| 2 | AirSign | Key exposure: signing key never touches a networked device | Ed25519 signature via fountain QR |
| 3 | MORA | Connectivity: payment authorized without live RPC | 84-byte signed voucher, Receipt PDA |
Composition Flow
A full-stack payment follows this path:
- Bob publishes a GrayBox Meta-Address (spendPub + viewPub).
- Alice derives a one-time stealth vault address from Bob's Meta-Address.
- Alice builds an 84-byte MORA message targeting the stealth vault and signs it air-gapped via AirSign, producing a 148-byte signed voucher.
- The signed voucher travels back to Alice's online device via fountain-coded QR. No USB, no Bluetooth.
- Bob's device submits the signed voucher on-chain. Solana's native Ed25519Program verifies the signature atomically. A Receipt PDA is created. Replay is impossible.
- Bob's GrayBox indexer detects the deposit using his view key. Funds enter the AML-gated quarantine vault.
- After AML attestation, Bob calls
releaseto move funds to treasury. No on-chain link to sender or stealth address.
This adds Groth16 proof-based unlinkability between deposit and withdrawal, breaking the link entirely.
Cloak is an independent 3rd-party system, not required.
On-chain Programs
| Program | Network | Address |
|---|---|---|
| MORA escrow | Devnet | 9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8 |
| GrayBox quarantine vault | Devnet | 75HuPfb2n7SD7KtcQnVpCW5SVN3RP9gZ9vTXP4D4ha6C |
Wallet Connection Setup
All on-chain interactions require a connected Solana wallet. Use the official @solana/wallet-adapter packages to support Phantom, Backpack, and other wallets.
Installation
npm install @solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-wallets \
@solana/wallet-adapter-base \
@solana/web3.jsProvider Setup (Next.js App Router)
// components/WalletProvider.tsx
"use client";
import { FC, ReactNode, useMemo } from "react";
import {
ConnectionProvider,
WalletProvider,
} from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { PhantomWalletAdapter } from "@solana/wallet-adapter-wallets";
import "@solana/wallet-adapter-react-ui/styles.css";
const DEVNET_RPC = "https://api.devnet.solana.com";
export const AppWalletProvider: FC<{ children: ReactNode }> = ({ children }) => {
const wallets = useMemo(() => [new PhantomWalletAdapter()], []);
return (
<ConnectionProvider endpoint={DEVNET_RPC}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>{children}</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
};Building an AnchorProvider
"use client"; // this hook can only be used in client components
import { useMemo } from "react";
import { AnchorProvider, Program } from "@coral-xyz/anchor";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { PublicKey } from "@solana/web3.js";
import idl from "./mora_idl.json"; // download from GitHub repo
const MORA_PROGRAM_ID = new PublicKey(
"9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8"
);
function useMoraProgram() {
const { connection } = useConnection();
const wallet = useWallet();
return useMemo(() => {
const provider = new AnchorProvider(
connection,
wallet as any, // wallet-adapter satisfies AnchorWallet interface
{ commitment: "confirmed" }
);
return new Program(idl, MORA_PROGRAM_ID, provider);
}, [connection, wallet]);
}MORA: Offline Payment Escrow
MORA lets users pay on Solana with zero internet at transaction time. Alice locks funds once into an escrow PDA. This is the only moment internet is required. From that point, she signs MORA vouchers (84-byte message, 64-byte detached signature) completely offline. Bob settles whenever he reconnects.
On-chain Program
Program ID (devnet): 9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8
Instructions
| Instruction | Description |
|---|---|
| create_escrow | Lock SOL into a PDA derived from [payer, seed]. Internet required only once. |
| settle | Submit 2-instruction tx: Ed25519 native verify + settle ix. Transfers lamports, creates Receipt PDA. |
| close_escrow | After expiry (configurable at creation, default 30 days on devnet), reclaims rent + remainder. |
Voucher Format
The signed message is 84 bytes: "MORA" (4) | escrow (32) | nonce_u64_le (8) | payee (32) | amount_u64_le (8)
The complete voucher transmitted to Bob is 148 bytes: 84-byte message + 64-byte detached Ed25519 signature.
Both fit comfortably in a single QR code (QR v6 supports up to 224 bytes of binary data).
Security Guarantees
MORA enforces three independent guarantees on-chain, none of which require the payer to be online at transaction time.
| Attack | What an attacker tries | How MORA stops it |
|---|---|---|
| Double-spend | Give the same signed voucher to two different payees | Each nonce deterministically derives a unique Receipt PDA. The settle instruction initializes it with an init constraint, meaning it can only be created once. A second settle with the same nonce fails with already in use. |
| Double-settlement | Submit the same voucher on-chain twice (e.g. from two devices) | Same mechanism: the Receipt PDA already exists after the first settlement. The tx is rejected before any lamports move. |
| Signature forgery | Forge or tamper with a voucher without the payer's private key | Each settle call is a 2-instruction atomic tx: Solana's native Ed25519 program verifies the signature first. If it fails, the entire tx is rejected. The escrow is never touched. |
Installation
npm install @coral-xyz/anchor @solana/web3.js tweetnacl # Download IDL: # https://github.com/nzengi/mora/blob/main/target/idl/mora.json
TypeScript Types
import { PublicKey } from "@solana/web3.js";
import { BN } from "@coral-xyz/anchor";
interface MoraVoucher {
escrow: PublicKey;
nonce: bigint; // u64 — increment per payment
payee: PublicKey;
amount: bigint; // lamports (u64)
signature: Uint8Array; // 64-byte Ed25519 sig
}
interface EscrowAccount {
payer: PublicKey;
amount: BN;
seed: BN;
expiresAt: BN;
}Escrow PDA Derivation
import { PublicKey } from "@solana/web3.js";
import { BN } from "@coral-xyz/anchor";
import { Buffer } from "buffer";
const MORA_PROGRAM_ID = new PublicKey(
"9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8"
);
function deriveEscrowPda(payer: PublicKey, seed: number): PublicKey {
const seedBuffer = Buffer.alloc(8);
seedBuffer.writeBigUInt64LE(BigInt(seed)); // Date.now() fits in u64 safely
const [escrowPda] = PublicKey.findProgramAddressSync(
[payer.toBuffer(), seedBuffer],
MORA_PROGRAM_ID
);
return escrowPda;
}
// Usage — wallet.publicKey comes from useWallet() (see Wallet Setup section)
const escrowPda = deriveEscrowPda(wallet.publicKey, Date.now());Building nonceBuffer and amountBuffer
Buffer is not available in the browser by default. In client components, add import { Buffer } from "buffer"; and install with npm install buffer.import { Buffer } from "buffer"; // npm install buffer (browser polyfill)
// Both nonce and amount are 8-byte little-endian unsigned 64-bit integers
// nonce — increment per voucher to prevent replay
const nonce = BigInt(1); // start at 1, increment each payment
const nonceBuffer = Buffer.alloc(8);
nonceBuffer.writeBigUInt64LE(nonce);
// amount — payment in lamports (1 SOL = 1_000_000_000 lamports)
const lamports = BigInt(500_000_000); // 0.5 SOL
const amountBuffer = Buffer.alloc(8);
amountBuffer.writeBigUInt64LE(lamports);TypeScript Integration
import { AnchorProvider, Program, BN } from "@coral-xyz/anchor";
import { PublicKey, Keypair } from "@solana/web3.js";
import { Buffer } from "buffer"; // npm install buffer
import nacl from "tweetnacl";
import idl from "./mora_idl.json";
// MoraVoucher and EscrowAccount interfaces: see TypeScript Types section above
const MORA_PROGRAM_ID = new PublicKey(
"9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8"
);
async function createEscrow(
program: Program,
wallet: { publicKey: PublicKey },
totalLamports: bigint
): Promise<PublicKey> {
const seed = Date.now(); // number — fits in u64 safely
const seedBuffer = Buffer.alloc(8);
seedBuffer.writeBigUInt64LE(BigInt(seed));
const [escrowPda] = PublicKey.findProgramAddressSync(
[wallet.publicKey.toBuffer(), seedBuffer],
MORA_PROGRAM_ID
);
await program.methods
.createEscrow(new BN(totalLamports.toString()))
.accounts({ payer: wallet.publicKey, escrow: escrowPda })
.rpc();
return escrowPda;
}
function signVoucher(
escrow: PublicKey,
nonce: bigint,
payee: PublicKey,
amount: bigint,
keypair: Keypair
): MoraVoucher {
const nonceBuffer = Buffer.alloc(8);
nonceBuffer.writeBigUInt64LE(nonce);
const amountBuffer = Buffer.alloc(8);
amountBuffer.writeBigUInt64LE(amount);
const msg = Buffer.concat([
Buffer.from("MORA"),
escrow.toBuffer(),
nonceBuffer,
payee.toBuffer(),
amountBuffer,
]);
const signature = nacl.sign.detached(msg, keypair.secretKey);
return { escrow, nonce, payee, amount, signature };
}
async function settleVoucher(
program: Program,
voucher: MoraVoucher,
settler: { publicKey: PublicKey }
): Promise<string> {
try {
const txSig = await program.methods
.settle(Buffer.from(voucher.signature))
.accounts({ settler: settler.publicKey, escrow: voucher.escrow })
.rpc({ commitment: "confirmed" });
return txSig;
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already in use")) {
throw new Error(`Nonce ${voucher.nonce} already settled. Use a fresh nonce.`);
}
throw err;
}
}Error Handling
try {
await settleVoucher(program, voucher, settler);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("already in use")) {
console.error("Voucher already redeemed. Nonce:", voucher.nonce);
} else if (msg.includes("InvalidSignature")) {
console.error("Voucher signature invalid");
} else if (msg.includes("EscrowExpired")) {
console.error("Escrow expired. Call close_escrow to recover funds.");
} else if (msg.includes("InsufficientFunds")) {
console.error("Escrow underfunded for this voucher amount");
} else {
throw err;
}
}Tests
# Localnet solana-test-validator --reset --quiet & anchor test --skip-local-validator # Devnet smoke anchor test --skip-local-validator --skip-deploy --provider.cluster devnet
AirSign: Air-Gapped Signing
MORA already removes internet from the payment moment, but the signing key still lives on a device that touches networks. AirSign closes that gap: the signing key never touches a networked device. MORA vouchers are signed on a permanently air-gapped device; the signature travels back via fountain-coded QR codes with no USB, no Bluetooth, no attack surface.
How It Works
Online device Offline device
────────── ──────────────
TX → QR frames → Scan + reconstruct
↓
Scan signature ← Sign → QR frames
↓
Submit to SolanaKey Derivation
Keys are derived using Argon2id KDF from a passphrase. No seed phrase export, no USB, no Bluetooth. The QR channel uses fountain codes that tolerate significant frame loss, approximately 30% at practical QR frame counts (RaptorQ / RFC 6330: ~1-2% encoding overhead, allowing recovery from proportionally high erasure rates).
Setup
| Device | Role | URL |
|---|---|---|
| Offline (signer) | Scans TX QR, signs, returns signature QR | air-sign-signer-web.vercel.app/?role=signer |
| Online (coordinator) | Displays TX as QR, scans signature, submits | air-sign-signer-web.vercel.app/?role=coordinator |
AirSign vs Hardware Wallet
| Capability | Hardware Wallet | AirSign |
|---|---|---|
| No USB / Bluetooth attack surface | ✗ | ✓ |
| M-of-N multisig | vendor-dependent | ✓ |
| Open, auditable stack | partial | ✓ |
| Fountain code frame loss tolerance | n/a | ✓ |
| Native browser WASM | adapter only | ✓ |
GrayBox: Receiver Privacy
MORA hides when you pay. AirSign hides how you sign. GrayBox hides who receives. x25519 ECDH stealth address derivation (using Ed25519 keys via Curve25519) ensures the recipient's real wallet never appears on-chain. Every payment lands at a unique one-time address with no traceable link back to the recipient's identity. Privacy is achieved without any zero-knowledge dependency.
release instruction can be called. In the current devnet implementation, Sirius Labs operates the oracle. The design supports pluggable oracle providers: any party with the oracle keypair can submit attestations, enabling institutional or third-party AML providers. This gives institutions address-level privacy with full auditability. There is no on-chain link between sender, receiver, and treasury, while a compliant audit trail is preserved.On-chain Program
Program ID (devnet): 75HuPfb2n7SD7KtcQnVpCW5SVN3RP9gZ9vTXP4D4ha6C
How It Works
The Indexer is a GrayBox server-side component that continuously polls Solana program accounts using Bob's view key. When it detects a matching deposit, it notifies the gateway which triggers the AML attestation flow.
| Step | Actor | Action |
|---|---|---|
| 1 | Bob | Generates Meta-Address (spendPub + viewPub) |
| 2 | Alice | Derives one-time stealth address from Meta-Address via API |
| 3 | Alice | Deposits funds into quarantine vault at stealth address |
| 4 | Indexer | Scans chain with Bob's view key, detects deposit, notifies gateway |
| 5 | AML Oracle | Attests funds are clean. Quarantine gate opens. |
| 6 | Bob | Releases funds to treasury (no on-chain link to sender or stealth address) |
Steps 3, 5, and 6 are on-chain Anchor program calls (deposit, attest, release). Step 4 (Indexer) is an off-chain service. See the quarantine-vault program on GitHub for the IDL and instruction accounts.
TypeScript Types
interface GrayBoxMetaAddress {
spendPub: string; // base58 Ed25519 public key
viewPub: string; // base58 Ed25519 public key
}
interface StealthAddressResponse {
stealthPubkey: string; // base58 — send funds here
ephemeralPub: string; // needed for recipient to scan
viewTag: number; // single-byte scan optimization
}
interface ViewingKeyResponse {
viewingKey: string; // used for compliance scanning
}API Key
# Demo API key (devnet only) x-api-key: g-p_demo_h6kj9d8s7g6f5d4 # Production keys: hello@siriuslabs.tech
API Endpoints
Base URL: https://graybox-cloak-production.up.railway.app
POST /v1/receiving-address
Body: { spendPub: string, viewPub: string }
Returns: StealthAddressResponse
POST /v1/compliance/viewing-key
Headers: x-api-key
Returns: ViewingKeyResponseTypeScript SDK
const GRAYBOX_URL = "https://graybox-cloak-production.up.railway.app";
// Derive a one-time stealth address for incoming payment
async function getStealthAddress(
meta: GrayBoxMetaAddress,
apiKey: string
): Promise<StealthAddressResponse> {
const res = await fetch(`${GRAYBOX_URL}/v1/receiving-address`, {
method: "POST",
headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify(meta),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GrayBox API error ${res.status}: ${text}`);
}
return res.json() as StealthAddressResponse;
}
// Generate a compliance viewing key (institutions / auditors)
async function getViewingKey(apiKey: string): Promise<ViewingKeyResponse> {
const res = await fetch(`${GRAYBOX_URL}/v1/compliance/viewing-key`, {
method: "POST",
headers: { "x-api-key": apiKey },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GrayBox API error ${res.status}: ${text}`);
}
return res.json() as ViewingKeyResponse;
}
// Usage — stealth address
try {
const { stealthPubkey } = await getStealthAddress(
{ spendPub: recipient.spendPub, viewPub: recipient.viewPub },
"g-p_demo_h6kj9d8s7g6f5d4"
);
// stealthPubkey is now safe to use as the payment destination
} catch (err) {
console.error("Failed to derive stealth address:", err);
}ZK Shielded Pool: Optional Integration
The three core layers leave one gap: a determined observer can still see that a deposit happened, and later that a withdrawal happened. A ZK shielded pool breaks even that link. The Sirius stack is designed to route through any Groth16-based pool. Cloak is the reference integration.
How the Stack Connects
| Step | Actor | What happens |
|---|---|---|
| 1 | GrayBox | Derives one-time stealth address for recipient |
| 2 | Sender | Deposits into ZK shielded pool targeting stealth address |
| 3 | Pool | Generates Groth16 proof (inputs = outputs, link hidden) |
| 4 | Recipient | Withdraws. On-chain record is only a proof, not a link. |
Live Proof of Concept (Mainnet)
| Step | TX (short) | Network |
|---|---|---|
| Deposit into shielded pool | 3bpjSixv... | Mainnet → |
| ZK withdrawal | 4mLPo2J8... | Mainnet → |
Integration Interface
// Generic interface — implement for any ZK shielded pool
interface ZkPoolAdapter {
deposit(params: {
amount: number; // lamports
recipient: string; // stealth pubkey from GrayBox
}): Promise<{ txSignature: string }>;
withdraw(params: {
amount: number;
nullifier: string; // spend nullifier
proof: Uint8Array; // Groth16 proof
}): Promise<{ txSignature: string }>;
}Quick Start: Full Stack Integration
End-to-end flow: MORA escrow (online, once) → GrayBox stealth address → AirSign air-gapped signing → MORA settle (+ optional ZK pool deposit).
1. Fund escrow: MORA (online, once)
// components/SetupEscrow.tsx
"use client";
import { useWallet, useConnection } from "@solana/wallet-adapter-react";
import { AnchorProvider, Program, BN } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { Buffer } from "buffer"; // npm install buffer
import idl from "./mora_idl.json";
const MORA_PROGRAM_ID = new PublicKey(
"9fcXHD3pHDKLX79JuVgCEKQiqYkvVqFtpoAEVjBq4aJ8"
);
export function SetupEscrow() {
const { connection } = useConnection();
const wallet = useWallet();
async function handleSetup() {
if (!wallet.publicKey) return;
const provider = new AnchorProvider(connection, wallet as any, {
commitment: "confirmed",
});
const program = new Program(idl, MORA_PROGRAM_ID, provider);
const seed = Date.now();
const seedBuffer = Buffer.alloc(8);
seedBuffer.writeBigUInt64LE(BigInt(seed));
const [escrowPda] = PublicKey.findProgramAddressSync(
[wallet.publicKey.toBuffer(), seedBuffer],
MORA_PROGRAM_ID
);
await program.methods
.createEscrow(new BN("2000000000")) // 2 SOL budget
.accounts({ payer: wallet.publicKey, escrow: escrowPda })
.rpc({ commitment: "confirmed" });
console.log("Escrow funded:", escrowPda.toBase58());
// Save escrowPda.toBase58() — needed for every voucher
}
return (
<button
onClick={handleSetup}
style={{ padding: "8px 20px", borderRadius: "8px", background: "#1d4ed8", color: "#fff", fontWeight: 600, cursor: "pointer" }}
>
Fund Escrow
</button>
);
}2. Get stealth address: GrayBox
const API_KEY = "g-p_demo_h6kj9d8s7g6f5d4"; // devnet demo key
const { stealthPubkey } = await getStealthAddress(
{ spendPub: recipient.spendPub, viewPub: recipient.viewPub },
API_KEY
);
// stealthPubkey — one-time address, safe to use as payment destination3. Sign voucher offline: MORA + AirSign
import nacl from "tweetnacl"; // npm install tweetnacl
import { PublicKey, Keypair } from "@solana/web3.js";
import { Buffer } from "buffer"; // npm install buffer (browser polyfill)
// On air-gapped device — AirSign handles QR transport of keypair and message
// escrowPda: PublicKey from Step 1 | stealthPubkey: string from Step 2
// keypair: Keypair of the payer (never leaves the offline device)
const nonce = BigInt(1); // increment per payment
const lamports = BigInt(500_000_000); // 0.5 SOL
const nonceBuffer = Buffer.alloc(8);
nonceBuffer.writeBigUInt64LE(nonce);
const amountBuffer = Buffer.alloc(8);
amountBuffer.writeBigUInt64LE(lamports);
// 84-byte message: "MORA"(4) | escrow(32) | nonce(8) | payee(32) | amount(8)
const msg = Buffer.concat([
Buffer.from("MORA"),
escrowPda.toBuffer(),
nonceBuffer,
new PublicKey(stealthPubkey).toBuffer(),
amountBuffer,
]);
const signature = nacl.sign.detached(msg, keypair.secretKey);
// AirSign encodes signature into fountain QR → online device scans4. Settle MORA voucher (+ optional ZK pool routing)
Bob's online device scans the AirSign QR, recovers the signature, and settles the voucher. If ZK unlinkability is required, Bob deposits into a ZK shielded pool instead of receiving funds directly; the escrow still settles to the stealth address, which the pool then shields.
import { Buffer } from "buffer";
// Bob's online device received the signature from AirSign QR scan.
// Step 1: Settle the MORA voucher on-chain.
// Ed25519 native verify + lamport transfer are atomic in one tx.
// Bob (settler) calls this — not Alice.
try {
const settleTx = await program.methods
.settle(Buffer.from(signature))
.accounts({ settler: wallet.publicKey, escrow: escrowPda })
.rpc({ commitment: "confirmed" });
console.log("MORA settled:", settleTx);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("already in use")) {
throw new Error("Voucher already redeemed. Use a fresh nonce.");
}
throw err;
}
// Step 2 (optional): Route through ZK shielded pool for full unlinkability.
// Funds have now arrived at the stealth address — deposit into pool from there.
// Skip this block if ZK is not required.
const { txSignature: poolTx } = await zkPool.deposit({
amount: Number(lamports),
recipient: stealthPubkey, // stealth address from GrayBox (Step 2)
});
console.log("Shielded deposit:", poolTx);release instruction on the quarantine-vault program to move funds to the treasury. See the GrayBox: How It Works section for the full flow.Contact & Support
For integration support, partnerships, or enterprise inquiries: hello@siriuslabs.tech
Coming Next
Sirius AI: Private Payroll Agent
The first AI agent layer built on top of the Sirius stack. Offline authorization, stealth address routing, and air-gapped signing, orchestrated autonomously for private payroll flows on Solana. Coming 2026.