Privacy protocols on Solana - mixers, privacy pools, confidential transfer
programs - face a unique challenge: they need to prevent sanctioned or stolen
funds from entering the protocol while preserving user privacy for legitimate
users. If tainted funds flow through your protocol, it taints the entire pool
and exposes your project to regulatory risk.
Range’s Address Risk Score provides a
straightforward solution: screen the depositor’s wallet before accepting funds.
Block high-risk wallets at the door - before they can contaminate the pool.
Why Screen Deposits?
| Without screening | With screening |
|---|
| Stolen funds enter your privacy pool | Stolen funds are rejected before deposit |
| OFAC-sanctioned addresses mix freely | Sanctioned addresses are blocked at the door |
| Your protocol becomes a laundering vector | Your protocol stays clean for legitimate users |
| Regulators target your protocol | Demonstrable compliance controls |
| Honest users’ funds are tainted by association | Pool integrity is preserved |
Privacy and compliance are not mutually exclusive. Screening depositors (not
tracking what happens inside the pool) lets you maintain privacy guarantees for
legitimate users while blocking bad actors.
Architecture
User wants to deposit
│
▼
┌──────────────────────────┐
│ Screen depositor wallet │
│ (Address Risk Score) │
│ │
│ riskScore 1–10 │
│ Includes sanctions + │
│ ML + proximity analysis │
└──────────────────────────┘
│
├── Score ≥ 7 ──────► REJECT deposit
│ Show reason
│
├── Score 4–6 ──────► FLAG for review
│ Optional: require
│ additional verification
│
└── Score 1–3 ──────► ALLOW deposit
Proceed normally
Address Risk Score already includes sanctions and blacklist screening. You do
not need a separate sanctions check - OFAC-flagged and stablecoin-blacklisted
addresses are factored into the risk score.
Step 1: Screen the Depositor
Before accepting a deposit, check the wallet’s risk score. This single API call
covers malicious proximity, ML-based behavioral analysis, sanctions, and
blacklist screening.
curl -G https://api.range.org/v1/risk/address \
--data-urlencode "address=DEPOSITOR_ADDRESS" \
--data-urlencode "network=solana" \
-H "Authorization: Bearer your_api_key_here"
async function screenDepositor(address: string) {
const params = new URLSearchParams({
address,
network: 'solana',
});
const response = await fetch(
`https://api.range.org/v1/risk/address?${params}`,
{ headers: { Authorization: `Bearer ${API_KEY}` } },
);
return response.json();
}
Example Response - Flagged Wallet
{
"riskScore": 9,
"riskLevel": "Extremely high risk",
"numHops": 1,
"maliciousAddressesFound": [
{
"address": "SuspiciousAddr...",
"distance": 1,
"name_tag": "Exploit Funds",
"entity": null,
"category": "hack_funds"
}
],
"reasoning": "Address is 1 hop from known exploit funds.",
"attribution": null
}
This wallet is 1 hop from known hack funds - it should be rejected.
Step 2: Make a Deposit Decision
interface RiskData {
riskScore: number;
riskLevel: string;
reasoning: string;
attribution: { name_tag: string; entity: string } | null;
}
interface DepositDecision {
decision: 'allow' | 'reject' | 'flag';
reason: string;
}
function evaluateDeposit(riskData: RiskData): DepositDecision {
const { riskScore, reasoning, attribution } = riskData;
// Known system addresses and verified entities are always safe
if (attribution) {
return {
decision: 'allow',
reason: `Verified entity: ${attribution.name_tag} (${attribution.entity})`,
};
}
// High risk - reject
if (riskScore >= 7) {
return {
decision: 'reject',
reason: `Risk score ${riskScore}/10: ${reasoning}`,
};
}
// Medium risk - flag for review or require additional verification
if (riskScore >= 4) {
return {
decision: 'flag',
reason: `Elevated risk (${riskScore}/10): ${reasoning}`,
};
}
// Low risk - allow
return {
decision: 'allow',
reason: 'Address screening passed',
};
}
Step 3: Integrate into Your Deposit Flow
Off-Chain (Backend / Frontend)
Screen wallets in your dApp frontend or backend before constructing the deposit
transaction:
async function handleDeposit(depositorAddress: string, amount: number) {
// Screen the wallet
const riskData = await screenDepositor(depositorAddress);
const evaluation = evaluateDeposit(riskData);
if (evaluation.decision === 'reject') {
return {
status: 'rejected',
message:
'This wallet has been flagged for connections to malicious activity. ' +
'Deposit not permitted.',
};
}
if (evaluation.decision === 'flag') {
// Optional: require additional verification for medium-risk wallets
const verified = await requestAdditionalVerification(depositorAddress);
if (!verified) {
return {
status: 'rejected',
message: 'Additional verification required.',
};
}
}
// Proceed with deposit
return await executeDeposit(depositorAddress, amount);
}
On-Chain (Solana Program)
For protocol-level enforcement that can’t be bypassed by calling the program
directly, use the
Onchain Risk Verifier. This verifies
risk scores inside your Solana program using Switchboard oracles:
// In your deposit instruction handler:
let risk_score = verified_feed.value(); // 0–100 scale (original 0–10 × 10)
// Reject deposits from high-risk wallets
if risk_score >= 70 {
return Err(ErrorCode::HighRiskDepositor.into());
}
// Proceed with deposit logic
On-chain enforcement is stronger than off-chain screening alone. Users can
bypass your frontend, but they can’t bypass your program’s instruction logic.
For maximum protection, use both: off-chain screening for UX (show the user
why they’re blocked) and on-chain verification as the enforcement layer.
Alternative: Signed message attestation. Instead of using Switchboard
oracles, you can implement a lighter-weight pattern where your backend signs
an attestation message containing the screened wallet address and a timestamp.
The Solana program then uses Ed25519 signature verification (via the Ed25519
precompile) and instruction introspection to confirm: (1) the message was
signed by your trusted attestation key, (2) the depositor matches the approved
address in the message, and (3) the timestamp is fresh. This approach trades
the decentralized oracle model for a simpler trusted-backend pattern.
Choosing Your Risk Threshold
The right threshold depends on your protocol’s risk tolerance and regulatory
posture:
| Threshold | Rejects | Trade-off |
|---|
| Score ≥ 8 | Only directly malicious or 1-hop addresses | Permissive - blocks clear threats, allows most users |
| Score ≥ 7 | Addresses within 2 hops of malicious actors | Balanced - recommended starting point |
| Score ≥ 6 | Addresses within 2 hops (with multiple malicious connections) | Strict - fewer false negatives, more false positives |
| Score ≥ 4 | Addresses within 3 hops | Very strict - suitable for regulated environments |
Setting the threshold too low (e.g., ≥ 3) will reject a significant number of
legitimate users. Many clean addresses are naturally within 4–5 hops of
flagged addresses through shared exchange or program interactions. Start with
≥ 7 and adjust based on your data.
Handling Edge Cases
What If the Risk Check Fails?
Network errors or API timeouts shouldn’t block legitimate deposits indefinitely.
Implement a fallback policy:
async function screenWithFallback(address: string) {
try {
const result = await Promise.race([
screenDepositor(address),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 5000),
),
]);
return evaluateDeposit(result);
} catch (error) {
// Fallback: allow with logging for manual review
console.warn(`Risk check failed for ${address}: ${error}`);
return {
decision: 'allow',
reason: 'Risk check unavailable - allowed with manual review flag',
requiresReview: true,
};
}
}
Known System Addresses
Some addresses that interact with your protocol will be system programs, token
programs, or exchange hot wallets. These will have an attribution field in the
response - use it to fast-path known entities:
if (riskData.attribution) {
// Verified non-malicious entity - skip further checks
console.log(`Known entity: ${riskData.attribution.name_tag}`);
return { decision: 'allow' };
}
Rate Limits
If you’re processing high deposit volume, see
Rate Limits & Plans for scaling options.
For on-chain enforcement via Switchboard oracles, rate limits apply to the
oracle quote requests, not the on-chain verification.
Compliance Considerations
Screening deposits demonstrates that your protocol has taken reasonable steps to
prevent misuse. Document your screening policy and keep records:
- Policy document - Define your risk threshold and what happens when a
deposit is rejected
- Audit log - Store the risk score, reasoning, and decision for each
screened deposit
- Threshold rationale - Document why you chose your threshold and any
adjustments over time
- False positive handling - Have a process for users to contest rejections
async function logScreeningResult(
depositor: string,
riskData: RiskData,
decision: DepositDecision,
) {
await complianceDB.insert({
timestamp: new Date().toISOString(),
depositor_address: depositor,
risk_score: riskData.riskScore,
risk_level: riskData.riskLevel,
reasoning: riskData.reasoning,
decision: decision.decision,
decision_reason: decision.reason,
});
}
What’s Next