When you can't trust the client to move money, you need a program that verifies signatures on-chain. Here's what happened when I built one.
The problem
In v1 of StreamPy, settlement worked like this: a client script built an SPL Token transfer, signed it with the sender's keypair, and sent it. Fee: 5,000 lamports. Confirmation: under a second. Auditable via Solana Pay references.
But the client could send those tokens anywhere. The "settlement rail" was really just a wallet pointed at a receiver. Any client with the key could drain it.
For v2, I moved the trust boundary. A custom Anchor program holds funds in a PDA-controlled vault and only releases them when an Ed25519 attestation is verified on-chain. The client constructs the transaction; only the program can move the money.
The program is live on Devnet at 6zNdkgUzRMoj2nfH9zV3xKyTc3V7881EZiXqVuF5hZUA. Here's how it works, and what happened on the way there.
The design: 3 PDAs, 1 attestation, 1 receipt
.
| Offset | Size | Field |
|---|---|---|
0 |
1 |
num_signatures = 1 |
1 |
1 |
padding = 0 |
2 |
2 |
signature_offset = 16 (u16 LE) |
4 |
2 |
signature_instruction_index = 0xFFFF |
6 |
2 |
pubkey_offset = 80 (u16 LE) |
8 |
2 |
pubkey_instruction_index = 0xFFFF |
10 |
2 |
message_offset = 112 (u16 LE) |
12 |
2 |
message_size (u16 LE) |
14 |
2 |
message_instruction_index = 0xFFFF |
16 |
64 |
Ed25519 signature |
80 |
32 |
Ed25519 public key |
112 |
N |
message bytes |
0xFFFF for the instruction index means "self-contained" — the data lives in this instruction's own bytes, not referenced from another instruction.
Building it in JavaScript
function buildEd25519Instruction(signature, message, publicKey) {
const sigOffset = 16;
const pkOffset = sigOffset + 64; // 80
const msgOffset = pkOffset + 32; // 112
const msgLen = message.length;
const buf = Buffer.alloc(msgOffset + msgLen);
buf.writeUInt8(1, 0); // num_signatures
buf.writeUInt8(0, 1); // padding
buf.writeUInt16LE(sigOffset, 2);
buf.writeUInt16LE(0xFFFF, 4);
buf.writeUInt16LE(pkOffset, 6);
buf.writeUInt16LE(0xFFFF, 8);
buf.writeUInt16LE(msgOffset, 10);
buf.writeUInt16LE(msgLen, 12);
buf.writeUInt16LE(0xFFFF, 14);
signature.copy(buf, sigOffset);
publicKey.copy(buf, pkOffset);
message.copy(buf, msgOffset);
return new TransactionInstruction({
programId: new PublicKey('Ed25519SigVerify111111111111111111111111111'),
keys: [],
data: buf,
});
}
The message format
The message is whatever your program expects. In StreamPay:
Signed Message Format
The message the attestation signer signs must exactly equal:
| Bytes | Field | Value |
|---|---|---|
0..7 |
prefix |
"STREAMP" (ASCII) |
7..39 |
settlement_id |
32-byte unique identifier, little-endian byte order preserved |
39..71 |
worker_pubkey |
32-byte Ed25519 public key of the worker wallet |
71..79 |
amount_le_u64 |
u64 amount in token base units, little-endian |
79 |
Total message size | |
Any byte-level difference causes MessageMismatch in the program.
That's it. The message binds the amount to a specific settlement ID and a specific worker. A signature over this message authorizes exactly one advancement of exactly one amount to exactly one recipient.
Verifying it in the program
use solana_program::sysvar::instructions::{
load_instruction_at_checked, ID as IX_SYSVAR_ID,
};
pub fn handler(
ctx: Context<Advance>,
amount: u64,
settlement_id: [u8; 32],
ed25519_ix_index: u8,
) -> Result<()> {
// Reconstruct the expected message
let mut msg = Vec::new();
msg.extend_from_slice(b"STREAMP");
msg.extend_from_slice(&settlement_id);
msg.extend_from_slice(ctx.accounts.worker.key().as_ref());
msg.extend_from_slice(&amount.to_le_bytes());
// Load the precompile instruction at the caller-specified index
let ed_ix = load_instruction_at_checked(
ed25519_ix_index as usize,
&ctx.accounts.instructions_sysvar,
).map_err(|_| ErrorCode::InvalidEd25519Instruction)?;
// Parse the header
let data = ed_ix.data.as_slice();
require!(data.len() >= 16, ErrorCode::InvalidEd25519Instruction);
let read_u16 = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
let pk_offset = read_u16(6) as usize;
let msg_offset = read_u16(10) as usize;
let msg_size = read_u16(12) as usize;
let signed_pk = &data[pk_offset..pk_offset + 32];
let signed_msg = &data[msg_offset..msg_offset + msg_size];
// The precompile already verified the signature itself.
// Our job is to check *what* was signed.
require!(
signed_pk == ctx.accounts.config.attestation_signer.as_ref(),
ErrorCode::UnauthorizedSigner
);
require!(
signed_msg == msg.as_slice(),
ErrorCode::MessageMismatch
);
// ... mark receipt consumed, CPI to token program ...
}
The key insight: the precompile already verified the signature. If the signature were invalid, the transaction would have aborted before your instruction ran. Your job is only to verify what was signed — that the signer is trusted and the message matches what your program intends to do.
That's a smaller job than people think. You don't reimplement Ed25519. You don't need to. You just parse a header and compare bytes.
The replay guard: why a PDA is better than a database
Every settlement in StreamPay has a settlement_id — a 32-byte random value. It's included in the signed message, so a signature is bound to a specific settlement.
The receipt PDA is derived from that ID:
#[account(
init,
payer = worker,
space = SettlementReceipt::LEN,
seeds = [b"receipt", settlement_id.as_ref()],
bump
)]
pub receipt: Account<'info, SettlementReceipt>,
init on a PDA fails if the account already exists. That's the entire replay guard.
Second call with the same settlement_id:
Allocate: account Address { address: 8bwQB6zysgfTj77DnnX2BvTCsHm7QfPiTAo93gZyPUg1, base: None } already in use
Program 11111111111111111111111111111111 failed: custom program error: 0x0
The transaction aborts at account allocation. No token instruction runs. The vault balance stays at 49.
Compare to the alternatives:
Replay guard options
| Approach | Cost | Latency | Failure mode |
|---|---|---|---|
| Database lookup |
~$0.01/check |
~10ms | Race conditions, DB outage, ops burden |
On-chain PDA init
|
~5,000 lamports |
~400ms | None — deterministic |
| Nonce account (System program) | ~2,000 lamports |
~400ms | Works, but state lives elsewhere |
The PDA is the cheapest, fastest, most reliable option. And it's free in the sense that you're already paying for the account creation as part of the settlement.
Wall #1: the dependency that broke the type system
This is where the tutorial ends and the actual work begins.
Anchor 1.1.2 uses solana-pubkey 3.x and solana-account-info 3.x internally. But solana-instructions-sysvar, the crate that provides load_instruction_at_checked, defaults to 2.x. Two major versions of the same crate in the dependency tree.
Rust treats solana_pubkey::Pubkey (2.x) and anchor_lang::prelude::Pubkey (3.x) as different types. Even though they have identical memory layout. Even though they have the same name. The compiler is right: a future version of 2.x could change the layout, and Rust is protecting you from that possibility.
Five errors, all variations on:
error[E0308]: mismatched types
--> src/instructions/advance.rs:11:10
|
11 | #[derive(Accounts)]
| ^^^^^^^^ expected `__Pubkey`, found `solana_pubkey::Pubkey`
The fix:
cargo update -p solana-instructions-sysvar@2.2.2 --precise 3.0.1
Eighteen crates removed from the tree in one command. Two hours of diagnosis, one line of resolution.
The lesson: in Solana's current dependency landscape, version pins matter more than they do in most ecosystems. ^ is not your friend. Anchor's own docs now recommend exact pins for anything that touches transaction serialization.
Wall #2: the client couldn't read its own IDL
With the program compiled and deployed, I moved to the client side. The plan was straightforward: load the IDL, instantiate Program, call program.methods.advance(...).
The first call crashed:
TypeError: Cannot read properties of undefined (reading 'size')
at new AccountClient (.../account.js:76:43)
at new Program (.../program/index.js:109:103)
``
The IDL was valid. I confirmed with a script:
js
top-level keys: ['address', 'metadata', 'instructions', 'accounts', 'errors', 'types']
accounts type: array
accounts count: 3
Everything `@anchor-lang/core@1.2.2` should have needed. But `BorshAccountsCoder` produced an undefined map, and Program threw before my code even ran.
That's an internal library bug or a format assumption I couldn't resolve from outside. A search of the Anchor GitHub issues showed similar reports but no fix in 1.2.x.
## The fallback: what Anchor instructions actually are
Here's the thing that took me a while to internalize: the Anchor client is a convenience, not a requirement.
An Anchor instruction is:
`[8-byte discriminator][Borsh-encoded arguments]`
The discriminator is:
`sha256("global:<snake_case_method_name>")[0:8]`
That's it. It's been stable since Anchor 0.24. The client computes it for you, encodes arguments for you, resolves accounts from your IDL for you — but none of that is magic. You can do it in fifty lines.
js
import crypto from 'crypto';
function discriminator(name) {
return crypto.createHash('sha256')
.update(name)
.digest()
.slice(0, 8);
}
// advance(amount: u64, settlement_id: [u8; 32], ed25519_ix_index: u8)
const args = Buffer.concat([
discriminator('global:advance'),
Buffer.from(new BigUint64Array([amount]).buffer), // u64 LE
settlementId, // [u8; 32]
Buffer.from([0]), // ed25519_ix_index
]);
const advanceIx = new TransactionInstruction({
programId: new PublicKey('6zNdkgUzRMoj2nfH9zV3xKyTc3V7881EZiXqVuF5hZUA'),
keys: [
{ pubkey: configPda, isSigner: false, isWritable: false },
{ pubkey: vaultPda, isSigner: false, isWritable: false },
{ pubkey: vaultTokenPda, isSigner: false, isWritable: true },
{ pubkey: workerAta, isSigner: false, isWritable: true },
{ pubkey: receiptPda, isSigner: false, isWritable: true },
{ pubkey: workerPubkey, isSigner: true, isWritable: true },
{ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false },
{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
],
data: args,
});
The account order must match the #[derive(Accounts)] struct in advance.rs. That's the only thing you have to get right by hand.
This produces byte-identical output to what the client would have produced. The program can't tell the difference. Neither can the RPC. Only the JavaScript bundler notices — it doesn't have to load @anchor-lang/core anymore.
The lesson: the IDL is documentation, not a wire format contract. The wire format is sha256 + Borsh. Everything else is convenience.
### The result
Four transactions on Devnet:
<h3>v2 Transactions</h3>
<table>
<thead>
<tr>
<th>Instruction</th>
<th>Signature</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>initialize_config</code></td>
<td>
<a href="https://explorer.solana.com/tx/3ABeLTE63TNziwZexggLn5F8cEJySvaNTXFGDu5JMGMo6nkvjsaDddq5VEtNr7QKr7wWXN2NXuF9aeMrgkyLDVkH?cluster=devnet">
<code>3ABeLTE6...LDVkH</code>
</a>
</td>
</tr>
<tr>
<td><code>initialize_vault</code></td>
<td>
<a href="https://explorer.solana.com/tx/5haYJwzKKndembXrWm72W2BhGpMXBNJW3cFvBv7T7w1VyW4jMunVQEAHJtAcWJ29o8FLBE3Fwh92YBwWXunr6BWF?cluster=devnet">
<code>5haYJwzK...6BWF</code>
</a>
</td>
</tr>
<tr>
<td><code>fund_vault</code></td>
<td>
<a href="https://explorer.solana.com/tx/5gAJ1PhHPxGHsagnB6y2eQXfbj7i2QWa3zWorH77QnGH63vjSPuDXSUiWP2oMfZ9nYpAv1W65V4gpzHCrPN1dcne?cluster=devnet">
<code>5gAJ1PhH...1dcne</code>
</a>
</td>
</tr>
<tr>
<td><code>advance</code></td>
<td>
<a href="https://explorer.solana.com/tx/29qGVycgcxTN4h6zXE7o5EBejdhtV4afVqQ4mdAGVnZSrAS8pm1agB4d8DAL4eqUTLP4wT7otysqpZm7sCPBasHP?cluster=devnet">
<code>29qGVycg...BasHP</code>
</a>
</td>
</tr>
</tbody>
</table>
The advance transaction:
Vault balance: 50 -> 49
Worker balance: 2 -> 3
Settlement ID: 4c73b8b3...
Receipt PDA: 8bwQB6zy...
Then I replayed the same settlement ID:
js
Allocate: account Address { address: 8bwQB6zysgfTj77DnnX2BvTCsHm7QfPiTAo93gZyPUg1, base: None } already in use
Vault balance after: 49. No double-spend.
The security property isn't a claim in the README anymore. It's a log line from a transaction that failed for the right reason.
## What this proves (and what it doesn't)
Proven:
A program can enforce settlement rules without trusting the client
Ed25519 attestations can gate on-chain transfers with ~5,000 lamports of overhead
PDA-derived receipts prevent replay at the protocol level
The Anchor client can be bypassed entirely with ~50 lines of raw instruction building
Not proven:
That the number being signed is correct. The attestation service is a local script that signs whatever it's told. In production it would need to receive HMAC-verified webhooks from a payroll provider, normalize the fields, and sign with a key stored in a KMS. The program trusts the attestation key; whether the attestation service is honest is a trust assumption the program cannot check.
That's the oracle problem. Every attestation-gated system has it. The good ones document it explicitly.
## Three lessons for anyone building this
1. The Anchor client is optional. If it breaks, or you need finer control, or you just want fewer dependencies, build the instruction yourself. sha256("global:<method>")[0:8] + Borsh is the whole format.
2. Version pins are load-bearing. solana-instructions-sysvar@3.0.1 vs @2.2.2 is a two-hour debugging session if you get it wrong. Pin exact versions for anything that touches serialization. No carets.
3. Replay guards belong on-chain. A init-constrained PDA costs ~5,000 lamports and never fails open. A database lookup costs $0.01/check and fails regularly. The chain is cheaper and more reliable.
**Repo and further reading**
The full project, including the wire format appendix that documents every discriminator and account order:
syed-ghufran-hassan
/
streampay-rail
Auditable SPL Token settlement on Solana Devnet — Solana Pay reference attached on-chain, reconciled by reference key alone. No database.
StreamPay - Client Rail (v1.1) + Program Rail (v2)
An auditable stablecoin settlement proof on Solana Devnet, in two layers:
-
v1.1 Client Rail - a Solana Pay transfer request is encoded with a unique
reference key, settled as an SPL Token transfer with that reference attached
on-chain, then independently located and validated by reference alone. No
database, no lookup table.
-
v2 Program Rail - a custom Anchor program holds settlement funds in a
PDA-controlled vault and releases them only when an off-chain Ed25519
attestation is verified on-chain. Replay is prevented by a per-settlement
receipt PDA.
This is the settlement + reconciliation + enforcement stack of the StreamPay
concept: a compliant earned wage access rail for gig and hourly workers that
uses stablecoin settlement to move funds instantly and for fractions of a cent
while workers receive fiat through their existing bank or debit card.
Status: settlement + reconciliation + on-chain…
Top comments (0)