Skip to content

Commit 3a639ce

Browse files
feat: complete remaining audit items ΓÇö SDK adapter, Jito MEV protection, DB-backed RunLock, wallet adapter
#1: BagsSdkAdapter wrapping @bagsfm/bags-sdk with full BagsAdapter interface #8: Jito bundle signing in signAndSendSwap/Claim with tip accounts and bundle polling #11: Solana wallet adapter (Phantom/Solflare) with auto-fill ownerWallet #12: RunLock rewritten to persistent SQLite run_locks table with stale lock cleanup
1 parent cc0b514 commit 3a639ce

14 files changed

Lines changed: 1051 additions & 126 deletions

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { PublicKey, type Connection } from '@solana/web3.js';
2+
import { BagsSDK } from '@bagsfm/bags-sdk';
3+
import pino from 'pino';
4+
import type {
5+
BagsAdapter,
6+
BagsApiConfig,
7+
BagsRequestOptions,
8+
BagsRateLimitInfo,
9+
ClaimablePosition,
10+
TradeQuote,
11+
SwapTransaction,
12+
ClaimTransaction,
13+
} from '../types/index.js';
14+
15+
const logger = pino({ name: 'BagsSdkAdapter' });
16+
17+
/**
18+
* BagsAdapter implementation backed by the official @bagsfm/bags-sdk.
19+
*
20+
* Translates between SDK types (PublicKey, VersionedTransaction) and our
21+
* domain types (string addresses, base64/base58 encoded transactions).
22+
*
23+
* Advantages over raw axios BagsClient:
24+
* - Uses SDK-maintained HTTP client with built-in error handling
25+
* - Automatic SDK updates when Bags API changes
26+
* - Access to Jito bundle support via sdk.solana.sendBundle()
27+
* - Consistent type system with Bags ecosystem
28+
*/
29+
export class BagsSdkAdapter implements BagsAdapter {
30+
private readonly sdk: BagsSDK;
31+
private rateLimitInfo: BagsRateLimitInfo = { remaining: 100, resetAt: 0 };
32+
33+
constructor(
34+
private readonly config: BagsApiConfig,
35+
connection: Connection,
36+
) {
37+
this.sdk = new BagsSDK(config.apiKey, connection);
38+
logger.info('BagsSdkAdapter initialized with official SDK');
39+
}
40+
41+
/** Expose the underlying SDK for direct access (e.g., Jito bundles). */
42+
getSdk(): BagsSDK {
43+
return this.sdk;
44+
}
45+
46+
async getClaimablePositions(
47+
wallet: string,
48+
_options?: BagsRequestOptions,
49+
): Promise<ClaimablePosition[]> {
50+
logger.debug({ wallet }, 'Fetching claimable positions via SDK');
51+
52+
const pubkey = new PublicKey(wallet);
53+
const sdkPositions = await this.sdk.fee.getAllClaimablePositions(pubkey);
54+
55+
// Map SDK positions to our domain type
56+
const positions: ClaimablePosition[] = sdkPositions.map((pos: Record<string, unknown>) =>
57+
mapSdkPosition(pos),
58+
);
59+
60+
logger.info(
61+
{ wallet, count: positions.length },
62+
'Retrieved claimable positions via SDK',
63+
);
64+
return positions;
65+
}
66+
67+
async getClaimTransactions(
68+
feeClaimer: string,
69+
position: ClaimablePosition,
70+
_options?: BagsRequestOptions,
71+
): Promise<ClaimTransaction[]> {
72+
logger.debug(
73+
{ feeClaimer, virtualPool: position.virtualPoolAddress },
74+
'Fetching claim transactions via SDK',
75+
);
76+
77+
const wallet = new PublicKey(feeClaimer);
78+
const tokenMint = new PublicKey(position.baseMint);
79+
80+
const sdkTransactions = await this.sdk.fee.getClaimTransactions(
81+
wallet,
82+
tokenMint,
83+
);
84+
85+
// SDK returns Transaction objects; serialize back to our ClaimTransaction format
86+
const claimTxs: ClaimTransaction[] = sdkTransactions.map((tx) => {
87+
const serialized = tx.serialize();
88+
// Use base58 encoding for claim transactions (Bags.fm convention)
89+
const encoded = Buffer.from(serialized).toString('base64');
90+
// We need to extract blockhash from the transaction message
91+
const message = tx.compileMessage();
92+
return {
93+
tx: encoded,
94+
blockhash: {
95+
blockhash: message.recentBlockhash,
96+
lastValidBlockHeight: 0, // SDK doesn't expose this directly
97+
},
98+
};
99+
});
100+
101+
return claimTxs;
102+
}
103+
104+
async getTradeQuote(
105+
params: {
106+
inputMint: string;
107+
outputMint: string;
108+
amount: number;
109+
slippageBps?: number;
110+
},
111+
_options?: BagsRequestOptions,
112+
): Promise<TradeQuote> {
113+
logger.debug(
114+
{
115+
inputMint: params.inputMint,
116+
outputMint: params.outputMint,
117+
amount: params.amount,
118+
},
119+
'Fetching trade quote via SDK',
120+
);
121+
122+
const sdkQuote = await this.sdk.trade.getQuote({
123+
inputMint: new PublicKey(params.inputMint),
124+
outputMint: new PublicKey(params.outputMint),
125+
amount: params.amount,
126+
slippageBps: params.slippageBps,
127+
});
128+
129+
// Map SDK response to our TradeQuote type
130+
const quote: TradeQuote = {
131+
requestId: sdkQuote.requestId,
132+
contextSlot: sdkQuote.contextSlot,
133+
inAmount: sdkQuote.inAmount,
134+
inputMint: sdkQuote.inputMint,
135+
outAmount: sdkQuote.outAmount,
136+
outputMint: sdkQuote.outputMint,
137+
minOutAmount: sdkQuote.minOutAmount,
138+
otherAmountThreshold: sdkQuote.minOutAmount, // SDK maps this
139+
priceImpactPct: sdkQuote.priceImpactPct,
140+
slippageBps: sdkQuote.slippageBps,
141+
routePlan: sdkQuote.routePlan?.map((leg: Record<string, unknown>) => ({
142+
venue: (leg.venue as string) ?? '',
143+
inAmount: (leg.inAmount as string) ?? '',
144+
outAmount: (leg.outAmount as string) ?? '',
145+
inputMint: (leg.inputMint as string) ?? '',
146+
outputMint: (leg.outputMint as string) ?? '',
147+
inputMintDecimals: (leg.inputMintDecimals as number) ?? 0,
148+
outputMintDecimals: (leg.outputMintDecimals as number) ?? 0,
149+
marketKey: (leg.marketKey as string) ?? '',
150+
data: (leg.data as string) ?? '',
151+
})) ?? [],
152+
platformFee: {
153+
amount: (sdkQuote.platformFee as Record<string, unknown>)?.amount as string ?? '0',
154+
feeBps: (sdkQuote.platformFee as Record<string, unknown>)?.feeBps as number ?? 0,
155+
feeAccount: (sdkQuote.platformFee as Record<string, unknown>)?.feeAccount as string ?? '',
156+
segmenterFeeAmount: (sdkQuote.platformFee as Record<string, unknown>)?.segmenterFeeAmount as string ?? '0',
157+
segmenterFeePct: (sdkQuote.platformFee as Record<string, unknown>)?.segmenterFeePct as number ?? 0,
158+
},
159+
outTransferFee: sdkQuote.outTransferFee ?? '0',
160+
simulatedComputeUnits: sdkQuote.simulatedComputeUnits ?? 0,
161+
};
162+
163+
logger.info(
164+
{
165+
inAmount: quote.inAmount,
166+
outAmount: quote.outAmount,
167+
priceImpactPct: quote.priceImpactPct,
168+
},
169+
'Received trade quote via SDK',
170+
);
171+
return quote;
172+
}
173+
174+
async createSwapTransaction(
175+
quoteResponse: TradeQuote,
176+
userPublicKey: string,
177+
_options?: BagsRequestOptions,
178+
): Promise<SwapTransaction> {
179+
logger.debug(
180+
{ requestId: quoteResponse.requestId, userPublicKey },
181+
'Creating swap transaction via SDK',
182+
);
183+
184+
const sdkResult = await this.sdk.trade.createSwapTransaction({
185+
quoteResponse: quoteResponse as unknown as Parameters<typeof this.sdk.trade.createSwapTransaction>[0]['quoteResponse'],
186+
userPublicKey: new PublicKey(userPublicKey),
187+
});
188+
189+
// SDK returns VersionedTransaction object; serialize to base64 for our interface
190+
const serialized = sdkResult.transaction.serialize();
191+
const swapTx: SwapTransaction = {
192+
swapTransaction: Buffer.from(serialized).toString('base64'),
193+
computeUnitLimit: sdkResult.computeUnitLimit,
194+
lastValidBlockHeight: sdkResult.lastValidBlockHeight,
195+
prioritizationFeeLamports: sdkResult.prioritizationFeeLamports,
196+
};
197+
198+
logger.info(
199+
{
200+
requestId: quoteResponse.requestId,
201+
computeUnits: swapTx.computeUnitLimit,
202+
},
203+
'Swap transaction created via SDK',
204+
);
205+
return swapTx;
206+
}
207+
208+
async prepareSwap(
209+
params: {
210+
inputMint: string;
211+
outputMint: string;
212+
amount: number;
213+
userPublicKey: string;
214+
slippageBps?: number;
215+
maxPriceImpactBps?: number;
216+
},
217+
options?: BagsRequestOptions,
218+
): Promise<{ quote: TradeQuote; swapTx: SwapTransaction }> {
219+
const quote = await this.getTradeQuote(
220+
{
221+
inputMint: params.inputMint,
222+
outputMint: params.outputMint,
223+
amount: params.amount,
224+
slippageBps: params.slippageBps,
225+
},
226+
options,
227+
);
228+
229+
if (
230+
params.maxPriceImpactBps !== undefined &&
231+
parseFloat(quote.priceImpactPct) * 100 > params.maxPriceImpactBps
232+
) {
233+
throw new Error(
234+
`Price impact ${(parseFloat(quote.priceImpactPct) * 100).toFixed(2)} bps exceeds max ${params.maxPriceImpactBps} bps`,
235+
);
236+
}
237+
238+
const swapTx = await this.createSwapTransaction(
239+
quote,
240+
params.userPublicKey,
241+
options,
242+
);
243+
return { quote, swapTx };
244+
}
245+
246+
async getTotalClaimableSol(
247+
wallet: string,
248+
options?: BagsRequestOptions,
249+
): Promise<{ totalLamports: bigint; positions: ClaimablePosition[] }> {
250+
const positions = await this.getClaimablePositions(wallet, options);
251+
const totalLamports = positions.reduce(
252+
(sum, pos) => sum + BigInt(pos.totalClaimableLamportsUserShare),
253+
0n,
254+
);
255+
return { totalLamports, positions };
256+
}
257+
258+
getRateLimitStatus(): BagsRateLimitInfo {
259+
return { ...this.rateLimitInfo };
260+
}
261+
}
262+
263+
/**
264+
* Map an SDK BagsClaimablePosition to our ClaimablePosition domain type.
265+
* The SDK returns union types; we normalize to a flat structure.
266+
*/
267+
function mapSdkPosition(pos: Record<string, unknown>): ClaimablePosition {
268+
return {
269+
isCustomFeeVault: (pos.isCustomFeeVault as boolean) ?? false,
270+
baseMint: String(pos.baseMint ?? ''),
271+
isMigrated: (pos.isMigrated as boolean) ?? false,
272+
totalClaimableLamportsUserShare:
273+
(pos.totalClaimableLamportsUserShare as number) ?? 0,
274+
programId: String(pos.programId ?? ''),
275+
quoteMint: String(pos.quoteMint ?? ''),
276+
virtualPool: String(pos.virtualPool ?? ''),
277+
virtualPoolAddress: String(pos.virtualPoolAddress ?? ''),
278+
virtualPoolClaimableAmount:
279+
(pos.virtualPoolClaimableAmount as number) ?? 0,
280+
virtualPoolClaimableLamportsUserShare:
281+
(pos.virtualPoolClaimableLamportsUserShare as number) ?? 0,
282+
dammPoolClaimableAmount: (pos.dammPoolClaimableAmount as number) ?? 0,
283+
dammPoolClaimableLamportsUserShare:
284+
(pos.dammPoolClaimableLamportsUserShare as number) ?? 0,
285+
dammPoolAddress: String(pos.dammPoolAddress ?? ''),
286+
dammPositionInfo: pos.dammPositionInfo as ClaimablePosition['dammPositionInfo'],
287+
claimableDisplayAmount: (pos.claimableDisplayAmount as number) ?? 0,
288+
user: String(pos.user ?? ''),
289+
claimerIndex: (pos.claimerIndex as number) ?? 0,
290+
userBps: (pos.userBps as number) ?? 0,
291+
customFeeVault: String(pos.customFeeVault ?? ''),
292+
customFeeVaultClaimerA: String(pos.customFeeVaultClaimerA ?? ''),
293+
customFeeVaultClaimerB: String(pos.customFeeVaultClaimerB ?? ''),
294+
customFeeVaultClaimerSide:
295+
(pos.customFeeVaultClaimerSide as 'A' | 'B') ?? 'A',
296+
};
297+
}

0 commit comments

Comments
 (0)