|
| 1 | +import { Connection, Commitment, PublicKey } from '@solana/web3.js'; |
| 2 | +import { AnchorProvider, BN } from '@coral-xyz/anchor'; |
| 3 | +import { Keypair } from '@solana/web3.js'; |
| 4 | + |
| 5 | +import { DriftClient } from '../src/driftClient'; |
| 6 | +import { BulkAccountLoader } from '../src/accounts/bulkAccountLoader'; |
| 7 | +import { DRIFT_PROGRAM_ID, Wallet } from '../src'; |
| 8 | +import { User as CurrentUser } from '../src/user'; |
| 9 | +import { User as OldUser } from '../src/user_oldMarginCalculation'; |
| 10 | +import { UserMap } from '../src/userMap/userMap'; |
| 11 | +import { UserMapConfig } from '../src/userMap/userMapConfig'; |
| 12 | + |
| 13 | +type MarginCategory = 'Initial' | 'Maintenance'; |
| 14 | + |
| 15 | +function getEnv(name: string, fallback?: string): string { |
| 16 | + const v = process.env[name]; |
| 17 | + if (v === undefined || v === '') { |
| 18 | + if (fallback !== undefined) return fallback; |
| 19 | + throw new Error(`${name} env var must be set.`); |
| 20 | + } |
| 21 | + return v; |
| 22 | +} |
| 23 | + |
| 24 | +function asCommitment( |
| 25 | + maybe: string | undefined, |
| 26 | + fallback: Commitment |
| 27 | +): Commitment { |
| 28 | + const val = (maybe as Commitment) || fallback; |
| 29 | + return val; |
| 30 | +} |
| 31 | + |
| 32 | +function bnEq(a: BN, b: BN): boolean { |
| 33 | + return a.eq(b); |
| 34 | +} |
| 35 | + |
| 36 | +function buildOldUserFromSnapshot( |
| 37 | + driftClient: DriftClient, |
| 38 | + currentUser: CurrentUser |
| 39 | +): OldUser { |
| 40 | + const userAccountPubkey = currentUser.getUserAccountPublicKey(); |
| 41 | + |
| 42 | + const oldUser = new OldUser({ |
| 43 | + driftClient, |
| 44 | + userAccountPublicKey: userAccountPubkey, |
| 45 | + accountSubscription: { |
| 46 | + type: 'custom', |
| 47 | + userAccountSubscriber: currentUser.accountSubscriber, |
| 48 | + }, |
| 49 | + }); |
| 50 | + |
| 51 | + return oldUser; |
| 52 | +} |
| 53 | + |
| 54 | +function logMismatch( |
| 55 | + userPubkey: PublicKey, |
| 56 | + fn: string, |
| 57 | + args: Record<string, unknown>, |
| 58 | + vNew: BN, |
| 59 | + vOld: BN |
| 60 | +) { |
| 61 | + // Ensure BN values are logged as strings and arrays are printable |
| 62 | + const serialize = (val: unknown): unknown => { |
| 63 | + if (val instanceof BN) return val.toString(); |
| 64 | + if (Array.isArray(val)) |
| 65 | + return val.map((x) => (x instanceof BN ? x.toString() : x)); |
| 66 | + return val; |
| 67 | + }; |
| 68 | + |
| 69 | + const argsSerialized: Record<string, unknown> = {}; |
| 70 | + for (const k of Object.keys(args)) { |
| 71 | + argsSerialized[k] = serialize(args[k]); |
| 72 | + } |
| 73 | + |
| 74 | + const argsLines = Object.keys(argsSerialized) |
| 75 | + .map( |
| 76 | + (k) => |
| 77 | + `\t- ${k}: ${ |
| 78 | + Array.isArray(argsSerialized[k]) |
| 79 | + ? (argsSerialized[k] as unknown[]).join(', ') |
| 80 | + : String(argsSerialized[k]) |
| 81 | + }` |
| 82 | + ) |
| 83 | + .join('|'); |
| 84 | + |
| 85 | + console.error( |
| 86 | + // `❌ Parity mismatch\n` + |
| 87 | + `- ❌ user: ${userPubkey.toBase58()} | function: ${fn}\n` + |
| 88 | + `- args:\n${argsLines || '\t- none'}\n` + |
| 89 | + `- new: ${vNew.toString()} | old: ${vOld.toString()}\n` |
| 90 | + ); |
| 91 | +} |
| 92 | + |
| 93 | +async function main(): Promise<void> { |
| 94 | + const RPC_ENDPOINT = getEnv('RPC_ENDPOINT'); |
| 95 | + const COMMITMENT = asCommitment(process.env.COMMITMENT, 'processed'); |
| 96 | + const POLL_FREQUENCY_MS = Number(process.env.POLL_FREQUENCY_MS || '40000'); |
| 97 | + |
| 98 | + const connection = new Connection(RPC_ENDPOINT, COMMITMENT); |
| 99 | + const wallet = new Wallet(new Keypair()); |
| 100 | + |
| 101 | + // AnchorProvider is not strictly required for polling, but some downstream utils expect a provider on the program |
| 102 | + // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| 103 | + const _provider = new AnchorProvider( |
| 104 | + connection, |
| 105 | + wallet as unknown as AnchorProvider['wallet'], |
| 106 | + { |
| 107 | + commitment: COMMITMENT, |
| 108 | + preflightCommitment: COMMITMENT, |
| 109 | + } |
| 110 | + ); |
| 111 | + |
| 112 | + const bulkAccountLoader = new BulkAccountLoader( |
| 113 | + connection, |
| 114 | + COMMITMENT, |
| 115 | + POLL_FREQUENCY_MS |
| 116 | + ); |
| 117 | + |
| 118 | + const driftClient = new DriftClient({ |
| 119 | + connection, |
| 120 | + wallet, |
| 121 | + programID: new PublicKey(DRIFT_PROGRAM_ID), |
| 122 | + accountSubscription: { |
| 123 | + type: 'polling', |
| 124 | + accountLoader: bulkAccountLoader, |
| 125 | + }, |
| 126 | + }); |
| 127 | + |
| 128 | + await driftClient.subscribe(); |
| 129 | + |
| 130 | + const userMapConfig: UserMapConfig = { |
| 131 | + driftClient, |
| 132 | + subscriptionConfig: { |
| 133 | + type: 'polling', |
| 134 | + frequency: POLL_FREQUENCY_MS, |
| 135 | + commitment: COMMITMENT, |
| 136 | + }, |
| 137 | + includeIdle: false, |
| 138 | + fastDecode: true, |
| 139 | + throwOnFailedSync: false, |
| 140 | + }; |
| 141 | + |
| 142 | + const userMap = new UserMap(userMapConfig); |
| 143 | + await userMap.subscribe(); |
| 144 | + await userMap.sync(); |
| 145 | + |
| 146 | + let mismatches = 0; |
| 147 | + let usersChecked = 0; |
| 148 | + const mismatchesByFunction: Record<string, number> = {}; |
| 149 | + const usersWithDiscrepancies = new Set<string>(); |
| 150 | + |
| 151 | + const isolatedKeysEnv = process.env.ISOLATED_USER_PUBKEY; |
| 152 | + const isolatedKeys = |
| 153 | + isolatedKeysEnv && isolatedKeysEnv.length > 0 |
| 154 | + ? isolatedKeysEnv |
| 155 | + .split(',') |
| 156 | + .map((k) => k.trim()) |
| 157 | + .filter((k) => k.length > 0) |
| 158 | + : []; |
| 159 | + |
| 160 | + const usersFilterd = |
| 161 | + isolatedKeys.length > 0 |
| 162 | + ? Array.from(userMap.entries()).filter(([userKey]) => |
| 163 | + isolatedKeys.includes(userKey) |
| 164 | + ) |
| 165 | + : Array.from(userMap.entries()); |
| 166 | + |
| 167 | + for (const [userKey, currUser] of usersFilterd) { |
| 168 | + usersChecked += 1; |
| 169 | + const userPubkey = new PublicKey(userKey); |
| 170 | + |
| 171 | + function noteMismatch(functionName: string): void { |
| 172 | + mismatchesByFunction[functionName] = |
| 173 | + (mismatchesByFunction[functionName] ?? 0) + 1; |
| 174 | + usersWithDiscrepancies.add(userPubkey.toBase58()); |
| 175 | + mismatches += 1; |
| 176 | + } |
| 177 | + |
| 178 | + // clean curr User position flags to be all 0 |
| 179 | + |
| 180 | + currUser.getActivePerpPositions().forEach((position) => { |
| 181 | + position.positionFlag = 0; |
| 182 | + }); |
| 183 | + |
| 184 | + const oldUser = buildOldUserFromSnapshot(driftClient, currUser, COMMITMENT); |
| 185 | + |
| 186 | + try { |
| 187 | + // Cross-account level comparisons |
| 188 | + // const categories: MarginCategory[] = ['Initial', 'Maintenance']; |
| 189 | + const categories: MarginCategory[] = ['Initial']; |
| 190 | + // const categories: MarginCategory[] = ['Maintenance']; |
| 191 | + // const categories: MarginCategory[] = []; |
| 192 | + |
| 193 | + for (const cat of categories) { |
| 194 | + // getFreeCollateral |
| 195 | + const vNew_fc = currUser.getFreeCollateral(cat); |
| 196 | + const vOld_fc = oldUser.getFreeCollateral(cat); |
| 197 | + if (!bnEq(vNew_fc, vOld_fc)) { |
| 198 | + logMismatch( |
| 199 | + userPubkey, |
| 200 | + 'getFreeCollateral', |
| 201 | + { marginCategory: cat }, |
| 202 | + vNew_fc, |
| 203 | + vOld_fc |
| 204 | + ); |
| 205 | + noteMismatch('getFreeCollateral'); |
| 206 | + } |
| 207 | + |
| 208 | + // only do free collateral for now |
| 209 | + // continue; |
| 210 | + |
| 211 | + // getTotalCollateral |
| 212 | + const vNew_tc = currUser.getTotalCollateral(cat); |
| 213 | + const vOld_tc = oldUser.getTotalCollateral(cat); |
| 214 | + if (!bnEq(vNew_tc, vOld_tc)) { |
| 215 | + logMismatch( |
| 216 | + userPubkey, |
| 217 | + 'getTotalCollateral', |
| 218 | + { marginCategory: cat }, |
| 219 | + vNew_tc, |
| 220 | + vOld_tc |
| 221 | + ); |
| 222 | + noteMismatch('getTotalCollateral'); |
| 223 | + } |
| 224 | + |
| 225 | + // getMarginRequirement (strict=true, includeOpenOrders=true) |
| 226 | + const vNew_mr = currUser.getMarginRequirement( |
| 227 | + cat, |
| 228 | + undefined, |
| 229 | + true, |
| 230 | + true |
| 231 | + ); |
| 232 | + const vOld_mr = oldUser.getMarginRequirement( |
| 233 | + cat, |
| 234 | + undefined, |
| 235 | + true, |
| 236 | + true |
| 237 | + ); |
| 238 | + if (!bnEq(vNew_mr, vOld_mr)) { |
| 239 | + logMismatch( |
| 240 | + userPubkey, |
| 241 | + 'getMarginRequirement', |
| 242 | + { marginCategory: cat, strict: true, includeOpenOrders: true }, |
| 243 | + vNew_mr, |
| 244 | + vOld_mr |
| 245 | + ); |
| 246 | + noteMismatch('getMarginRequirement'); |
| 247 | + } |
| 248 | + } |
| 249 | + // continue; |
| 250 | + |
| 251 | + // Per-perp-market comparisons |
| 252 | + const activePerpPositions = currUser.getActivePerpPositions(); |
| 253 | + for (const pos of activePerpPositions) { |
| 254 | + const marketIndex = pos.marketIndex; |
| 255 | + |
| 256 | + // getPerpBuyingPower |
| 257 | + const vNew_pbp = currUser.getPerpBuyingPower(marketIndex); |
| 258 | + const vOld_pbp = oldUser.getPerpBuyingPower(marketIndex); |
| 259 | + if (!bnEq(vNew_pbp, vOld_pbp)) { |
| 260 | + logMismatch( |
| 261 | + userPubkey, |
| 262 | + 'getPerpBuyingPower', |
| 263 | + { marketIndex }, |
| 264 | + vNew_pbp, |
| 265 | + vOld_pbp |
| 266 | + ); |
| 267 | + noteMismatch('getPerpBuyingPower'); |
| 268 | + } |
| 269 | + |
| 270 | + // liquidationPrice (defaults) |
| 271 | + const vNew_lp = currUser.liquidationPrice(marketIndex); |
| 272 | + const vOld_lp = oldUser.liquidationPrice(marketIndex); |
| 273 | + if (!bnEq(vNew_lp, vOld_lp)) { |
| 274 | + logMismatch( |
| 275 | + userPubkey, |
| 276 | + 'liquidationPrice', |
| 277 | + { marketIndex }, |
| 278 | + vNew_lp, |
| 279 | + vOld_lp |
| 280 | + ); |
| 281 | + noteMismatch('liquidationPrice'); |
| 282 | + } |
| 283 | + |
| 284 | + // liquidationPriceAfterClose with 10% of current quote as close amount (skip if zero/absent) |
| 285 | + const quoteAbs = pos.quoteAssetAmount |
| 286 | + ? pos.quoteAssetAmount.abs() |
| 287 | + : new BN(0); |
| 288 | + const closeQuoteAmount = quoteAbs.div(new BN(10)); |
| 289 | + if (closeQuoteAmount.gt(new BN(0))) { |
| 290 | + const vNew_lpac = currUser.liquidationPriceAfterClose( |
| 291 | + marketIndex, |
| 292 | + closeQuoteAmount |
| 293 | + ); |
| 294 | + const vOld_lpac = oldUser.liquidationPriceAfterClose( |
| 295 | + marketIndex, |
| 296 | + closeQuoteAmount |
| 297 | + ); |
| 298 | + if (!bnEq(vNew_lpac, vOld_lpac)) { |
| 299 | + logMismatch( |
| 300 | + userPubkey, |
| 301 | + 'liquidationPriceAfterClose', |
| 302 | + { marketIndex, closeQuoteAmount: closeQuoteAmount.toString() }, |
| 303 | + vNew_lpac, |
| 304 | + vOld_lpac |
| 305 | + ); |
| 306 | + noteMismatch('liquidationPriceAfterClose'); |
| 307 | + } |
| 308 | + } |
| 309 | + } |
| 310 | + } catch (e) { |
| 311 | + console.error( |
| 312 | + `💥 Parity exception\n` + |
| 313 | + `- user: ${userPubkey.toBase58()}\n` + |
| 314 | + `- error: ${(e as Error).message}` |
| 315 | + ); |
| 316 | + usersWithDiscrepancies.add(userPubkey.toBase58()); |
| 317 | + mismatches += 1; |
| 318 | + } finally { |
| 319 | + await oldUser.unsubscribe(); |
| 320 | + } |
| 321 | + } |
| 322 | + |
| 323 | + const byFunctionLines = Object.entries(mismatchesByFunction) |
| 324 | + .sort((a, b) => b[1] - a[1]) |
| 325 | + .map(([fn, count]) => `\t- ${fn}: ${count}`) |
| 326 | + .join('\n'); |
| 327 | + |
| 328 | + console.log( |
| 329 | + `\n📊 User parity summary\n` + |
| 330 | + `- users checked: ${usersChecked}\n` + |
| 331 | + `- users with discrepancy: ${usersWithDiscrepancies.size}\n` + |
| 332 | + `- percentage of users with discrepancy: ${ |
| 333 | + (usersWithDiscrepancies.size / usersChecked) * 100 |
| 334 | + }%\n` + |
| 335 | + `- total mismatches: ${mismatches}\n` + |
| 336 | + // `- percentage of mismatches: ${(mismatches / usersChecked) * 100}%\n` + |
| 337 | + `- mismatches by function:\n${byFunctionLines || '\t- none'}\n` |
| 338 | + ); |
| 339 | + |
| 340 | + await userMap.unsubscribe(); |
| 341 | + await driftClient.unsubscribe(); |
| 342 | + |
| 343 | + if (mismatches > 0) { |
| 344 | + process.exit(1); |
| 345 | + } else { |
| 346 | + process.exit(0); |
| 347 | + } |
| 348 | +} |
| 349 | + |
| 350 | +// eslint-disable-next-line @typescript-eslint/no-floating-promises |
| 351 | +main(); |
0 commit comments