Skip to content

rangesecurity/onchain-riskapi-sigverify

Repository files navigation

SigVerify — On-Chain Risk Score Attestation

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.

Architecture

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
Loading

Project Structure

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

Client / Backend (app/)

sdk.ts

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.

index.ts

Orchestrates the full flow:

  1. Loads user and backend keypairs from JSON files
  2. Screens the user address via screenRecipient
  3. If risk score < 10, signs the attestation with createSignedMessage
  4. Builds the instruction with buildVerifyRiskInstruction
  5. Constructs a v0 transaction using @solana/kit pipes (fee payer, blockhash, instruction)
  6. Signs with signTransactionMessageWithSigners and sends with sendAndConfirmTransactionFactory

On-Chain Program (programs/sigverify/)

lib.rs — Instruction Handler

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.

helpers.rs — ScoreVerifier

  • sig_verify(pubkey, sig, message) — Wraps brine_ed25519::sig_verify to verify the Ed25519 signature on-chain
  • extract_message(message_bytes) — Parses the UTF-8 message string by splitting on _, extracting the timestamp (u64), pubkey (Pubkey), and risk call ID (String)

lib.rs — Verification Steps

  1. Extract signature and message from instruction data
  2. Verify the Ed25519 signature using brine-ed25519
  3. Parse the message into its three components
  4. Assert the pubkey in the message matches the transaction signer
  5. Assert the timestamp is within MAX_SCORE_LIFETIME (60 seconds) of the on-chain clock
  6. Log success with the risk call ID

constants.rs

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.rs

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

Setup

1. Install dependencies

npm install

2. Generate keypairs

Backend keypair (optionally grind for a vanity prefix):

cd app
solana-keygen grind --starts-with SigV:1
mv SigV*.json backend_keypair.json

User keypair:

solana-keygen new --outfile user_keypair.json --no-bip39-passphrase

3. Set the backend public key in the program

solana-keygen pubkey app/backend_keypair.json

Update programs/sigverify/src/constants.rs with the output:

pub const BACKEND_PUBKEY: &str = "<your-backend-pubkey>";

4. Add your Range API key

Create a .env file in the project root:

RANGE_API_KEY=<your-range-api-key>

Build, Deploy, and Test

Step 1: Build the Anchor program

anchor build

Step 2: Start Surfpool with auto-deploy

surfpool start --watch

This 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 deployment

Step 3: Airdrop SOL to the user wallet

solana airdrop 2 $(solana-keygen pubkey app/user_keypair.json) --url http://localhost:8899

Step 4: Run the app

cd app
npx tsx index.ts

Expected output:

Wallet: SigVErn... Risk score: 3 Level: Low risk
Transaction confirmed: 5xYk...

Step 5: Verify the transaction

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.

Devnet

Program ID: 7wePxpHdrwL4M99f4UACiqNVcgZSLtkFfUn74UJHQJgC

Example transaction: UUk5YD2fQ6XdZ5vbhnf8RZSStPxM8o9512bs9h3Zn2Ppmwv2BRvG3YR252NJZgYuPhd9R9FQ1oPUaEUgky5WutZ

About

OnChain Risk API using Ed25519 signatures.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors