A reference implementation showing how to gate access to a Solana protocol based on a user's wallet risk score. A backend screens the user via the Range API, signs an attestation message with its Ed25519 keypair, and the user submits a transaction where the on-chain Anchor program verifies the signature, message freshness, and signer identity.
sequenceDiagram
participant User as User Wallet
participant Backend as Backend Server
participant Range as Range API
participant Program as Solana Program
User->>Backend: Request protocol access
Backend->>Range: GET /v1/risk/address?address={user}
Range-->>Backend: { riskScore, riskLevel, reasoning }
alt riskScore > 9
Backend-->>User: Rejected (score too high)
else riskScore < 10
Backend->>Backend: Generate riskCallId
Backend->>Backend: Build message: "{timestamp}_{userPubkey}_{riskCallId}"
Backend->>Backend: Sign message with backend Ed25519 keypair (nacl.sign.detached)
Backend-->>User: { signature, message }
end
User->>User: Build instruction with signature + message
User->>User: Build transaction (fee payer = user)
User->>User: Sign transaction with user keypair
User->>Program: Submit transaction
Program->>Program: Deserialize VerifyRangeArgs { signature, message }
Program->>Program: Verify Ed25519 signature (brine-ed25519)
Program->>Program: Extract {timestamp, pubkey, riskCallId} from message
Program->>Program: Assert pubkey == transaction signer
Program->>Program: Assert timestamp within 60s of on-chain clock
Program-->>User: Success — user granted access
sigverify/
├── programs/sigverify/src/ # Anchor program (Rust)
│ ├── lib.rs # Instruction handler + accounts
│ ├── helpers.rs # Signature verification + message parsing
│ ├── constants.rs # Backend pubkey, score lifetime
│ └── error.rs # Custom error codes
├── app/ # Client / Backend (TypeScript)
│ ├── index.ts # Orchestration — full flow
│ ├── sdk.ts # SDK — Range API, signing, instruction building
│ ├── backend_keypair.json # Backend Ed25519 keypair
│ └── user_keypair.json # User Ed25519 keypair (demo only)
└── tests/ # Anchor tests
Three functions that handle the off-chain logic:
screenRecipient(userAddress, apiKey, network?) Calls the Range API to get
a risk score for a wallet address. Returns the score, risk level, reasoning, and
a riskCallId — a unique identifier that ties this API call to the on-chain
attestation for compliance tracking and auditability.
createSignedMessage(timestamp, userAddress, riskCallId, backendSecretKey)
Builds the attestation message string "{timestamp}_{userPubkey}_{riskCallId}"
and signs it with the backend's Ed25519 secret key using nacl.sign.detached.
The signature and message bytes are returned to be included in the transaction.
buildVerifyRiskInstruction(userSigner, signature, message) Constructs the
raw Anchor instruction data:
- 8-byte discriminator (
sha256("global:verify_risk_signature")[0..8]) - Borsh-serialized
Vec<u8>for signature (4-byte LE length + 64 bytes) - Borsh-serialized
Vec<u8>for message (4-byte LE length + N bytes)
Returns a @solana/kit Instruction with the user as WRITABLE_SIGNER.
Orchestrates the full flow:
- Loads user and backend keypairs from JSON files
- Screens the user address via
screenRecipient - If risk score < 10, signs the attestation with
createSignedMessage - Builds the instruction with
buildVerifyRiskInstruction - Constructs a v0 transaction using
@solana/kitpipes (fee payer, blockhash, instruction) - Signs with
signTransactionMessageWithSignersand sends withsendAndConfirmTransactionFactory
Single instruction verify_risk_signature that accepts
VerifyRangeArgs { signature: Vec<u8>, message: Vec<u8> } and one account (the
user as Signer). Delegates verification to the accounts impl.
sig_verify(pubkey, sig, message)— Wrapsbrine_ed25519::sig_verifyto verify the Ed25519 signature on-chainextract_message(message_bytes)— Parses the UTF-8 message string by splitting on_, extracting the timestamp (u64), pubkey (Pubkey), and risk call ID (String)
- Extract signature and message from instruction data
- Verify the Ed25519 signature using
brine-ed25519 - Parse the message into its three components
- Assert the pubkey in the message matches the transaction signer
- Assert the timestamp is within
MAX_SCORE_LIFETIME(60 seconds) of the on-chain clock - Log success with the risk call ID
| Constant | Value | Purpose |
|---|---|---|
BACKEND_PUBKEY |
2auSfVh...R9Ha |
Public key of the backend signer (used for future on-chain verification against a known authority) |
MAX_SCORE_LIFETIME |
60 |
Maximum age in seconds for a valid attestation message |
| Error | When |
|---|---|
CouldntVerifySignature |
Ed25519 signature doesn't match |
WrongMessageSplitLength |
Message doesn't have exactly 3 underscore-separated parts |
TimestampParsingFailed |
First part of message isn't a valid u64 |
PubkeyParsingFailed |
Second part isn't a valid base58 pubkey |
SignersMismatch |
Pubkey in message doesn't match transaction signer |
ScoreLifetimeExpired |
Timestamp is older than 60 seconds |
npm installBackend keypair (optionally grind for a vanity prefix):
cd app
solana-keygen grind --starts-with SigV:1
mv SigV*.json backend_keypair.jsonUser keypair:
solana-keygen new --outfile user_keypair.json --no-bip39-passphrasesolana-keygen pubkey app/backend_keypair.jsonUpdate programs/sigverify/src/constants.rs with the output:
pub const BACKEND_PUBKEY: &str = "<your-backend-pubkey>";Create a .env file in the project root:
RANGE_API_KEY=<your-range-api-key>
anchor buildsurfpool start --watchThis starts a local Surfnet and watches for program recompilations. Every time
you run anchor build, the program is automatically redeployed. You can also
deploy manually:
surfpool run deploymentsolana airdrop 2 $(solana-keygen pubkey app/user_keypair.json) --url http://localhost:8899cd app
npx tsx index.tsExpected output:
Wallet: SigVErn... Risk score: 3 Level: Low risk
Transaction confirmed: 5xYk...
Open the transaction in Solana Explorer using the Surfpool custom RPC URL:
https://explorer.solana.com/tx/<SIGNATURE>?cluster=custom&customUrl=http://127.0.0.1:18488
Or inspect it directly in Surfpool Studio (opens automatically with
surfpool start).
The program logs should show:
Program log: Instruction: VerifyRiskSignature
Program log: Signature verified successfully for risk call ID: a1b2c3...
Program log: User can now be granted access to the protocol.
Program ID: 7wePxpHdrwL4M99f4UACiqNVcgZSLtkFfUn74UJHQJgC
Example transaction: UUk5YD2fQ6XdZ5vbhnf8RZSStPxM8o9512bs9h3Zn2Ppmwv2BRvG3YR252NJZgYuPhd9R9FQ1oPUaEUgky5WutZ