Skip to content

Commit 782dc44

Browse files
committed
fix: many fixes and testing script to bring cross collat margin calc parity
1 parent 02a46b0 commit 782dc44

File tree

5 files changed

+4610
-43
lines changed

5 files changed

+4610
-43
lines changed

sdk/scripts/compare-user-parity.ts

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
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();

sdk/src/math/margin.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,25 @@ export function calculateWorstCaseBaseAssetAmount(
165165
export function calculateWorstCasePerpLiabilityValue(
166166
perpPosition: PerpPosition,
167167
perpMarket: PerpMarketAccount,
168-
oraclePrice: BN
168+
oraclePrice: BN,
169+
includeOpenOrders = true
169170
): { worstCaseBaseAssetAmount: BN; worstCaseLiabilityValue: BN } {
171+
const isPredictionMarket = isVariant(perpMarket.contractType, 'prediction');
172+
173+
if (!includeOpenOrders) {
174+
return {
175+
worstCaseBaseAssetAmount: perpPosition.baseAssetAmount,
176+
worstCaseLiabilityValue: calculatePerpLiabilityValue(
177+
perpPosition.baseAssetAmount,
178+
oraclePrice,
179+
isPredictionMarket
180+
),
181+
};
182+
}
183+
170184
const allBids = perpPosition.baseAssetAmount.add(perpPosition.openBids);
171185
const allAsks = perpPosition.baseAssetAmount.add(perpPosition.openAsks);
172186

173-
const isPredictionMarket = isVariant(perpMarket.contractType, 'prediction');
174187
const allBidsLiabilityValue = calculatePerpLiabilityValue(
175188
allBids,
176189
oraclePrice,

sdk/src/math/spotPosition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function getWorstCaseTokenAmounts(
3434
strictOraclePrice: StrictOraclePrice,
3535
marginCategory: MarginCategory,
3636
customMarginRatio?: number,
37-
includeOpenOrders?: boolean
37+
includeOpenOrders: boolean = true
3838
): OrderFillSimulation {
3939
const tokenAmount = getSignedTokenAmount(
4040
getTokenAmount(

0 commit comments

Comments
 (0)