Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
539 changes: 539 additions & 0 deletions src/amm/index.ts

Large diffs are not rendered by default.

543 changes: 543 additions & 0 deletions src/amm/pool.ts

Large diffs are not rendered by default.

395 changes: 395 additions & 0 deletions src/amm/queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,395 @@
/**
* VRC-20 AMM — Seller Queue Management
*
* Sellers list their VRC-20 tokens at AMM price by joining a FIFO queue.
* When a buyer swaps VTC for tokens, sellers are matched in order.
* Cancellation incurs escalating slashing penalties to prevent manipulation.
*/

import { v4 as uuidv4 } from "uuid";
import {
getPool,
getQueueAdjustedQuote,
applySwap,
returnTokensToReserve,
} from "./pool.ts";
import type { SwapQuote } from "./pool.ts";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface QueueEntry {
id: string;
poolId: string;
sellerAddress: string;
tokenAmount: number;
remainingAmount: number;
joinedAtBlock: number;
joinedAt: number; // unix ms
}

export interface QueueCancelRecord {
sellerAddress: string;
poolId: string;
cancelledAtBlock: number;
cancelCount: number; // within 100-block window
}

export interface SwapMatch {
sellerId: string;
sellerAddress: string;
tokensFromSeller: number;
vtcToSeller: number;
}

export interface SwapResult {
poolId: string;
buyerAddress: string;
vtcSpent: number;
totalTokensReceived: number;
quote: SwapQuote;
matches: SwapMatch[];
remainingVtcUnmatched: number;
}

// ---------------------------------------------------------------------------
// Storage
// ---------------------------------------------------------------------------

/** poolId → ordered array of queue entries (FIFO) */
const queues: Map<string, QueueEntry[]> = new Map();

/** `${poolId}:${sellerAddress}` → cancel history */
const cancelHistory: Map<string, QueueCancelRecord> = new Map();

/** Rate limiter: `${poolId}:${block}` → swap count */
const swapRateLimit: Map<string, number> = new Map();

const MAX_SWAPS_PER_BLOCK_PER_POOL = 24;
const SLASH_WINDOW_BLOCKS = 100;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function getQueue(poolId: string): QueueEntry[] {
let q = queues.get(poolId);
if (!q) {
q = [];
queues.set(poolId, q);
}
return q;
}

/**
* Calculate slash percentage based on cancel count within the 100-block window.
* 1st cancel: 50%, 2nd: 70%, 3rd+: 90%
*/
function getSlashPercent(cancelCount: number): number {
if (cancelCount <= 1) return 50;
if (cancelCount === 2) return 70;
return 90;
}

function getCancelKey(poolId: string, sellerAddress: string): string {
return `${poolId}:${sellerAddress}`;
}

function getRateLimitKey(poolId: string, block: number): string {
return `${poolId}:${block}`;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Add a seller to the queue for a given pool.
*
* @param poolId - Pool to join
* @param sellerAddress - Vertcoin address of the seller (vtc1...)
* @param tokenAmount - Number of tokens to list for sale
* @param currentBlock - Current block height (for cancel tracking)
* @returns The created QueueEntry
*/
export function joinQueue(
poolId: string,
sellerAddress: string,
tokenAmount: number,
currentBlock: number = 0
): QueueEntry {
const pool = getPool(poolId);
if (!pool) throw new Error(`Pool ${poolId} not found`);

if (tokenAmount <= 0) {
throw new Error(`tokenAmount must be positive, got ${tokenAmount}`);
}

if (!sellerAddress) {
throw new Error("sellerAddress is required");
}

const entry: QueueEntry = {
id: uuidv4(),
poolId,
sellerAddress,
tokenAmount,
remainingAmount: tokenAmount,
joinedAtBlock: currentBlock,
joinedAt: Date.now(),
};

getQueue(poolId).push(entry);
return entry;
}

/**
* Remove a seller from the queue. Applies slashing penalty.
*
* Penalty schedule (within 100-block window):
* - 1st cancel: 50% of queued tokens slashed
* - 2nd cancel: 70% slashed
* - 3rd+ cancel: 90% slashed
*
* Slashed tokens are returned to the pool's virtual reserves.
*
* @param poolId - Pool to leave
* @param sellerAddress - Seller's address
* @param currentBlock - Current block height
* @returns Object with tokens returned to seller and tokens slashed
*/
export function leaveQueue(
poolId: string,
sellerAddress: string,
currentBlock: number = 0
): { tokensReturned: number; tokensSlashed: number; slashPercent: number } {
const pool = getPool(poolId);
if (!pool) throw new Error(`Pool ${poolId} not found`);

const queue = getQueue(poolId);
const entryIndex = queue.findIndex(
(e) => e.sellerAddress === sellerAddress && e.remainingAmount > 0
);

if (entryIndex === -1) {
throw new Error(
`No active queue entry for ${sellerAddress} in pool ${poolId}`
);
}

const entry = queue[entryIndex]!;
const remaining = entry.remainingAmount;

// Update cancel history
const cancelKey = getCancelKey(poolId, sellerAddress);
let record = cancelHistory.get(cancelKey);

if (
!record ||
currentBlock - record.cancelledAtBlock > SLASH_WINDOW_BLOCKS
) {
// Outside window — reset counter
record = {
sellerAddress,
poolId,
cancelledAtBlock: currentBlock,
cancelCount: 1,
};
} else {
record.cancelCount++;
record.cancelledAtBlock = currentBlock;
}
cancelHistory.set(cancelKey, record);

const slashPercent = getSlashPercent(record.cancelCount);
const tokensSlashed = (remaining * slashPercent) / 100;
const tokensReturned = remaining - tokensSlashed;

// Remove from queue
queue.splice(entryIndex, 1);

// Slashed tokens improve pool liquidity
if (tokensSlashed > 0) {
returnTokensToReserve(poolId, tokensSlashed);
}

return { tokensReturned, tokensSlashed, slashPercent };
}

/**
* Process a buyer swap by matching against the seller queue (FIFO).
* VTC flows directly from buyer to matched sellers.
*
* Uses queue-adjusted pricing to account for selling pressure.
*
* @param poolId - Pool to swap in
* @param buyerAddress - Buyer's VTC address
* @param vtcAmount - Amount of VTC the buyer is spending
* @param currentBlock - Current block height (for rate limiting)
* @returns SwapResult with matched sellers and amounts
*/
export function processSwap(
poolId: string,
buyerAddress: string,
vtcAmount: number,
currentBlock: number = 0
): SwapResult {
const pool = getPool(poolId);
if (!pool) throw new Error(`Pool ${poolId} not found`);

if (vtcAmount <= 0) {
throw new Error(`vtcAmount must be positive, got ${vtcAmount}`);
}

// Rate limiting: max 24 swaps per block per pool
const rlKey = getRateLimitKey(poolId, currentBlock);
const currentSwaps = swapRateLimit.get(rlKey) ?? 0;
if (currentSwaps >= MAX_SWAPS_PER_BLOCK_PER_POOL) {
throw new Error(
`Rate limit exceeded: max ${MAX_SWAPS_PER_BLOCK_PER_POOL} swaps per block per pool`
);
}
swapRateLimit.set(rlKey, currentSwaps + 1);

// Get queue-adjusted quote
const queueDepth = getQueueDepth(poolId);
const quote = getQueueAdjustedQuote(poolId, vtcAmount, queueDepth);
const totalTokensOut = quote.amountOut;

// Match against queue sellers (FIFO)
const queue = getQueue(poolId);
const matches: SwapMatch[] = [];
let tokensNeeded = totalTokensOut;
let vtcRemaining = vtcAmount - quote.fee; // VTC after protocol fee

for (const entry of queue) {
if (tokensNeeded <= 0) break;
if (entry.remainingAmount <= 0) continue;

const tokensFromSeller = Math.min(entry.remainingAmount, tokensNeeded);
const vtcShare = (tokensFromSeller / totalTokensOut) * vtcRemaining;

matches.push({
sellerId: entry.id,
sellerAddress: entry.sellerAddress,
tokensFromSeller,
vtcToSeller: vtcShare,
});

entry.remainingAmount -= tokensFromSeller;
tokensNeeded -= tokensFromSeller;
}

// Remove fully filled entries
const activeQueue = queue.filter((e) => e.remainingAmount > 0);
queues.set(poolId, activeQueue);

// Calculate unmatched VTC if queue didn't have enough sellers
const totalMatched = matches.reduce(
(sum, m) => sum + m.tokensFromSeller,
0
);
const remainingVtcUnmatched =
tokensNeeded > 0
? (tokensNeeded / totalTokensOut) * vtcAmount
: 0;

// Apply the swap to pool virtual reserves
if (totalMatched > 0) {
const effectiveVtcIn = vtcAmount - remainingVtcUnmatched;
const effectiveFee =
(effectiveVtcIn * pool.feeBps) / 10_000;
applySwap(
poolId,
"vtc_to_token",
effectiveVtcIn,
totalMatched,
effectiveFee
);
}

return {
poolId,
buyerAddress,
vtcSpent: vtcAmount - remainingVtcUnmatched,
totalTokensReceived: totalMatched,
quote,
matches,
remainingVtcUnmatched,
};
}

/**
* Get the total token depth in the seller queue for a pool.
*/
export function getQueueDepth(poolId: string): number {
const queue = queues.get(poolId);
if (!queue) return 0;
return queue.reduce((sum, e) => sum + e.remainingAmount, 0);
}

/**
* Get the number of active sellers in the queue.
*/
export function getQueueSize(poolId: string): number {
const queue = queues.get(poolId);
if (!queue) return 0;
return queue.filter((e) => e.remainingAmount > 0).length;
}

/**
* Get all queue entries for a pool (read-only snapshot).
*/
export function getQueueEntries(poolId: string): QueueEntry[] {
const queue = queues.get(poolId);
if (!queue) return [];
return queue
.filter((e) => e.remainingAmount > 0)
.map((e) => ({ ...e }));
}

/**
* Get queue entries for a specific seller across all pools or a specific pool.
*/
export function getSellerEntries(
sellerAddress: string,
poolId?: string
): QueueEntry[] {
const results: QueueEntry[] = [];

if (poolId) {
const queue = queues.get(poolId);
if (queue) {
results.push(
...queue.filter(
(e) =>
e.sellerAddress === sellerAddress && e.remainingAmount > 0
)
);
}
} else {
for (const queue of queues.values()) {
results.push(
...queue.filter(
(e) =>
e.sellerAddress === sellerAddress && e.remainingAmount > 0
)
);
}
}

return results.map((e) => ({ ...e }));
}

/**
* Clean up stale rate-limit entries (call periodically).
*/
export function cleanupRateLimits(currentBlock: number): void {
for (const key of swapRateLimit.keys()) {
const block = parseInt(key.split(":")[1] ?? "0", 10);
if (currentBlock - block > 10) {
swapRateLimit.delete(key);
}
}
}
Loading