@cks-systems/manifest-sdk
Version:
TypeScript SDK for Manifest
392 lines (353 loc) • 13 kB
text/typescript
import { WebSocketManager } from './utils/WebSocketManager';
import {
Connection,
ConfirmedSignatureInfo,
VersionedTransactionResponse,
} from '@solana/web3.js';
import { FillLog } from './manifest/accounts/FillLog';
import { PROGRAM_ID } from './manifest';
import { convertU128 } from './utils/numbers';
import { genAccDiscriminator } from './utils/discriminator';
import * as promClient from 'prom-client';
import { FillLogResult } from './types';
// For live monitoring of the fill feed. For a more complete look at fill
// history stats, need to index all trades.
const fills = new promClient.Counter({
name: 'fills',
help: 'Number of fills',
labelNames: ['market', 'isGlobal', 'takerIsBuy'] as const,
});
/**
* FillFeed example implementation.
*/
export class FillFeed {
private wsManager: WebSocketManager;
private shouldEnd: boolean = false;
private ended: boolean = false;
private lastUpdateUnix: number = Date.now();
constructor(private connection: Connection) {
this.wsManager = new WebSocketManager(1234, 30000);
}
public msSinceLastUpdate() {
return Date.now() - this.lastUpdateUnix;
}
public async stopParseLogs() {
this.shouldEnd = true;
const start = Date.now();
while (!this.ended) {
const timeout = 30_000;
const pollInterval = 500;
if (Date.now() - start > timeout) {
return Promise.reject(
new Error(
`failed to stop parseLogs after ${timeout / 1_000} seconds`,
),
);
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
return Promise.resolve();
}
/**
* Parse logs in an endless loop.
*/
public async parseLogs(endEarly?: boolean) {
// Start with a hopefully recent signature.
const lastSignatureStatus = (
await this.connection.getSignaturesForAddress(
PROGRAM_ID,
{ limit: 1 },
'finalized',
)
)[0];
let lastSignature: string | undefined = lastSignatureStatus.signature;
let lastSlot: number = lastSignatureStatus.slot;
// End early is 30 seconds, used for testing.
const endTime: Date = endEarly
? new Date(Date.now() + 30_000)
: new Date(Date.now() + 1_000_000_000_000);
// TODO: remove endTime in favor of stopParseLogs for testing
while (!this.shouldEnd && new Date(Date.now()) < endTime) {
// This sleep was originally implemented to wait until there was enough
// transactions to avoid just spamming the RPC. Reduced to just
// enough to avoid RPC spam, but not wait too long since the router
// integrations give us steady flow.
await new Promise((f) => setTimeout(f, 400));
const signatures: ConfirmedSignatureInfo[] =
await this.connection.getSignaturesForAddress(
PROGRAM_ID,
{
until: lastSignature,
},
'finalized',
);
// Flip it so we do oldest first.
signatures.reverse();
// Process even single signatures, but handle the edge case differently
if (signatures.length === 0) {
continue;
}
// If we only got back the same signature we already processed, skip it
if (
signatures.length === 1 &&
signatures[0].signature === lastSignature
) {
continue;
}
for (const signature of signatures) {
// Skip if we already processed this signature
if (signature.signature === lastSignature) {
continue;
}
// Separately track the last slot. This is necessary because sometimes
// gsfa ignores the until param and just gives 1_000 signatures.
if (signature.slot < lastSlot) {
continue;
}
await this.handleSignature(signature);
}
console.log(
'New last signature:',
signatures[signatures.length - 1].signature,
'New last signature slot:',
signatures[signatures.length - 1].slot,
'num sigs',
signatures.length,
);
lastSignature = signatures[signatures.length - 1].signature;
lastSlot = signatures[signatures.length - 1].slot;
this.lastUpdateUnix = Date.now();
}
console.log('ended loop');
this.wsManager.close();
this.ended = true;
}
/**
* Handle a signature by fetching the tx onchain and possibly sending a fill
* notification.
*/
private async handleSignature(signature: ConfirmedSignatureInfo) {
console.log('Handling', signature.signature, 'slot', signature.slot);
const tx = await this.connection.getTransaction(signature.signature, {
maxSupportedTransactionVersion: 0,
});
if (!tx?.meta?.logMessages) {
console.log('No log messages');
return;
}
if (tx.meta.err != null) {
console.log('Skipping failed tx', signature.signature);
return;
}
// Extract the original signer (fee payer/first signer) and all signers
let originalSigner: string | undefined;
let signers: string[] | undefined;
try {
const message = tx.transaction.message;
if ('accountKeys' in message) {
// Legacy transaction
originalSigner = message.accountKeys[0]?.toBase58();
// Extract all signers using isAccountSigner method
signers = message.accountKeys
.map((key, index) => ({ key, index }))
.filter(({ index }) => message.isAccountSigner(index))
.map(({ key }) => key.toBase58());
} else {
// Versioned transaction (v0) - use staticAccountKeys for the main accounts
originalSigner = message.staticAccountKeys[0]?.toBase58();
// Extract all signers using isAccountSigner method
signers = message.staticAccountKeys
.map((key, index) => ({ key, index }))
.filter(({ index }) => message.isAccountSigner(index))
.map(({ key }) => key.toBase58());
}
} catch (error) {
console.error('Error extracting signers:', error);
}
const aggregator: string | undefined = detectAggregator(tx);
const originatingProtocol: string | undefined =
detectOriginatingProtocol(tx);
const messages: string[] = tx?.meta?.logMessages!;
const programDatas: string[] = messages.filter((message) => {
return message.includes('Program data:');
});
if (programDatas.length == 0) {
console.log('No program datas');
return;
}
for (const programDataEntry of programDatas) {
const programData = programDataEntry.split(' ')[2];
const byteArray: Uint8Array = Uint8Array.from(atob(programData), (c) =>
c.charCodeAt(0),
);
const buffer = Buffer.from(byteArray);
if (!buffer.subarray(0, 8).equals(fillDiscriminant)) {
continue;
}
const deserializedFillLog: FillLog = FillLog.deserialize(
buffer.subarray(8),
)[0];
const fillResult = toFillLogResult(
deserializedFillLog,
signature.slot,
signature.signature,
originalSigner,
aggregator,
originatingProtocol,
signers,
// ?? undefined because can be null or undefined
signature.blockTime ?? undefined,
);
const resultString: string = JSON.stringify(fillResult);
console.log('Got a fill', resultString);
fills.inc({
market: deserializedFillLog.market.toString(),
isGlobal: deserializedFillLog.isMakerGlobal.toString(),
takerIsBuy: deserializedFillLog.takerIsBuy.toString(),
});
this.wsManager.broadcast(JSON.stringify(fillResult));
}
}
}
// Constants for known aggregators and protocols
export const AGGREGATOR_PROGRAM_IDS = {
MEXkeo4BPUCZuEJ4idUUwMPu4qvc9nkqtLn3yAyZLxg: 'Swissborg',
T1TANpTeScyeqVzzgNViGDNrkQ6qHz9KrSBS4aNXvGT: 'Titan',
'6m2CDdhRgxpH4WjvdzxAYbGxwdGUz5MziiL5jek2kBma': 'OKX',
proVF4pMXVaYqmy4NjniPh4pqKNfMmsihgd4wdkCX3u: 'OKX',
DF1ow4tspfHX9JwWJsAb9epbkA8hmpSEAtxXy1V27QBH: 'DFlow',
JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4: 'Jupiter',
SPURp82qAR9nvzy8j1gP31zmzGytrgDBKcpGzeGkka8: 'Spur',
s7SunwrPG5SbViEKiViaDThPRJxkkTrNx2iRPN3exNC: 'Bitget',
} as const;
export const ORIGINATING_PROTOCOL_IDS = {
LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF: 'kamino',
UMnFStVeG1ecZFc2gc5K3vFy3sMpotq8C91mXBQDGwh: 'cabana',
BQ72nSv9f3PRyRKCBnHLVrerrv37CYTHm5h3s9VSGQDV: 'jupiter', // JUP 1
'2MFoS3MPtvyQ4Wh4M9pdfPjz6UhVoNbFbGJAskCPCj3h': 'jupiter', // JUP 2
HU23r7UoZbqTUuh3vA7emAGztFtqwTeVips789vqxxBw: 'jupiter', // JUP 3
'6LXutJvKUw8Q5ue2gCgKHQdAN4suWW8awzFVC6XCguFx': 'jupiter', // JUP 5
CapuXNQoDviLvU1PxFiizLgPNQCxrsag1uMeyk6zLVps: 'jupiter', // JUP 6
GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ: 'jupiter', // JUP 7
'9nnLbotNTcUhvbrsA6Mdkx45Sm82G35zo28AqUvjExn8': 'jupiter', // JUP 8
'6U91aKa8pmMxkJwBCfPTmUEfZi6dHe7DcFq2ALvB2tbB': 'jupiter', // JUP 12
'4xDsmeTWPNjgSVSS1VTfzFq3iHZhp77ffPkAmkZkdu71': 'jupiter', // JUP 14
HFqp6ErWHY6Uzhj8rFyjYuDya2mXUpYEk8VW75K9PSiY: 'jupiter', // JUP 16
'9yj3zvLS3fDMqi1F8zhkaWfq8TZpZWHe6cz1Sgt7djXf': 'phantom',
'8psNvWTrdNTiVRNzAgsou9kETXNJm2SXZyaKuJraVRtf': 'phantom',
B3111yJCeHBcA1bizdJjUFPALfhAfSRnAbJzGUtnt56A: 'binance',
} as const;
// Helper function to detect aggregator from account keys
export function detectAggregatorFromKeys(
accountKeys: string[],
): string | undefined {
for (const account of accountKeys) {
const aggregator =
AGGREGATOR_PROGRAM_IDS[account as keyof typeof AGGREGATOR_PROGRAM_IDS];
if (aggregator) {
return aggregator;
}
}
return undefined;
}
// Helper function to detect originating protocol from account keys
export function detectOriginatingProtocolFromKeys(
accountKeys: string[],
): string | undefined {
for (const accountKey of accountKeys) {
const protocol =
ORIGINATING_PROTOCOL_IDS[
accountKey as keyof typeof ORIGINATING_PROTOCOL_IDS
];
if (protocol) {
return protocol;
}
}
return undefined;
}
function detectAggregator(
tx: VersionedTransactionResponse,
): string | undefined {
// Look for the aggregator program id from a list of known ids.
try {
// For versioned transactions, we need to handle both static and resolved account keys
const message = tx.transaction.message;
// Handle both legacy and versioned transactions
if ('accountKeys' in message) {
// Legacy transaction
const accountKeysStr = message.accountKeys.map((k) => k.toBase58());
return detectAggregatorFromKeys(accountKeysStr);
} else {
// V0 transaction - use staticAccountKeys directly to avoid lookup resolution issues
const accountKeysStr = message.staticAccountKeys.map((k) => k.toBase58());
return detectAggregatorFromKeys(accountKeysStr);
}
} catch (error) {
console.warn('Error detecting aggregator:', error);
// Fall back to undefined if we can't detect the aggregator
}
return undefined;
}
function detectOriginatingProtocol(
tx: VersionedTransactionResponse,
): string | undefined {
try {
const message = tx.transaction.message;
// Handle both legacy and versioned transactions
if ('accountKeys' in message) {
// Legacy transaction
const accountKeysStr = message.accountKeys.map((k) => k.toBase58());
return detectOriginatingProtocolFromKeys(accountKeysStr);
} else {
// V0 transaction - use staticAccountKeys directly to avoid lookup resolution issues
const accountKeysStr = message.staticAccountKeys.map((k) => k.toBase58());
return detectOriginatingProtocolFromKeys(accountKeysStr);
}
} catch (error) {
console.warn('Error detecting originating protocol:', error);
// Fall back to undefined if we can't detect the originating protocol
}
return undefined;
}
export const fillDiscriminant = genAccDiscriminator('manifest::logs::FillLog');
export function toFillLogResult(
fillLog: FillLog,
slot: number,
signature: string,
originalSigner?: string,
aggregator?: string,
originatingProtocol?: string,
signers?: string[],
blockTime?: number,
): FillLogResult {
const result: FillLogResult = {
market: fillLog.market.toBase58(),
maker: fillLog.maker.toBase58(),
taker: fillLog.taker.toBase58(),
baseAtoms: fillLog.baseAtoms.inner.toString(),
quoteAtoms: fillLog.quoteAtoms.inner.toString(),
priceAtoms: convertU128(fillLog.price.inner),
takerIsBuy: fillLog.takerIsBuy,
isMakerGlobal: fillLog.isMakerGlobal,
makerSequenceNumber: fillLog.makerSequenceNumber.toString(),
takerSequenceNumber: fillLog.takerSequenceNumber.toString(),
signature,
slot,
};
if (originalSigner) {
result.originalSigner = originalSigner;
}
if (aggregator) {
result.aggregator = aggregator;
}
if (originatingProtocol) {
result.originatingProtocol = originatingProtocol;
}
if (signers && signers.length > 0) {
result.signers = signers;
}
if (blockTime !== undefined) {
result.blockTime = blockTime;
}
return result;
}