From 0d096760217bf25a98a6ebe255ec86367700b1bc Mon Sep 17 00:00:00 2001 From: nolarose Date: Thu, 6 Aug 2026 01:29:54 -0500 Subject: [PATCH 1/7] feat(partner): add authorization decision core --- src/partner/authorization/domain.ts | 238 +++++++++++++++++++ src/partner/authorization/gate.ts | 127 ++++++++++ src/partner/authorization/hash.ts | 85 +++++++ src/partner/authorization/index.ts | 5 + src/partner/authorization/sql.ts | 274 ++++++++++++++++++++++ src/partner/authorization/stake.ts | 60 +++++ tests/partner/authorization/gate.test.ts | 178 ++++++++++++++ tests/partner/authorization/hash.test.ts | 86 +++++++ tests/partner/authorization/sql.test.ts | 175 ++++++++++++++ tests/partner/authorization/stake.test.ts | 88 +++++++ 10 files changed, 1316 insertions(+) create mode 100644 src/partner/authorization/domain.ts create mode 100644 src/partner/authorization/gate.ts create mode 100644 src/partner/authorization/hash.ts create mode 100644 src/partner/authorization/index.ts create mode 100644 src/partner/authorization/sql.ts create mode 100644 src/partner/authorization/stake.ts create mode 100644 tests/partner/authorization/gate.test.ts create mode 100644 tests/partner/authorization/hash.test.ts create mode 100644 tests/partner/authorization/sql.test.ts create mode 100644 tests/partner/authorization/stake.test.ts diff --git a/src/partner/authorization/domain.ts b/src/partner/authorization/domain.ts new file mode 100644 index 0000000..1952dbe --- /dev/null +++ b/src/partner/authorization/domain.ts @@ -0,0 +1,238 @@ +/** Immutable authorization policy and execution-gate domain contracts. */ + +declare const partnerCodeBrand: unique symbol; +declare const outIdBrand: unique symbol; +declare const providerIdBrand: unique symbol; +declare const skinIdBrand: unique symbol; +declare const currencyCodeBrand: unique symbol; +declare const policyHashBrand: unique symbol; +declare const telegramChatIdBrand: unique symbol; +declare const telegramTopicIdBrand: unique symbol; +declare const telegramMessageIdBrand: unique symbol; +declare const telegramUserIdBrand: unique symbol; +declare const authorizationRequestIdBrand: unique symbol; +declare const authorizationIdBrand: unique symbol; + +export type PartnerCode = string & { readonly [partnerCodeBrand]: true }; +export type OutId = string & { readonly [outIdBrand]: true }; +export type ProviderId = string & { readonly [providerIdBrand]: true }; +export type SkinId = string & { readonly [skinIdBrand]: true }; +export type CurrencyCode = string & { readonly [currencyCodeBrand]: true }; +export type PolicyHash = string & { readonly [policyHashBrand]: true }; +export type TelegramChatId = string & { readonly [telegramChatIdBrand]: true }; +export type TelegramTopicId = string & { readonly [telegramTopicIdBrand]: true }; +export type TelegramMessageId = string & { readonly [telegramMessageIdBrand]: true }; +export type TelegramUserId = string & { readonly [telegramUserIdBrand]: true }; +export type AuthorizationRequestId = number & { readonly [authorizationRequestIdBrand]: true }; +export type AuthorizationId = number & { readonly [authorizationIdBrand]: true }; + +export const PERMISSION_SCOPES = ["observe_odds", "paper_trade", "live_trade"] as const; +export type PermissionScope = (typeof PERMISSION_SCOPES)[number]; + +export const MAX_WIN_BASES = ["profit", "total_return"] as const; +export type MaxWinBasis = (typeof MAX_WIN_BASES)[number]; + +export const AUTHORIZATION_REQUEST_STATUSES = [ + "pending", + "approved", + "rejected", + "cancelled", + "expired", +] as const; +export type AuthorizationRequestStatus = (typeof AUTHORIZATION_REQUEST_STATUSES)[number]; + +export const POLICY_HASH_DOMAIN = "partner-account-authorization-policy-v1"; + +function brandNonEmpty(value: string, label: string): T { + const normalized = value.trim(); + if (normalized.length === 0) throw new TypeError(`${label} must not be empty`); + if (normalized.length > 128) throw new TypeError(`${label} must be at most 128 characters`); + if (/\p{Cc}/u.test(normalized)) throw new TypeError(`${label} must not contain control characters`); + return normalized as T; +} + +function brandTelegramNumericId(value: string, label: string): T { + const normalized = brandNonEmpty(value, label); + if (!/^-?\d+$/.test(normalized)) throw new TypeError(`${label} must be a numeric Telegram ID`); + return normalized as T; +} + +export function asPartnerCode(value: string): PartnerCode { + return brandNonEmpty(value, "partner code"); +} + +export function asOutId(value: string): OutId { + return brandNonEmpty(value, "out ID"); +} + +export function asProviderId(value: string): ProviderId { + return brandNonEmpty(value, "provider ID"); +} + +export function asSkinId(value: string): SkinId { + return brandNonEmpty(value, "skin ID"); +} + +export function asCurrencyCode(value: string): CurrencyCode { + const normalized = value.trim().toUpperCase(); + if (!/^[A-Z]{3}$/.test(normalized)) { + throw new TypeError("currency code must contain exactly three ASCII letters"); + } + return normalized as CurrencyCode; +} + +export function asPolicyHash(value: string): PolicyHash { + const normalized = value.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) { + throw new TypeError("policy hash must be a 64-character SHA-256 hex digest"); + } + return normalized as PolicyHash; +} + +export function asTelegramChatId(value: string): TelegramChatId { + return brandTelegramNumericId(value, "Telegram chat ID"); +} + +export function asTelegramTopicId(value: string): TelegramTopicId { + return brandTelegramNumericId(value, "Telegram topic ID"); +} + +export function asTelegramMessageId(value: string): TelegramMessageId { + return brandTelegramNumericId(value, "Telegram message ID"); +} + +export function asTelegramUserId(value: string): TelegramUserId { + return brandTelegramNumericId(value, "Telegram user ID"); +} + +function brandPositiveInteger(value: number, label: string): T { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer`); + } + return value as T; +} + +export function asAuthorizationRequestId(value: number): AuthorizationRequestId { + return brandPositiveInteger(value, "authorization request ID"); +} + +export function asAuthorizationId(value: number): AuthorizationId { + return brandPositiveInteger(value, "authorization ID"); +} + +/** Every money value is an integer number of currency minor units. */ +export interface AuthorizationPolicy { + partnerCode: PartnerCode; + outId: OutId; + provider: ProviderId; + skin: SkinId; + scope: PermissionScope; + maxStake: number; + maxWin: number; + maxWinBasis: MaxWinBasis; + dailyLimit: number | null; + exposureLimit: number | null; + currency: CurrencyCode; + validFromMs: number; + expiresAtMs: number | null; +} + +export interface AuthorizationRequest extends AuthorizationPolicy { + id: AuthorizationRequestId; + status: AuthorizationRequestStatus; + requestHash: PolicyHash; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + telegramMessageId: TelegramMessageId; + createdAtMs: number; + updatedAtMs: number; +} + +export interface ApprovedAuthorization extends AuthorizationPolicy { + id: AuthorizationId; + requestId: AuthorizationRequestId; + approvalHash: PolicyHash; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + telegramMessageId: TelegramMessageId; + approvingUserId: TelegramUserId; + revokedAtMs: number | null; + createdAtMs: number; + updatedAtMs: number; +} + +export interface StakeComputationInput { + requestedStake: number; + sitePerBetMax: number; + partnerApprovedMaxStake: number; + maxWin: number; + maxWinBasis: MaxWinBasis; + decimalOdds: number; + availableBalance: number; + dailyUsed: number; + dailyLimit: number | null; + outstandingExposure: number; + exposureLimit: number | null; + /** Confirmed executable amount at the quoted odds; pass zero when unknown. */ + marketLiquidity: number; +} + +/** Runtime values only. Authorization-controlled caps are supplied by the verified policy. */ +export type GateStakeInput = Omit< + StakeComputationInput, + "partnerApprovedMaxStake" | "maxWin" | "maxWinBasis" | "dailyLimit" | "exposureLimit" +>; + +export interface GateChecks { + hasActiveAuthorization: boolean; + isScopeLiveTrade: boolean; + hashMatch: boolean; + oddsFresh: boolean; + effectiveStakePositive: boolean; + providerSessionValid: boolean; + riskHealthy: boolean; +} + +export type GateDenialCode = + | "NO_AUTHORIZATION" + | "INVALID_EVALUATION_TIME" + | "AUTHORIZATION_REVOKED" + | "AUTHORIZATION_NOT_YET_VALID" + | "AUTHORIZATION_EXPIRED" + | "SCOPE_NOT_LIVE_TRADE" + | "POLICY_HASH_MISMATCH" + | "STALE_ODDS" + | "EFFECTIVE_STAKE_NOT_POSITIVE" + | "PROVIDER_SESSION_INVALID" + | "RISK_UNHEALTHY"; + +export type GateDecision = + | { + allowed: true; + effectiveStake: number; + checks: GateChecks; + } + | { + allowed: false; + code: GateDenialCode; + reason: string; + checks: GateChecks; + }; + +export function policyFromAuthorization(auth: ApprovedAuthorization): AuthorizationPolicy { + return { + partnerCode: auth.partnerCode, + outId: auth.outId, + provider: auth.provider, + skin: auth.skin, + scope: auth.scope, + maxStake: auth.maxStake, + maxWin: auth.maxWin, + maxWinBasis: auth.maxWinBasis, + dailyLimit: auth.dailyLimit, + exposureLimit: auth.exposureLimit, + currency: auth.currency, + validFromMs: auth.validFromMs, + expiresAtMs: auth.expiresAtMs, + }; +} diff --git a/src/partner/authorization/gate.ts b/src/partner/authorization/gate.ts new file mode 100644 index 0000000..5530fc6 --- /dev/null +++ b/src/partner/authorization/gate.ts @@ -0,0 +1,127 @@ +import type { + ApprovedAuthorization, + AuthorizationPolicy, + GateChecks, + GateDecision, + GateDenialCode, + GateStakeInput, +} from "./domain.ts"; +import { policyFromAuthorization } from "./domain.ts"; +import { verifyPolicyMatch } from "./hash.ts"; +import { computeEffectiveStake } from "./stake.ts"; + +export interface GateContext { + authorization: ApprovedAuthorization | null; + currentPolicy: AuthorizationPolicy; + nowMs: number; + oddsFresh: boolean; + providerSessionValid: boolean; + riskHealthy: boolean; + stakeInput: GateStakeInput; +} + +function initialChecks(): GateChecks { + return { + hasActiveAuthorization: false, + isScopeLiveTrade: false, + hashMatch: false, + oddsFresh: false, + effectiveStakePositive: false, + providerSessionValid: false, + riskHealthy: false, + }; +} + +function denied( + code: GateDenialCode, + reason: string, + checks: GateChecks, +): GateDecision { + return { allowed: false, code, reason, checks }; +} + +/** Pure, deterministic, fail-closed authorization decision. No reservation or placement occurs. */ +export function evaluateExecutionGate(context: GateContext): GateDecision { + const checks = initialChecks(); + const auth = context.authorization; + + if (auth === null) { + return denied("NO_AUTHORIZATION", "No authorization was supplied", checks); + } + if (!Number.isSafeInteger(context.nowMs) || context.nowMs < 0) { + return denied( + "INVALID_EVALUATION_TIME", + "Authorization evaluation time is invalid", + checks, + ); + } + if (auth.revokedAtMs !== null) { + return denied("AUTHORIZATION_REVOKED", "Authorization has been revoked", checks); + } + if (context.nowMs < auth.validFromMs) { + return denied( + "AUTHORIZATION_NOT_YET_VALID", + "Authorization validity period has not started", + checks, + ); + } + if (auth.expiresAtMs !== null && context.nowMs >= auth.expiresAtMs) { + return denied("AUTHORIZATION_EXPIRED", "Authorization has expired", checks); + } + checks.hasActiveAuthorization = true; + + if (auth.scope !== "live_trade") { + return denied("SCOPE_NOT_LIVE_TRADE", `Scope is ${auth.scope}, not live_trade`, checks); + } + checks.isScopeLiveTrade = true; + + if ( + !verifyPolicyMatch(policyFromAuthorization(auth), auth.approvalHash) || + !verifyPolicyMatch(context.currentPolicy, auth.approvalHash) + ) { + return denied( + "POLICY_HASH_MISMATCH", + "Policy hash mismatch: authorization terms changed", + checks, + ); + } + checks.hashMatch = true; + + if (!context.oddsFresh) { + return denied("STALE_ODDS", "Odds are stale", checks); + } + checks.oddsFresh = true; + + const effectiveStake = computeEffectiveStake({ + ...context.stakeInput, + partnerApprovedMaxStake: context.currentPolicy.maxStake, + maxWin: context.currentPolicy.maxWin, + maxWinBasis: context.currentPolicy.maxWinBasis, + dailyLimit: context.currentPolicy.dailyLimit, + exposureLimit: context.currentPolicy.exposureLimit, + }); + if (effectiveStake <= 0) { + return denied( + "EFFECTIVE_STAKE_NOT_POSITIVE", + "Effective stake is not positive", + checks, + ); + } + checks.effectiveStakePositive = true; + + if (!context.providerSessionValid) { + return denied( + "PROVIDER_SESSION_INVALID", + "Provider session is expired or invalid", + checks, + ); + } + checks.providerSessionValid = true; + + if (!context.riskHealthy) { + return denied("RISK_UNHEALTHY", "Global risk health check failed", checks); + } + checks.riskHealthy = true; + + return { allowed: true, effectiveStake, checks }; +} diff --git a/src/partner/authorization/hash.ts b/src/partner/authorization/hash.ts new file mode 100644 index 0000000..b2efd11 --- /dev/null +++ b/src/partner/authorization/hash.ts @@ -0,0 +1,85 @@ +import type { AuthorizationPolicy, PolicyHash } from "./domain.ts"; +import { asPolicyHash, POLICY_HASH_DOMAIN } from "./domain.ts"; + +export type CanonicalPolicySnapshot = Readonly<{ + schema: typeof POLICY_HASH_DOMAIN; + partnerCode: AuthorizationPolicy["partnerCode"]; + outId: AuthorizationPolicy["outId"]; + provider: AuthorizationPolicy["provider"]; + skin: AuthorizationPolicy["skin"]; + scope: AuthorizationPolicy["scope"]; + maxStake: number; + maxWin: number; + maxWinBasis: AuthorizationPolicy["maxWinBasis"]; + dailyLimit: number | null; + exposureLimit: number | null; + currency: AuthorizationPolicy["currency"]; + validFromMs: number; + expiresAtMs: number | null; +}>; + +function assertMinorUnits(value: number | null, field: string): void { + if (value !== null && (!Number.isSafeInteger(value) || value < 0)) { + throw new TypeError(`${field} must be a non-negative safe integer in minor units`); + } +} + +function assertTimestamp(value: number | null, field: string): void { + if (value !== null && (!Number.isSafeInteger(value) || value < 0)) { + throw new TypeError(`${field} must be a non-negative epoch-millisecond integer`); + } +} + +export function canonicalPolicySnapshot(policy: AuthorizationPolicy): CanonicalPolicySnapshot { + assertMinorUnits(policy.maxStake, "maxStake"); + assertMinorUnits(policy.maxWin, "maxWin"); + assertMinorUnits(policy.dailyLimit, "dailyLimit"); + assertMinorUnits(policy.exposureLimit, "exposureLimit"); + assertTimestamp(policy.validFromMs, "validFromMs"); + assertTimestamp(policy.expiresAtMs, "expiresAtMs"); + if (policy.expiresAtMs !== null && policy.expiresAtMs <= policy.validFromMs) { + throw new TypeError("expiresAtMs must be later than validFromMs"); + } + + // Explicit field order is the serialized contract. Never hash the source object directly. + return Object.freeze({ + schema: POLICY_HASH_DOMAIN, + partnerCode: policy.partnerCode, + outId: policy.outId, + provider: policy.provider, + skin: policy.skin, + scope: policy.scope, + maxStake: policy.maxStake, + maxWin: policy.maxWin, + maxWinBasis: policy.maxWinBasis, + dailyLimit: policy.dailyLimit, + exposureLimit: policy.exposureLimit, + currency: policy.currency, + validFromMs: policy.validFromMs, + expiresAtMs: policy.expiresAtMs, + }); +} + +export function computePolicyHash(policy: AuthorizationPolicy): PolicyHash { + const serialized = JSON.stringify(canonicalPolicySnapshot(policy)); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(serialized); + return asPolicyHash(hasher.digest("hex")); +} + +export function verifyPolicyMatch(policy: AuthorizationPolicy, storedHash: string): boolean { + let expected: PolicyHash; + let actual: PolicyHash; + try { + expected = computePolicyHash(policy); + actual = asPolicyHash(storedHash); + } catch { + return false; + } + + let mismatch = 0; + for (let index = 0; index < expected.length; index++) { + mismatch |= expected.charCodeAt(index) ^ actual.charCodeAt(index); + } + return mismatch === 0; +} diff --git a/src/partner/authorization/index.ts b/src/partner/authorization/index.ts new file mode 100644 index 0000000..964705a --- /dev/null +++ b/src/partner/authorization/index.ts @@ -0,0 +1,5 @@ +export * from "./domain.ts"; +export * from "./gate.ts"; +export * from "./hash.ts"; +export * from "./sql.ts"; +export * from "./stake.ts"; diff --git a/src/partner/authorization/sql.ts b/src/partner/authorization/sql.ts new file mode 100644 index 0000000..d3f3604 --- /dev/null +++ b/src/partner/authorization/sql.ts @@ -0,0 +1,274 @@ +import type { Database } from "bun:sqlite"; +import type { ApprovedAuthorization, OutId, PartnerCode, SkinId } from "./domain.ts"; +import { + asAuthorizationId, + asAuthorizationRequestId, + asCurrencyCode, + asOutId, + asPartnerCode, + asPolicyHash, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + asTelegramUserId, +} from "./domain.ts"; + +export const AUTHORIZATION_MIGRATIONS = [ + { + id: "001_account_authorization_core", + sql: ` + CREATE TABLE IF NOT EXISTS account_authorization_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + partner_code TEXT NOT NULL, + out_id TEXT NOT NULL, + provider TEXT NOT NULL, + skin TEXT NOT NULL, + permission_scope TEXT NOT NULL CHECK ( + permission_scope IN ('observe_odds', 'paper_trade', 'live_trade') + ), + requested_max_stake INTEGER NOT NULL CHECK ( + typeof(requested_max_stake) = 'integer' AND requested_max_stake >= 0 + ), + requested_max_win INTEGER NOT NULL CHECK ( + typeof(requested_max_win) = 'integer' AND requested_max_win >= 0 + ), + max_win_basis TEXT NOT NULL CHECK (max_win_basis IN ('profit', 'total_return')), + daily_limit INTEGER CHECK ( + daily_limit IS NULL OR (typeof(daily_limit) = 'integer' AND daily_limit >= 0) + ), + exposure_limit INTEGER CHECK ( + exposure_limit IS NULL OR (typeof(exposure_limit) = 'integer' AND exposure_limit >= 0) + ), + currency TEXT NOT NULL DEFAULT 'USD' CHECK ( + length(currency) = 3 AND currency = upper(currency) + AND currency GLOB '[A-Z][A-Z][A-Z]' + ), + valid_from_ms INTEGER NOT NULL CHECK (typeof(valid_from_ms) = 'integer' AND valid_from_ms >= 0), + expires_at_ms INTEGER CHECK ( + expires_at_ms IS NULL OR ( + typeof(expires_at_ms) = 'integer' AND expires_at_ms > valid_from_ms + ) + ), + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + telegram_chat_id TEXT NOT NULL, + telegram_topic_id TEXT, + telegram_message_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK ( + status IN ('pending', 'approved', 'rejected', 'cancelled', 'expired') + ), + created_at_ms INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)), + updated_at_ms INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)) + ); + + CREATE TABLE IF NOT EXISTS account_authorizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL UNIQUE REFERENCES account_authorization_requests(id), + partner_code TEXT NOT NULL, + out_id TEXT NOT NULL, + provider TEXT NOT NULL, + skin TEXT NOT NULL, + permission_scope TEXT NOT NULL CHECK ( + permission_scope IN ('observe_odds', 'paper_trade', 'live_trade') + ), + approved_max_stake INTEGER NOT NULL CHECK ( + typeof(approved_max_stake) = 'integer' AND approved_max_stake >= 0 + ), + approved_max_win INTEGER NOT NULL CHECK ( + typeof(approved_max_win) = 'integer' AND approved_max_win >= 0 + ), + max_win_basis TEXT NOT NULL CHECK (max_win_basis IN ('profit', 'total_return')), + daily_limit INTEGER CHECK ( + daily_limit IS NULL OR (typeof(daily_limit) = 'integer' AND daily_limit >= 0) + ), + exposure_limit INTEGER CHECK ( + exposure_limit IS NULL OR (typeof(exposure_limit) = 'integer' AND exposure_limit >= 0) + ), + currency TEXT NOT NULL DEFAULT 'USD' CHECK ( + length(currency) = 3 AND currency = upper(currency) + AND currency GLOB '[A-Z][A-Z][A-Z]' + ), + valid_from_ms INTEGER NOT NULL CHECK (typeof(valid_from_ms) = 'integer' AND valid_from_ms >= 0), + expires_at_ms INTEGER CHECK ( + expires_at_ms IS NULL OR ( + typeof(expires_at_ms) = 'integer' AND expires_at_ms > valid_from_ms + ) + ), + approval_hash TEXT NOT NULL CHECK (length(approval_hash) = 64), + telegram_chat_id TEXT NOT NULL, + telegram_topic_id TEXT, + telegram_message_id TEXT NOT NULL, + telegram_approving_user_id TEXT NOT NULL, + revoked_at_ms INTEGER CHECK ( + revoked_at_ms IS NULL OR (typeof(revoked_at_ms) = 'integer' AND revoked_at_ms >= 0) + ), + created_at_ms INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)), + updated_at_ms INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)) + ); + + CREATE TABLE IF NOT EXISTS account_authorization_approvers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + partner_code TEXT NOT NULL, + out_id TEXT, + telegram_user_id TEXT NOT NULL, + created_at_ms INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)) + ); + + CREATE INDEX IF NOT EXISTS idx_auth_requests_pending + ON account_authorization_requests (partner_code, out_id, status) + WHERE status = 'pending'; + CREATE INDEX IF NOT EXISTS idx_auth_grants_lookup + ON account_authorizations ( + partner_code, out_id, skin, permission_scope, revoked_at_ms, valid_from_ms, expires_at_ms + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_approvers_partner_wide + ON account_authorization_approvers (partner_code, telegram_user_id) + WHERE out_id IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_approvers_out + ON account_authorization_approvers (partner_code, out_id, telegram_user_id) + WHERE out_id IS NOT NULL; + `, + }, +] as const; + +type MigrationRow = { + migrationId: string; // brand-ok — internal migration wire value, not a business identifier +}; + +/** Apply all authorization migrations to an existing Bun SQLite connection. */ +export function migrateAuthorizationSchema(db: Database, nowMs = Date.now()): string[] { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError("migration time must be a non-negative epoch-millisecond integer"); + } + + db.run("PRAGMA foreign_keys = ON"); + db.run(`CREATE TABLE IF NOT EXISTS _partner_authorization_migrations ( + id TEXT PRIMARY KEY, + applied_at_ms INTEGER NOT NULL + )`); + + const applied = new Set( + ( + db + .query("SELECT id AS migrationId FROM _partner_authorization_migrations") + .all() as MigrationRow[] + ).map((row) => row.migrationId), + ); + const newlyApplied: string[] = []; + + for (const migration of AUTHORIZATION_MIGRATIONS) { + if (applied.has(migration.id)) continue; + db.run("BEGIN IMMEDIATE"); + try { + db.exec(migration.sql); + db.query( + `INSERT INTO _partner_authorization_migrations (id, applied_at_ms) + VALUES ($id, $appliedAtMs)`, + ).run({ $id: migration.id, $appliedAtMs: nowMs }); + db.run("COMMIT"); + newlyApplied.push(migration.id); + } catch (error) { + db.run("ROLLBACK"); + throw error; + } + } + return newlyApplied; +} + +export const ensureAuthorizationSchema = migrateAuthorizationSchema; + +type AuthorizationRow = { + id: number; + request_id: number; + partner_code: string; + out_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorizationRow + provider: string; + skin: string; + permission_scope: ApprovedAuthorization["scope"]; + approved_max_stake: number; + approved_max_win: number; + max_win_basis: ApprovedAuthorization["maxWinBasis"]; + daily_limit: number | null; + exposure_limit: number | null; + currency: string; + valid_from_ms: number; + expires_at_ms: number | null; + approval_hash: string; + telegram_chat_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorizationRow + telegram_topic_id: string | null; // brand-ok — SQLite wire value; parsed by mapAuthorizationRow + telegram_message_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorizationRow + telegram_approving_user_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorizationRow + revoked_at_ms: number | null; + created_at_ms: number; + updated_at_ms: number; +}; + +export interface ActiveAuthorizationLookup { + partnerCode: PartnerCode; + outId: OutId; + skin: SkinId; + nowMs: number; +} + +/** Return the newest currently valid live-trade grant for one exact out and skin. */ +export function getActiveLiveTradeAuthorization( + db: Database, + lookup: ActiveAuthorizationLookup, +): ApprovedAuthorization | null { + if (!Number.isSafeInteger(lookup.nowMs) || lookup.nowMs < 0) { + throw new TypeError("authorization lookup time must be a non-negative epoch-millisecond integer"); + } + + const row = db + .query( + `SELECT * + FROM account_authorizations + WHERE partner_code = $partnerCode + AND out_id = $outId + AND skin = $skin + AND permission_scope = 'live_trade' + AND revoked_at_ms IS NULL + AND valid_from_ms <= $nowMs + AND (expires_at_ms IS NULL OR expires_at_ms > $nowMs) + ORDER BY created_at_ms DESC, id DESC + LIMIT 1`, + ) + .get({ + $partnerCode: lookup.partnerCode, + $outId: lookup.outId, + $skin: lookup.skin, + $nowMs: lookup.nowMs, + }) as AuthorizationRow | null; + + return row === null ? null : mapAuthorizationRow(row); +} + +function mapAuthorizationRow(row: AuthorizationRow): ApprovedAuthorization { + return { + id: asAuthorizationId(row.id), + requestId: asAuthorizationRequestId(row.request_id), + partnerCode: asPartnerCode(row.partner_code), + outId: asOutId(row.out_id), + provider: asProviderId(row.provider), + skin: asSkinId(row.skin), + scope: row.permission_scope, + maxStake: row.approved_max_stake, + maxWin: row.approved_max_win, + maxWinBasis: row.max_win_basis, + dailyLimit: row.daily_limit, + exposureLimit: row.exposure_limit, + currency: asCurrencyCode(row.currency), + validFromMs: row.valid_from_ms, + expiresAtMs: row.expires_at_ms, + approvalHash: asPolicyHash(row.approval_hash), + telegramChatId: asTelegramChatId(row.telegram_chat_id), + telegramTopicId: + row.telegram_topic_id === null ? null : asTelegramTopicId(row.telegram_topic_id), + telegramMessageId: asTelegramMessageId(row.telegram_message_id), + approvingUserId: asTelegramUserId(row.telegram_approving_user_id), + revokedAtMs: row.revoked_at_ms, + createdAtMs: row.created_at_ms, + updatedAtMs: row.updated_at_ms, + }; +} diff --git a/src/partner/authorization/stake.ts b/src/partner/authorization/stake.ts new file mode 100644 index 0000000..a552791 --- /dev/null +++ b/src/partner/authorization/stake.ts @@ -0,0 +1,60 @@ +import type { StakeComputationInput } from "./domain.ts"; + +function isMinorUnits(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +function validMoneyInputs(input: StakeComputationInput): boolean { + const required = [ + input.requestedStake, + input.sitePerBetMax, + input.partnerApprovedMaxStake, + input.maxWin, + input.availableBalance, + input.dailyUsed, + input.outstandingExposure, + input.marketLiquidity, + ]; + return ( + required.every(isMinorUnits) && + (input.dailyLimit === null || isMinorUnits(input.dailyLimit)) && + (input.exposureLimit === null || isMinorUnits(input.exposureLimit)) + ); +} + +/** + * Compute the largest permitted integer stake in minor units. + * Invalid, unknown, exhausted, or non-executable inputs fail closed to zero. + */ +export function computeEffectiveStake(input: StakeComputationInput): number { + if (!validMoneyInputs(input) || !Number.isFinite(input.decimalOdds)) return 0; + if (input.decimalOdds <= 1) return 0; + if (input.maxWinBasis !== "profit" && input.maxWinBasis !== "total_return") return 0; + + const denominator = input.maxWinBasis === "profit" ? input.decimalOdds - 1 : input.decimalOdds; + let maxWinStake = Math.floor(input.maxWin / denominator); + if (!Number.isSafeInteger(maxWinStake) || maxWinStake < 0) return 0; + // IEEE-754 division can land exactly on an integer while multiplication lands just above it. + // Step down once when necessary so the computed win never exceeds the approved cap. + if (maxWinStake > 0 && maxWinStake * denominator > input.maxWin) { + maxWinStake -= 1; + } + + let effective = Math.min( + input.requestedStake, + input.sitePerBetMax, + input.partnerApprovedMaxStake, + maxWinStake, + input.availableBalance, + input.marketLiquidity, + ); + + if (input.dailyLimit !== null) { + effective = Math.min(effective, input.dailyLimit - input.dailyUsed); + } + if (input.exposureLimit !== null) { + effective = Math.min(effective, input.exposureLimit - input.outstandingExposure); + } + + return Math.max(Math.floor(effective), 0); +} diff --git a/tests/partner/authorization/gate.test.ts b/tests/partner/authorization/gate.test.ts new file mode 100644 index 0000000..72c349b --- /dev/null +++ b/tests/partner/authorization/gate.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { + asCurrencyCode, + asAuthorizationId, + asAuthorizationRequestId, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramUserId, + computePolicyHash, + evaluateExecutionGate, + type ApprovedAuthorization, + type AuthorizationPolicy, + type GateContext, + type GateDenialCode, +} from "../../../src/partner/authorization/index.ts"; + +const NOW_MS = 1_700_000_000_000; + +function policy(overrides: Partial = {}): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("TEST"), + outId: asOutId("out-TEST-1"), + provider: asProviderId("test-provider"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + dailyLimit: 1_000_000, + exposureLimit: 500_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 60_000, + expiresAtMs: NOW_MS + 60_000, + ...overrides, + }; +} + +function authorization( + approvedPolicy = policy(), + overrides: Partial = {}, +): ApprovedAuthorization { + return { + id: asAuthorizationId(1), + requestId: asAuthorizationRequestId(1), + ...approvedPolicy, + approvalHash: computePolicyHash(approvedPolicy), + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: null, + telegramMessageId: asTelegramMessageId("456"), + approvingUserId: asTelegramUserId("789"), + revokedAtMs: null, + createdAtMs: NOW_MS - 60_000, + updatedAtMs: NOW_MS - 60_000, + ...overrides, + }; +} + +function context(overrides: Partial = {}): GateContext { + const currentPolicy = policy(); + return { + authorization: authorization(currentPolicy), + currentPolicy, + nowMs: NOW_MS, + oddsFresh: true, + providerSessionValid: true, + riskHealthy: true, + stakeInput: { + requestedStake: 1_000, + sitePerBetMax: 10_000, + decimalOdds: 2, + availableBalance: 50_000, + dailyUsed: 0, + outstandingExposure: 0, + marketLiquidity: 100_000, + }, + ...overrides, + }; +} + +function expectDenied( + result: ReturnType, + code: GateDenialCode, +): void { + expect(result.allowed).toBeFalse(); + if (!result.allowed) expect(result.code).toBe(code); +} + +describe("execution authorization gate", () => { + test("allows a verified request and returns the effective stake", () => { + const result = evaluateExecutionGate(context()); + expect(result.allowed).toBeTrue(); + if (result.allowed) expect(result.effectiveStake).toBe(1_000); + expect(Object.values(result.checks).every(Boolean)).toBeTrue(); + }); + + test("rejects missing, revoked, future, and expired authorizations", () => { + expectDenied(evaluateExecutionGate(context({ authorization: null })), "NO_AUTHORIZATION"); + expectDenied( + evaluateExecutionGate( + context({ authorization: authorization(policy(), { revokedAtMs: NOW_MS - 1 }) }), + ), + "AUTHORIZATION_REVOKED", + ); + + const future = policy({ validFromMs: NOW_MS + 1, expiresAtMs: NOW_MS + 60_000 }); + expectDenied( + evaluateExecutionGate(context({ authorization: authorization(future), currentPolicy: future })), + "AUTHORIZATION_NOT_YET_VALID", + ); + + const expired = policy({ validFromMs: NOW_MS - 60_000, expiresAtMs: NOW_MS }); + expectDenied( + evaluateExecutionGate( + context({ authorization: authorization(expired), currentPolicy: expired }), + ), + "AUTHORIZATION_EXPIRED", + ); + }); + + test("requires live-trade scope and an unchanged policy hash", () => { + const paperPolicy = policy({ scope: "paper_trade" }); + expectDenied( + evaluateExecutionGate( + context({ authorization: authorization(paperPolicy), currentPolicy: paperPolicy }), + ), + "SCOPE_NOT_LIVE_TRADE", + ); + + expectDenied( + evaluateExecutionGate(context({ currentPolicy: policy({ maxStake: 60_000 }) })), + "POLICY_HASH_MISMATCH", + ); + + expectDenied( + evaluateExecutionGate( + context({ authorization: authorization(policy(), { maxStake: 60_000 }) }), + ), + "POLICY_HASH_MISMATCH", + ); + }); + + test("rejects each runtime health failure in fixed order", () => { + expectDenied(evaluateExecutionGate(context({ oddsFresh: false })), "STALE_ODDS"); + expectDenied( + evaluateExecutionGate( + context({ stakeInput: { ...context().stakeInput, marketLiquidity: 0 } }), + ), + "EFFECTIVE_STAKE_NOT_POSITIVE", + ); + expectDenied( + evaluateExecutionGate(context({ providerSessionValid: false })), + "PROVIDER_SESSION_INVALID", + ); + expectDenied(evaluateExecutionGate(context({ riskHealthy: false })), "RISK_UNHEALTHY"); + }); + + test("derives authorization limits from the hash-verified policy", () => { + const limitedPolicy = policy({ maxStake: 250, dailyLimit: 300, exposureLimit: 400 }); + const result = evaluateExecutionGate( + context({ + authorization: authorization(limitedPolicy), + currentPolicy: limitedPolicy, + stakeInput: { + ...context().stakeInput, + requestedStake: 10_000, + dailyUsed: 25, + outstandingExposure: 50, + }, + }), + ); + expect(result.allowed).toBeTrue(); + if (result.allowed) expect(result.effectiveStake).toBe(250); + }); +}); diff --git a/tests/partner/authorization/hash.test.ts b/tests/partner/authorization/hash.test.ts new file mode 100644 index 0000000..afab92e --- /dev/null +++ b/tests/partner/authorization/hash.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + canonicalPolicySnapshot, + computePolicyHash, + type AuthorizationPolicy, + verifyPolicyMatch, +} from "../../../src/partner/authorization/index.ts"; + +function policy(overrides: Partial = {}): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("TEST"), + outId: asOutId("out-TEST-1"), + provider: asProviderId("test-provider"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + dailyLimit: 1_000_000, + exposureLimit: 500_000, + currency: asCurrencyCode("USD"), + validFromMs: 1_700_000_000_000, + expiresAtMs: 1_700_086_400_000, + ...overrides, + }; +} + +describe("authorization policy hash", () => { + test("is deterministic and domain-separated", () => { + const first = policy(); + const reordered = { + expiresAtMs: first.expiresAtMs, + maxWin: first.maxWin, + partnerCode: first.partnerCode, + currency: first.currency, + outId: first.outId, + provider: first.provider, + skin: first.skin, + scope: first.scope, + maxStake: first.maxStake, + maxWinBasis: first.maxWinBasis, + dailyLimit: first.dailyLimit, + exposureLimit: first.exposureLimit, + validFromMs: first.validFromMs, + } satisfies AuthorizationPolicy; + + const hash = computePolicyHash(first); + expect(hash).toBe(computePolicyHash(reordered)); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + expect(canonicalPolicySnapshot(first).schema).toBe( + "partner-account-authorization-policy-v1", + ); + }); + + test("binds limits, identity, scope, currency, and validity", () => { + const original = policy(); + const hash = computePolicyHash(original); + const mutations: AuthorizationPolicy[] = [ + policy({ maxStake: original.maxStake + 1 }), + policy({ maxWin: original.maxWin + 1 }), + policy({ outId: asOutId("out-TEST-2") }), + policy({ scope: "paper_trade" }), + policy({ currency: asCurrencyCode("EUR") }), + policy({ expiresAtMs: original.expiresAtMs! + 1 }), + ]; + + for (const mutation of mutations) { + expect(computePolicyHash(mutation)).not.toBe(hash); + expect(verifyPolicyMatch(mutation, hash)).toBeFalse(); + } + expect(verifyPolicyMatch(original, hash)).toBeTrue(); + }); + + test("fails closed for malformed hashes and invalid policy values", () => { + expect(verifyPolicyMatch(policy(), "not-a-hash")).toBeFalse(); + expect(() => computePolicyHash(policy({ maxStake: 1.5 }))).toThrow("minor units"); + expect(() => + computePolicyHash(policy({ expiresAtMs: 1_700_000_000_000 })), + ).toThrow("later than validFromMs"); + }); +}); diff --git a/tests/partner/authorization/sql.test.ts b/tests/partner/authorization/sql.test.ts new file mode 100644 index 0000000..55392c2 --- /dev/null +++ b/tests/partner/authorization/sql.test.ts @@ -0,0 +1,175 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { + asCurrencyCode, + asAuthorizationId, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + computePolicyHash, + getActiveLiveTradeAuthorization, + migrateAuthorizationSchema, + type AuthorizationPolicy, + type AuthorizationId, +} from "../../../src/partner/authorization/index.ts"; + +const NOW_MS = 1_700_000_000_000; + +function policy(): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("TEST"), + outId: asOutId("out-TEST-1"), + provider: asProviderId("test-provider"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + dailyLimit: 1_000_000, + exposureLimit: 500_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 1_000, + expiresAtMs: NOW_MS + 1_000, + }; +} + +function insertGrant(db: Database, approvedPolicy = policy()): AuthorizationId { + const hash = computePolicyHash(approvedPolicy); + const request = db + .query( + `INSERT INTO account_authorization_requests ( + partner_code, out_id, provider, skin, permission_scope, + requested_max_stake, requested_max_win, max_win_basis, + daily_limit, exposure_limit, currency, valid_from_ms, expires_at_ms, + request_hash, telegram_chat_id, telegram_message_id + ) VALUES ( + $partner, $out, $provider, $skin, $scope, + $maxStake, $maxWin, $basis, + $daily, $exposure, $currency, $validFrom, $expiresAt, + $hash, '-123', '456' + ) RETURNING id`, + ) + .get({ + $partner: approvedPolicy.partnerCode, + $out: approvedPolicy.outId, + $provider: approvedPolicy.provider, + $skin: approvedPolicy.skin, + $scope: approvedPolicy.scope, + $maxStake: approvedPolicy.maxStake, + $maxWin: approvedPolicy.maxWin, + $basis: approvedPolicy.maxWinBasis, + $daily: approvedPolicy.dailyLimit, + $exposure: approvedPolicy.exposureLimit, + $currency: approvedPolicy.currency, + $validFrom: approvedPolicy.validFromMs, + $expiresAt: approvedPolicy.expiresAtMs, + $hash: hash, + }) as { id: number }; + + const grant = db + .query( + `INSERT INTO account_authorizations ( + request_id, partner_code, out_id, provider, skin, permission_scope, + approved_max_stake, approved_max_win, max_win_basis, + daily_limit, exposure_limit, currency, valid_from_ms, expires_at_ms, + approval_hash, telegram_chat_id, telegram_message_id, telegram_approving_user_id + ) VALUES ( + $requestId, $partner, $out, $provider, $skin, $scope, + $maxStake, $maxWin, $basis, + $daily, $exposure, $currency, $validFrom, $expiresAt, + $hash, '-123', '457', '789' + ) RETURNING id`, + ) + .get({ + $requestId: request.id, + $partner: approvedPolicy.partnerCode, + $out: approvedPolicy.outId, + $provider: approvedPolicy.provider, + $skin: approvedPolicy.skin, + $scope: approvedPolicy.scope, + $maxStake: approvedPolicy.maxStake, + $maxWin: approvedPolicy.maxWin, + $basis: approvedPolicy.maxWinBasis, + $daily: approvedPolicy.dailyLimit, + $exposure: approvedPolicy.exposureLimit, + $currency: approvedPolicy.currency, + $validFrom: approvedPolicy.validFromMs, + $expiresAt: approvedPolicy.expiresAtMs, + $hash: hash, + }) as { id: number }; + return asAuthorizationId(grant.id); +} + +describe("authorization SQL boundary", () => { + test("migrates idempotently and records epoch milliseconds", () => { + const db = new Database(":memory:"); + expect(migrateAuthorizationSchema(db, NOW_MS)).toEqual(["001_account_authorization_core"]); + expect(migrateAuthorizationSchema(db, NOW_MS + 1)).toEqual([]); + + const migration = db + .query("SELECT applied_at_ms FROM _partner_authorization_migrations") + .get() as { applied_at_ms: number }; + expect(migration.applied_at_ms).toBe(NOW_MS); + + const indexSql = db + .query("SELECT sql FROM sqlite_master WHERE name = 'idx_auth_grants_lookup'") + .get() as { sql: string }; + expect(indexSql.sql).not.toContain("unixepoch"); + db.close(); + }); + + test("loads only grants active at the supplied query time", () => { + const db = new Database(":memory:"); + migrateAuthorizationSchema(db, NOW_MS); + const grantId = insertGrant(db); + + const lookup = { + partnerCode: asPartnerCode("TEST"), + outId: asOutId("out-TEST-1"), + skin: asSkinId("main"), + nowMs: NOW_MS, + }; + const active = getActiveLiveTradeAuthorization(db, lookup); + expect(active?.id).toBe(grantId); + expect(active?.maxStake).toBe(50_000); + expect(getActiveLiveTradeAuthorization(db, { ...lookup, nowMs: NOW_MS + 1_000 })).toBeNull(); + + db.query("UPDATE account_authorizations SET revoked_at_ms = $now WHERE id = $id").run({ + $now: NOW_MS, + $id: grantId, + }); + expect(getActiveLiveTradeAuthorization(db, lookup)).toBeNull(); + db.close(); + }); + + test("rejects fractional minor units and supports partner-wide approvers", () => { + const db = new Database(":memory:"); + migrateAuthorizationSchema(db, NOW_MS); + expect(() => + db.query( + `INSERT INTO account_authorization_requests ( + partner_code, out_id, provider, skin, permission_scope, + requested_max_stake, requested_max_win, max_win_basis, + currency, valid_from_ms, request_hash, + telegram_chat_id, telegram_message_id + ) VALUES ('TEST', 'out-TEST-1', 'provider', 'main', 'live_trade', + 1.5, 100, 'profit', 'USD', 1, $hash, '-123', '456')`, + ).run({ $hash: "a".repeat(64) }), + ).toThrow(); + + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id + ) VALUES ('TEST', NULL, '789')`, + ).run(); + expect(() => + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id + ) VALUES ('TEST', NULL, '789')`, + ).run(), + ).toThrow(); + db.close(); + }); +}); diff --git a/tests/partner/authorization/stake.test.ts b/tests/partner/authorization/stake.test.ts new file mode 100644 index 0000000..4d1225a --- /dev/null +++ b/tests/partner/authorization/stake.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { + computeEffectiveStake, + type StakeComputationInput, +} from "../../../src/partner/authorization/index.ts"; + +function input(overrides: Partial = {}): StakeComputationInput { + return { + requestedStake: 10_000, + sitePerBetMax: 20_000, + partnerApprovedMaxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + decimalOdds: 2, + availableBalance: 100_000, + dailyUsed: 0, + dailyLimit: 1_000_000, + outstandingExposure: 0, + exposureLimit: 500_000, + marketLiquidity: 100_000, + ...overrides, + }; +} + +describe("effective stake", () => { + test("uses profit and total-return max-win bases", () => { + expect( + computeEffectiveStake(input({ requestedStake: 20_000, maxWin: 10_000, decimalOdds: 3 })), + ).toBe(5_000); + expect( + computeEffectiveStake( + input({ + requestedStake: 20_000, + maxWin: 10_000, + maxWinBasis: "total_return", + decimalOdds: 3, + }), + ), + ).toBe(3_333); + }); + + test("steps down when floating-point multiplication would exceed max win", () => { + const effective = computeEffectiveStake( + input({ + requestedStake: 100_000, + sitePerBetMax: 100_000, + partnerApprovedMaxStake: 100_000, + maxWin: 987, + decimalOdds: 1.0141, + }), + ); + expect(effective).toBe(69_999); + expect(effective * (1.0141 - 1)).toBeLessThanOrEqual(987); + }); + + test("applies every monetary cap", () => { + const capCases: Array<[Partial, number]> = [ + [{ requestedStake: 900 }, 900], + [{ sitePerBetMax: 800 }, 800], + [{ partnerApprovedMaxStake: 700 }, 700], + [{ availableBalance: 600 }, 600], + [{ marketLiquidity: 500 }, 500], + [{ dailyLimit: 10_400, dailyUsed: 10_000 }, 400], + [{ exposureLimit: 10_300, outstandingExposure: 10_000 }, 300], + ]; + + for (const [overrides, expected] of capCases) { + expect(computeEffectiveStake(input(overrides))).toBe(expected); + } + }); + + test("returns zero for exhausted or unknown executable capacity", () => { + expect(computeEffectiveStake(input({ dailyLimit: 100, dailyUsed: 101 }))).toBe(0); + expect( + computeEffectiveStake(input({ exposureLimit: 100, outstandingExposure: 101 })), + ).toBe(0); + expect(computeEffectiveStake(input({ marketLiquidity: 0 }))).toBe(0); + }); + + test("fails closed for invalid odds and non-integer money", () => { + for (const decimalOdds of [0, 1, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(computeEffectiveStake(input({ decimalOdds }))).toBe(0); + } + expect(computeEffectiveStake(input({ requestedStake: 1.5 }))).toBe(0); + expect(computeEffectiveStake(input({ availableBalance: -1 }))).toBe(0); + expect(computeEffectiveStake(input({ maxWin: Number.MAX_SAFE_INTEGER + 1 }))).toBe(0); + }); +}); From e6fe86737944ad087dd6aae58d346945d44dbb2d Mon Sep 17 00:00:00 2001 From: nolarose Date: Thu, 6 Aug 2026 01:49:22 -0500 Subject: [PATCH 2/7] feat(partner): add Telegram authorization flow --- src/partner/authorization/domain.ts | 15 +- src/partner/authorization/index.ts | 2 + src/partner/authorization/outbox.ts | 475 +++++++++++ src/partner/authorization/service.ts | 763 ++++++++++++++++++ src/partner/authorization/sql.ts | 74 ++ src/partner/domain.ts | 25 +- src/telegram/api.ts | 88 +- src/telegram/authorization-commands.ts | 298 +++++++ src/telegram/authorization-outbox-worker.ts | 91 +++ src/telegram/authorization-requests.ts | 181 +++++ src/telegram/bot.ts | 95 ++- src/telegram/commands.ts | 28 + tests/partner/authorization/hash.test.ts | 14 + tests/partner/authorization/outbox.test.ts | 277 +++++++ tests/partner/authorization/service.test.ts | 498 ++++++++++++ tests/partner/authorization/sql.test.ts | 6 +- tests/telegram/api.test.ts | 51 ++ tests/telegram/authorization-commands.test.ts | 208 +++++ .../authorization-outbox-worker.test.ts | 77 ++ tests/telegram/authorization-requests.test.ts | 144 ++++ tests/telegram/commands.test.ts | 30 + tools/telegram/setup-alert-hub.ts | 7 +- 22 files changed, 3396 insertions(+), 51 deletions(-) create mode 100644 src/partner/authorization/outbox.ts create mode 100644 src/partner/authorization/service.ts create mode 100644 src/telegram/authorization-commands.ts create mode 100644 src/telegram/authorization-outbox-worker.ts create mode 100644 src/telegram/authorization-requests.ts create mode 100644 src/telegram/commands.ts create mode 100644 tests/partner/authorization/outbox.test.ts create mode 100644 tests/partner/authorization/service.test.ts create mode 100644 tests/telegram/api.test.ts create mode 100644 tests/telegram/authorization-commands.test.ts create mode 100644 tests/telegram/authorization-outbox-worker.test.ts create mode 100644 tests/telegram/authorization-requests.test.ts create mode 100644 tests/telegram/commands.test.ts diff --git a/src/partner/authorization/domain.ts b/src/partner/authorization/domain.ts index 1952dbe..0dacf4c 100644 --- a/src/partner/authorization/domain.ts +++ b/src/partner/authorization/domain.ts @@ -51,9 +51,18 @@ function brandNonEmpty(value: string, label: string): T { return normalized as T; } -function brandTelegramNumericId(value: string, label: string): T { +function brandTelegramNumericId( + value: string, + label: string, + allowNegative = false, +): T { const normalized = brandNonEmpty(value, label); - if (!/^-?\d+$/.test(normalized)) throw new TypeError(`${label} must be a numeric Telegram ID`); + const pattern = allowNegative ? /^-?[1-9]\d*$/ : /^[1-9]\d*$/; + if (!pattern.test(normalized)) { + throw new TypeError( + `${label} must be ${allowNegative ? "a numeric" : "a positive numeric"} Telegram ID`, + ); + } return normalized as T; } @@ -90,7 +99,7 @@ export function asPolicyHash(value: string): PolicyHash { } export function asTelegramChatId(value: string): TelegramChatId { - return brandTelegramNumericId(value, "Telegram chat ID"); + return brandTelegramNumericId(value, "Telegram chat ID", true); } export function asTelegramTopicId(value: string): TelegramTopicId { diff --git a/src/partner/authorization/index.ts b/src/partner/authorization/index.ts index 964705a..eda4a9a 100644 --- a/src/partner/authorization/index.ts +++ b/src/partner/authorization/index.ts @@ -1,5 +1,7 @@ export * from "./domain.ts"; export * from "./gate.ts"; export * from "./hash.ts"; +export * from "./outbox.ts"; +export * from "./service.ts"; export * from "./sql.ts"; export * from "./stake.ts"; diff --git a/src/partner/authorization/outbox.ts b/src/partner/authorization/outbox.ts new file mode 100644 index 0000000..3260278 --- /dev/null +++ b/src/partner/authorization/outbox.ts @@ -0,0 +1,475 @@ +import type { Database } from "bun:sqlite"; +import { + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + type TelegramChatId, + type TelegramMessageId, + type TelegramTopicId, +} from "./domain.ts"; + +declare const receiptOutboxIdBrand: unique symbol; +declare const receiptDedupeKeyBrand: unique symbol; +declare const receiptLeaseOwnerBrand: unique symbol; + +export type AuthorizationReceiptOutboxId = number & { readonly [receiptOutboxIdBrand]: true }; +export type AuthorizationReceiptDedupeKey = string & { + readonly [receiptDedupeKeyBrand]: true; +}; +export type AuthorizationReceiptLeaseOwner = string & { + readonly [receiptLeaseOwnerBrand]: true; +}; + +export const AUTHORIZATION_RECEIPT_STATUSES = ["pending", "sent", "dead"] as const; +export type AuthorizationReceiptStatus = (typeof AUTHORIZATION_RECEIPT_STATUSES)[number]; + +export interface AuthorizationReceiptPayload { + text: string; + parseMode?: "HTML" | "MarkdownV2"; + disableNotification?: boolean; + replyToMessageId?: TelegramMessageId; +} + +export interface AuthorizationReceiptOutboxItem { + id: AuthorizationReceiptOutboxId; + dedupeKey: AuthorizationReceiptDedupeKey; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + payload: AuthorizationReceiptPayload; + status: AuthorizationReceiptStatus; + attempts: number; + availableAtMs: number; + leaseOwner: AuthorizationReceiptLeaseOwner | null; + leaseExpiresAtMs: number | null; + lastError: string | null; + sentAtMs: number | null; + createdAtMs: number; + updatedAtMs: number; +} + +export interface EnqueueAuthorizationReceiptInput { + dedupeKey: AuthorizationReceiptDedupeKey; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + payload: AuthorizationReceiptPayload; + availableAtMs?: number; +} + +export interface EnqueueAuthorizationReceiptResult { + created: boolean; + item: AuthorizationReceiptOutboxItem; +} + +export interface ClaimAuthorizationReceiptsInput { + nowMs: number; + leaseOwner: AuthorizationReceiptLeaseOwner; + leaseDurationMs: number; + limit?: number; +} + +export interface MarkAuthorizationReceiptSentInput { + id: AuthorizationReceiptOutboxId; + leaseOwner: AuthorizationReceiptLeaseOwner; + nowMs: number; +} + +export interface MarkAuthorizationReceiptFailedInput { + id: AuthorizationReceiptOutboxId; + leaseOwner: AuthorizationReceiptLeaseOwner; + nowMs: number; + error: string; + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +} + +export class AuthorizationReceiptDedupeConflictError extends Error { + constructor(dedupeKey: AuthorizationReceiptDedupeKey) { + super(`authorization receipt dedupe key already exists with different content: ${dedupeKey}`); + this.name = "AuthorizationReceiptDedupeConflictError"; + } +} + +type OutboxRow = { + id: number; + dedupe_key: string; + telegram_chat_id: string; // brand-ok — SQLite wire value; parsed by mapOutboxRow + telegram_topic_id: string | null; // brand-ok — SQLite wire value; parsed by mapOutboxRow + payload_json: string; + status: AuthorizationReceiptStatus; + attempts: number; + available_at_ms: number; + lease_owner: string | null; + lease_expires_at_ms: number | null; + last_error: string | null; + sent_at_ms: number | null; + created_at_ms: number; + updated_at_ms: number; +}; + +export function asAuthorizationReceiptOutboxId(value: number): AuthorizationReceiptOutboxId { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError("authorization receipt outbox ID must be a positive safe integer"); + } + return value as AuthorizationReceiptOutboxId; +} + +export function asAuthorizationReceiptDedupeKey(value: string): AuthorizationReceiptDedupeKey { + return brandBoundedString( + value, + "authorization receipt dedupe key", + 256, + ); +} + +export function asAuthorizationReceiptLeaseOwner(value: string): AuthorizationReceiptLeaseOwner { + return brandBoundedString( + value, + "authorization receipt lease owner", + 128, + ); +} + +/** Insert once by dedupe key. A reused key must describe the exact same immutable receipt. */ +export function enqueueAuthorizationReceipt( + db: Database, + input: EnqueueAuthorizationReceiptInput, + nowMs = Date.now(), +): EnqueueAuthorizationReceiptResult { + assertTimestamp(nowMs, "enqueue time"); + const availableAtMs = input.availableAtMs ?? nowMs; + assertTimestamp(availableAtMs, "receipt available time"); + const payloadJson = serializePayload(input.payload); + + const inserted = db + .query( + `INSERT INTO account_authorization_receipt_outbox ( + dedupe_key, telegram_chat_id, telegram_topic_id, payload_json, + available_at_ms, created_at_ms, updated_at_ms + ) VALUES ( + $dedupeKey, $chatId, $topicId, $payloadJson, + $availableAtMs, $nowMs, $nowMs + ) + ON CONFLICT(dedupe_key) DO NOTHING + RETURNING *`, + ) + .get({ + $dedupeKey: input.dedupeKey, + $chatId: input.telegramChatId, + $topicId: input.telegramTopicId, + $payloadJson: payloadJson, + $availableAtMs: availableAtMs, + $nowMs: nowMs, + }) as OutboxRow | null; + + if (inserted !== null) return { created: true, item: mapOutboxRow(inserted) }; + + const existing = db + .query( + `SELECT * FROM account_authorization_receipt_outbox + WHERE dedupe_key = $dedupeKey`, + ) + .get({ $dedupeKey: input.dedupeKey }) as OutboxRow | null; + if (existing === null) throw new Error("authorization receipt dedupe lookup failed"); + + if ( + existing.telegram_chat_id !== input.telegramChatId || + existing.telegram_topic_id !== input.telegramTopicId || + existing.payload_json !== payloadJson + ) { + throw new AuthorizationReceiptDedupeConflictError(input.dedupeKey); + } + return { created: false, item: mapOutboxRow(existing) }; +} + +/** Claim due pending receipts under one transaction and increment their delivery attempts. */ +export function claimDueAuthorizationReceipts( + db: Database, + input: ClaimAuthorizationReceiptsInput, +): AuthorizationReceiptOutboxItem[] { + assertTimestamp(input.nowMs, "claim time"); + assertPositiveInteger(input.leaseDurationMs, "lease duration"); + const limit = input.limit ?? 25; + assertPositiveInteger(limit, "claim limit"); + if (limit > 1_000) throw new TypeError("claim limit must not exceed 1000"); + const leaseExpiresAtMs = safeAdd(input.nowMs, input.leaseDurationMs, "lease expiry"); + + db.run("BEGIN IMMEDIATE"); + try { + const candidates = db + .query( + `SELECT id + FROM account_authorization_receipt_outbox + WHERE status = 'pending' + AND available_at_ms <= $nowMs + AND (lease_expires_at_ms IS NULL OR lease_expires_at_ms <= $nowMs) + ORDER BY available_at_ms ASC, id ASC + LIMIT $limit`, + ) + .all({ $nowMs: input.nowMs, $limit: limit }) as Array<{ id: number }>; + + const claimed: AuthorizationReceiptOutboxItem[] = []; + const claim = db.query( + `UPDATE account_authorization_receipt_outbox + SET lease_owner = $leaseOwner, + lease_expires_at_ms = $leaseExpiresAtMs, + attempts = attempts + 1, + updated_at_ms = $nowMs + WHERE id = $id + AND status = 'pending' + AND available_at_ms <= $nowMs + AND (lease_expires_at_ms IS NULL OR lease_expires_at_ms <= $nowMs) + RETURNING *`, + ); + for (const candidate of candidates) { + const row = claim.get({ + $leaseOwner: input.leaseOwner, + $leaseExpiresAtMs: leaseExpiresAtMs, + $nowMs: input.nowMs, + $id: candidate.id, + }) as OutboxRow | null; + if (row !== null) claimed.push(mapOutboxRow(row)); + } + db.run("COMMIT"); + return claimed; + } catch (error) { + db.run("ROLLBACK"); + throw error; + } +} + +/** Mark a receipt sent only while the caller still owns its unexpired lease. */ +export function markAuthorizationReceiptSent( + db: Database, + input: MarkAuthorizationReceiptSentInput, +): AuthorizationReceiptOutboxItem | null { + assertTimestamp(input.nowMs, "sent time"); + const row = db + .query( + `UPDATE account_authorization_receipt_outbox + SET status = 'sent', + sent_at_ms = $nowMs, + lease_owner = NULL, + lease_expires_at_ms = NULL, + last_error = NULL, + updated_at_ms = $nowMs + WHERE id = $id + AND status = 'pending' + AND lease_owner = $leaseOwner + AND lease_expires_at_ms > $nowMs + RETURNING *`, + ) + .get({ $id: input.id, $leaseOwner: input.leaseOwner, $nowMs: input.nowMs }) as OutboxRow | null; + return row === null ? null : mapOutboxRow(row); +} + +/** Release a claimed receipt for retry, or dead-letter it when the attempt budget is exhausted. */ +export function markAuthorizationReceiptFailed( + db: Database, + input: MarkAuthorizationReceiptFailedInput, +): AuthorizationReceiptOutboxItem | null { + assertTimestamp(input.nowMs, "failure time"); + const maxAttempts = input.maxAttempts ?? 5; + const baseDelayMs = input.baseDelayMs ?? 1_000; + const maxDelayMs = input.maxDelayMs ?? 60_000; + assertPositiveInteger(maxAttempts, "maximum attempts"); + assertPositiveInteger(baseDelayMs, "base retry delay"); + assertPositiveInteger(maxDelayMs, "maximum retry delay"); + if (maxDelayMs < baseDelayMs) { + throw new TypeError("maximum retry delay must be at least the base retry delay"); + } + const lastError = normalizeError(input.error); + + db.run("BEGIN IMMEDIATE"); + try { + const claimed = db + .query( + `SELECT attempts + FROM account_authorization_receipt_outbox + WHERE id = $id + AND status = 'pending' + AND lease_owner = $leaseOwner + AND lease_expires_at_ms > $nowMs`, + ) + .get({ $id: input.id, $leaseOwner: input.leaseOwner, $nowMs: input.nowMs }) as + | { attempts: number } + | null; + + if (claimed === null) { + db.run("COMMIT"); + return null; + } + + const dead = claimed.attempts >= maxAttempts; + const availableAtMs = dead + ? input.nowMs + : safeAdd( + input.nowMs, + boundedBackoff(claimed.attempts, baseDelayMs, maxDelayMs), + "retry availability", + ); + const row = db + .query( + `UPDATE account_authorization_receipt_outbox + SET status = $status, + available_at_ms = $availableAtMs, + lease_owner = NULL, + lease_expires_at_ms = NULL, + last_error = $lastError, + updated_at_ms = $nowMs + WHERE id = $id + AND status = 'pending' + AND lease_owner = $leaseOwner + AND lease_expires_at_ms > $nowMs + RETURNING *`, + ) + .get({ + $status: dead ? "dead" : "pending", + $availableAtMs: availableAtMs, + $lastError: lastError, + $nowMs: input.nowMs, + $id: input.id, + $leaseOwner: input.leaseOwner, + }) as OutboxRow | null; + db.run("COMMIT"); + return row === null ? null : mapOutboxRow(row); + } catch (error) { + db.run("ROLLBACK"); + throw error; + } +} + +export function getAuthorizationReceiptOutboxItem( + db: Database, + id: AuthorizationReceiptOutboxId, +): AuthorizationReceiptOutboxItem | null { + const row = db + .query("SELECT * FROM account_authorization_receipt_outbox WHERE id = $id") + .get({ $id: id }) as OutboxRow | null; + return row === null ? null : mapOutboxRow(row); +} + +function mapOutboxRow(row: OutboxRow): AuthorizationReceiptOutboxItem { + return { + id: asAuthorizationReceiptOutboxId(row.id), + dedupeKey: asAuthorizationReceiptDedupeKey(row.dedupe_key), + telegramChatId: asTelegramChatId(row.telegram_chat_id), + telegramTopicId: + row.telegram_topic_id === null ? null : asTelegramTopicId(row.telegram_topic_id), + payload: parsePayload(row.payload_json), + status: row.status, + attempts: row.attempts, + availableAtMs: row.available_at_ms, + leaseOwner: + row.lease_owner === null ? null : asAuthorizationReceiptLeaseOwner(row.lease_owner), + leaseExpiresAtMs: row.lease_expires_at_ms, + lastError: row.last_error, + sentAtMs: row.sent_at_ms, + createdAtMs: row.created_at_ms, + updatedAtMs: row.updated_at_ms, + }; +} + +function serializePayload(payload: AuthorizationReceiptPayload): string { + const normalized = validatePayload(payload); + // Explicit field order is the durable wire contract. + return JSON.stringify({ + text: normalized.text, + ...(normalized.parseMode === undefined ? {} : { parseMode: normalized.parseMode }), + ...(normalized.disableNotification === undefined + ? {} + : { disableNotification: normalized.disableNotification }), + ...(normalized.replyToMessageId === undefined + ? {} + : { replyToMessageId: normalized.replyToMessageId }), + }); +} + +function parsePayload(payloadJson: string): AuthorizationReceiptPayload { + let parsed: unknown; + try { + parsed = JSON.parse(payloadJson); + } catch { + throw new TypeError("stored authorization receipt payload is not valid JSON"); + } + return validatePayload(parsed); +} + +function validatePayload(payload: unknown): AuthorizationReceiptPayload { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new TypeError("authorization receipt payload must be an object"); + } + const candidate = payload as Record; + const allowed = new Set(["text", "parseMode", "disableNotification", "replyToMessageId"]); + for (const key of Object.keys(candidate)) { + if (!allowed.has(key)) throw new TypeError(`unknown authorization receipt payload field: ${key}`); + } + if (typeof candidate.text !== "string" || candidate.text.trim().length === 0) { + throw new TypeError("authorization receipt text must not be empty"); + } + if (candidate.text.length > 4_096) { + throw new TypeError("authorization receipt text must be at most 4096 characters"); + } + if ( + candidate.parseMode !== undefined && + candidate.parseMode !== "HTML" && + candidate.parseMode !== "MarkdownV2" + ) { + throw new TypeError("authorization receipt parse mode must be HTML or MarkdownV2"); + } + if ( + candidate.disableNotification !== undefined && + typeof candidate.disableNotification !== "boolean" + ) { + throw new TypeError("authorization receipt disableNotification must be boolean"); + } + + const result: AuthorizationReceiptPayload = { text: candidate.text }; + if (candidate.parseMode !== undefined) result.parseMode = candidate.parseMode; + if (candidate.disableNotification !== undefined) { + result.disableNotification = candidate.disableNotification; + } + if (candidate.replyToMessageId !== undefined) { + if (typeof candidate.replyToMessageId !== "string") { + throw new TypeError("authorization receipt replyToMessageId must be a string"); + } + result.replyToMessageId = asTelegramMessageId(candidate.replyToMessageId); + } + return result; +} + +function boundedBackoff(attempts: number, baseDelayMs: number, maxDelayMs: number): number { + const exponent = Math.min(Math.max(attempts - 1, 0), 52); + return Math.min(maxDelayMs, baseDelayMs * 2 ** exponent); +} + +function normalizeError(error: string): string { + const normalized = error.trim() || "delivery failed"; + return normalized.slice(0, 2_048); +} + +function assertTimestamp(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${label} must be a non-negative epoch-millisecond integer`); + } +} + +function assertPositiveInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer`); + } +} + +function safeAdd(left: number, right: number, label: string): number { + const result = left + right; + if (!Number.isSafeInteger(result)) throw new TypeError(`${label} exceeds safe integer range`); + return result; +} + +function brandBoundedString(value: string, label: string, max: number): T { + const normalized = value.trim(); + if (normalized.length === 0) throw new TypeError(`${label} must not be empty`); + if (normalized.length > max) throw new TypeError(`${label} must be at most ${max} characters`); + if (/\p{Cc}/u.test(normalized)) throw new TypeError(`${label} must not contain control characters`); + return normalized as T; +} diff --git a/src/partner/authorization/service.ts b/src/partner/authorization/service.ts new file mode 100644 index 0000000..9252687 --- /dev/null +++ b/src/partner/authorization/service.ts @@ -0,0 +1,763 @@ +import type { Database } from "bun:sqlite"; +import type { + ApprovedAuthorization, + AuthorizationPolicy, + AuthorizationRequest, + AuthorizationRequestId, + OutId, + PartnerCode, + PolicyHash, + SkinId, + TelegramChatId, + TelegramMessageId, + TelegramTopicId, + TelegramUserId, +} from "./domain.ts"; +import { + asAuthorizationId, + asAuthorizationRequestId, + asCurrencyCode, + asOutId, + asPartnerCode, + asPolicyHash, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + asTelegramUserId, +} from "./domain.ts"; +import { computePolicyHash, verifyPolicyMatch } from "./hash.ts"; + +export interface CreateAuthorizationRequestInput { + policy: AuthorizationPolicy; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + telegramMessageId: TelegramMessageId; + nowMs: number; +} + +export type CreateAuthorizationRequestResult = + | { ok: true; code: "REQUEST_CREATED"; request: AuthorizationRequest } + | { + ok: false; + code: "INVALID_INPUT" | "POLICY_ALREADY_EXPIRED" | "DATABASE_ERROR"; + reason: string; + }; + +export interface ApproveAuthorizationRequestInput { + requestId: AuthorizationRequestId; + currentPolicy: AuthorizationPolicy; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + telegramMessageId: TelegramMessageId; + approvingUserId: TelegramUserId; + nowMs: number; +} + +export type ApproveAuthorizationRequestResult = + | { + ok: true; + code: "AUTHORIZATION_APPROVED" | "ALREADY_APPROVED"; + authorization: ApprovedAuthorization; + } + | { + ok: false; + code: + | "INVALID_INPUT" + | "REQUEST_NOT_FOUND" + | "REQUEST_NOT_PENDING" + | "REQUEST_EXPIRED" + | "CHAT_MISMATCH" + | "TOPIC_MISMATCH" + | "APPROVER_NOT_ALLOWED" + | "POLICY_HASH_MISMATCH" + | "DATABASE_ERROR"; + reason: string; + }; + +export interface RevokeAuthorizationsInput { + partnerCode: PartnerCode; + outId: OutId; + skin: SkinId; + nowMs: number; +} + +export type RevokeAuthorizationsResult = + | { ok: true; code: "AUTHORIZATIONS_REVOKED"; revokedCount: number } + | { + ok: false; + code: "INVALID_INPUT" | "NO_ACTIVE_AUTHORIZATIONS" | "DATABASE_ERROR"; + reason: string; + }; + +export interface RevokeOutFromTelegramInput { + outId: OutId; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + telegramMessageId: TelegramMessageId; + revokingUserId: TelegramUserId; + nowMs: number; +} + +export type RevokeOutFromTelegramResult = + | { + ok: true; + code: "OUT_AUTHORIZATIONS_REVOKED"; + partnerCode: PartnerCode; + outId: OutId; + revokedCount: number; + } + | { + ok: false; + code: + | "INVALID_INPUT" + | "NO_ACTIVE_AUTHORIZATIONS" + | "CHANNEL_MISMATCH" + | "AMBIGUOUS_OUT" + | "APPROVER_NOT_ALLOWED" + | "DATABASE_ERROR"; + reason: string; + }; + +type RequestRow = { + id: number; + partner_code: string; + out_id: string; // brand-ok — SQLite wire value; parsed by requestPolicy + provider: string; + skin: string; + permission_scope: AuthorizationRequest["scope"]; + requested_max_stake: number; + requested_max_win: number; + max_win_basis: AuthorizationRequest["maxWinBasis"]; + daily_limit: number | null; + exposure_limit: number | null; + currency: string; + valid_from_ms: number; + expires_at_ms: number | null; + request_hash: string; + telegram_chat_id: string; // brand-ok — SQLite wire value; parsed by mapRequest + telegram_topic_id: string | null; // brand-ok — SQLite wire value; parsed by mapRequest + telegram_message_id: string; // brand-ok — SQLite wire value; parsed by mapRequest + status: AuthorizationRequest["status"]; + created_at_ms: number; + updated_at_ms: number; +}; + +type AuthorizationRow = { + id: number; + request_id: number; + partner_code: string; + out_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorization + provider: string; + skin: string; + permission_scope: ApprovedAuthorization["scope"]; + approved_max_stake: number; + approved_max_win: number; + max_win_basis: ApprovedAuthorization["maxWinBasis"]; + daily_limit: number | null; + exposure_limit: number | null; + currency: string; + valid_from_ms: number; + expires_at_ms: number | null; + approval_hash: string; + telegram_chat_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorization + telegram_topic_id: string | null; // brand-ok — SQLite wire value; parsed by mapAuthorization + telegram_message_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorization + telegram_approving_user_id: string; // brand-ok — SQLite wire value; parsed by mapAuthorization + revoked_at_ms: number | null; + created_at_ms: number; + updated_at_ms: number; +}; + +function validateNowMs(nowMs: number): string | null { + return Number.isSafeInteger(nowMs) && nowMs >= 0 + ? null + : "nowMs must be a non-negative epoch-millisecond integer"; +} + +function requestPolicy(row: RequestRow): AuthorizationPolicy { + return { + partnerCode: asPartnerCode(row.partner_code), + outId: asOutId(row.out_id), + provider: asProviderId(row.provider), + skin: asSkinId(row.skin), + scope: row.permission_scope, + maxStake: row.requested_max_stake, + maxWin: row.requested_max_win, + maxWinBasis: row.max_win_basis, + dailyLimit: row.daily_limit, + exposureLimit: row.exposure_limit, + currency: asCurrencyCode(row.currency), + validFromMs: row.valid_from_ms, + expiresAtMs: row.expires_at_ms, + }; +} + +function mapRequest(row: RequestRow): AuthorizationRequest { + return { + id: asAuthorizationRequestId(row.id), + ...requestPolicy(row), + status: row.status, + requestHash: asPolicyHash(row.request_hash), + telegramChatId: asTelegramChatId(row.telegram_chat_id), + telegramTopicId: + row.telegram_topic_id === null ? null : asTelegramTopicId(row.telegram_topic_id), + telegramMessageId: asTelegramMessageId(row.telegram_message_id), + createdAtMs: row.created_at_ms, + updatedAtMs: row.updated_at_ms, + }; +} + +function mapAuthorization(row: AuthorizationRow): ApprovedAuthorization { + return { + id: asAuthorizationId(row.id), + requestId: asAuthorizationRequestId(row.request_id), + partnerCode: asPartnerCode(row.partner_code), + outId: asOutId(row.out_id), + provider: asProviderId(row.provider), + skin: asSkinId(row.skin), + scope: row.permission_scope, + maxStake: row.approved_max_stake, + maxWin: row.approved_max_win, + maxWinBasis: row.max_win_basis, + dailyLimit: row.daily_limit, + exposureLimit: row.exposure_limit, + currency: asCurrencyCode(row.currency), + validFromMs: row.valid_from_ms, + expiresAtMs: row.expires_at_ms, + approvalHash: asPolicyHash(row.approval_hash), + telegramChatId: asTelegramChatId(row.telegram_chat_id), + telegramTopicId: + row.telegram_topic_id === null ? null : asTelegramTopicId(row.telegram_topic_id), + telegramMessageId: asTelegramMessageId(row.telegram_message_id), + approvingUserId: asTelegramUserId(row.telegram_approving_user_id), + revokedAtMs: row.revoked_at_ms, + createdAtMs: row.created_at_ms, + updatedAtMs: row.updated_at_ms, + }; +} + +function databaseFailure(reason: unknown): string { + return reason instanceof Error ? reason.message : "authorization database operation failed"; +} + +/** Persist an immutable policy request after its Telegram request message exists. */ +export function createAuthorizationRequest( + db: Database, + input: CreateAuthorizationRequestInput, +): CreateAuthorizationRequestResult { + const invalidTime = validateNowMs(input.nowMs); + if (invalidTime !== null) return { ok: false, code: "INVALID_INPUT", reason: invalidTime }; + if (input.policy.expiresAtMs !== null && input.policy.expiresAtMs <= input.nowMs) { + return { + ok: false, + code: "POLICY_ALREADY_EXPIRED", + reason: "authorization policy is already expired", + }; + } + + let requestHash: PolicyHash; + try { + requestHash = computePolicyHash(input.policy); + asTelegramChatId(input.telegramChatId); + if (input.telegramTopicId !== null) asTelegramTopicId(input.telegramTopicId); + asTelegramMessageId(input.telegramMessageId); + } catch (error) { + return { ok: false, code: "INVALID_INPUT", reason: databaseFailure(error) }; + } + + try { + const row = db + .query( + `INSERT INTO account_authorization_requests ( + partner_code, out_id, provider, skin, permission_scope, + requested_max_stake, requested_max_win, max_win_basis, + daily_limit, exposure_limit, currency, valid_from_ms, expires_at_ms, + request_hash, telegram_chat_id, telegram_topic_id, telegram_message_id, + status, created_at_ms, updated_at_ms + ) VALUES ( + $partnerCode, $outId, $provider, $skin, $scope, + $maxStake, $maxWin, $maxWinBasis, + $dailyLimit, $exposureLimit, $currency, $validFromMs, $expiresAtMs, + $requestHash, $telegramChatId, $telegramTopicId, $telegramMessageId, + 'pending', $nowMs, $nowMs + ) RETURNING *`, + ) + .get({ + $partnerCode: input.policy.partnerCode, + $outId: input.policy.outId, + $provider: input.policy.provider, + $skin: input.policy.skin, + $scope: input.policy.scope, + $maxStake: input.policy.maxStake, + $maxWin: input.policy.maxWin, + $maxWinBasis: input.policy.maxWinBasis, + $dailyLimit: input.policy.dailyLimit, + $exposureLimit: input.policy.exposureLimit, + $currency: input.policy.currency, + $validFromMs: input.policy.validFromMs, + $expiresAtMs: input.policy.expiresAtMs, + $requestHash: requestHash, + $telegramChatId: input.telegramChatId, + $telegramTopicId: input.telegramTopicId, + $telegramMessageId: input.telegramMessageId, + $nowMs: input.nowMs, + }) as RequestRow; + return { ok: true, code: "REQUEST_CREATED", request: mapRequest(row) }; + } catch (error) { + return { ok: false, code: "DATABASE_ERROR", reason: databaseFailure(error) }; + } +} + +function getAuthorizationByRequestId( + db: Database, + requestId: AuthorizationRequestId, +): ApprovedAuthorization | null { + const row = db + .query("SELECT * FROM account_authorizations WHERE request_id = $requestId") + .get({ $requestId: requestId }) as AuthorizationRow | null; + return row === null ? null : mapAuthorization(row); +} + +export function getAuthorizationRequest( + db: Database, + requestId: AuthorizationRequestId, +): AuthorizationRequest | null { + const row = db + .query("SELECT * FROM account_authorization_requests WHERE id = $requestId") + .get({ $requestId: requestId }) as RequestRow | null; + return row === null ? null : mapRequest(row); +} + +/** Resolve the newest approval policy snapshot for the same partner/out/provider/skin lane. */ +export function getCurrentAuthorizationPolicy( + db: Database, + request: AuthorizationRequest, +): AuthorizationPolicy | null { + const row = db + .query( + `SELECT * + FROM account_authorization_requests + WHERE partner_code = $partnerCode + AND out_id = $outId + AND provider = $provider + AND skin = $skin + AND status IN ('pending', 'approved') + ORDER BY created_at_ms DESC, id DESC + LIMIT 1`, + ) + .get({ + $partnerCode: request.partnerCode, + $outId: request.outId, + $provider: request.provider, + $skin: request.skin, + }) as RequestRow | null; + return row === null ? null : requestPolicy(row); +} + +/** + * Approve one pending request in a single immediate transaction. + * An explicit partner-wide approver (out_id IS NULL) may approve any out for that partner. + */ +export function approveAuthorizationRequest( + db: Database, + input: ApproveAuthorizationRequestInput, +): ApproveAuthorizationRequestResult { + const invalidTime = validateNowMs(input.nowMs); + if (invalidTime !== null) return { ok: false, code: "INVALID_INPUT", reason: invalidTime }; + try { + asAuthorizationRequestId(input.requestId); + asTelegramChatId(input.telegramChatId); + if (input.telegramTopicId !== null) asTelegramTopicId(input.telegramTopicId); + asTelegramMessageId(input.telegramMessageId); + asTelegramUserId(input.approvingUserId); + computePolicyHash(input.currentPolicy); + } catch (error) { + return { ok: false, code: "INVALID_INPUT", reason: databaseFailure(error) }; + } + + try { + const transaction = db.transaction((): ApproveAuthorizationRequestResult => { + const row = db + .query("SELECT * FROM account_authorization_requests WHERE id = $requestId") + .get({ $requestId: input.requestId }) as RequestRow | null; + if (row === null) { + return { ok: false, code: "REQUEST_NOT_FOUND", reason: "authorization request not found" }; + } + + if (row.telegram_chat_id !== input.telegramChatId) { + return { ok: false, code: "CHAT_MISMATCH", reason: "approval chat does not match request" }; + } + if (row.telegram_topic_id !== input.telegramTopicId) { + return { ok: false, code: "TOPIC_MISMATCH", reason: "approval topic does not match request" }; + } + + if (row.status === "approved") { + const replayedAuthorization = getAuthorizationByRequestId( + db, + asAuthorizationRequestId(row.id), + ); + if ( + replayedAuthorization !== null && + replayedAuthorization.telegramChatId === input.telegramChatId && + replayedAuthorization.telegramTopicId === input.telegramTopicId && + replayedAuthorization.telegramMessageId === input.telegramMessageId && + replayedAuthorization.approvingUserId === input.approvingUserId + ) { + return { + ok: true, + code: "ALREADY_APPROVED", + authorization: replayedAuthorization, + }; + } + } + + const approver = db + .query( + `SELECT 1 AS allowed + FROM account_authorization_approvers + WHERE partner_code = $partnerCode + AND telegram_user_id = $telegramUserId + AND (out_id = $outId OR out_id IS NULL) + LIMIT 1`, + ) + .get({ + $partnerCode: row.partner_code, + $outId: row.out_id, + $telegramUserId: input.approvingUserId, + }) as { allowed: number } | null; + if (approver === null) { + return { + ok: false, + code: "APPROVER_NOT_ALLOWED", + reason: "Telegram user is not allowlisted for this partner and out", + }; + } + + const persistedPolicy = requestPolicy(row); + if ( + !verifyPolicyMatch(persistedPolicy, row.request_hash) || + !verifyPolicyMatch(input.currentPolicy, row.request_hash) + ) { + return { + ok: false, + code: "POLICY_HASH_MISMATCH", + reason: "persisted or current policy does not match the requested policy hash", + }; + } + + if (row.status === "approved") { + const authorization = getAuthorizationByRequestId(db, asAuthorizationRequestId(row.id)); + if (authorization !== null) { + return { ok: true, code: "ALREADY_APPROVED", authorization }; + } + return { + ok: false, + code: "DATABASE_ERROR", + reason: "approved request has no authorization grant", + }; + } + if (row.status !== "pending") { + return { + ok: false, + code: "REQUEST_NOT_PENDING", + reason: `authorization request status is ${row.status}`, + }; + } + if (row.expires_at_ms !== null && row.expires_at_ms <= input.nowMs) { + db.query( + `UPDATE account_authorization_requests + SET status = 'expired', updated_at_ms = $nowMs + WHERE id = $requestId AND status = 'pending'`, + ).run({ $nowMs: input.nowMs, $requestId: row.id }); + return { ok: false, code: "REQUEST_EXPIRED", reason: "authorization request has expired" }; + } + + const grantRow = db + .query( + `INSERT INTO account_authorizations ( + request_id, partner_code, out_id, provider, skin, permission_scope, + approved_max_stake, approved_max_win, max_win_basis, + daily_limit, exposure_limit, currency, valid_from_ms, expires_at_ms, + approval_hash, telegram_chat_id, telegram_topic_id, telegram_message_id, + telegram_approving_user_id, revoked_at_ms, created_at_ms, updated_at_ms + ) VALUES ( + $requestId, $partnerCode, $outId, $provider, $skin, $scope, + $maxStake, $maxWin, $maxWinBasis, + $dailyLimit, $exposureLimit, $currency, $validFromMs, $expiresAtMs, + $approvalHash, $telegramChatId, $telegramTopicId, $telegramMessageId, + $approvingUserId, NULL, $nowMs, $nowMs + ) RETURNING *`, + ) + .get({ + $requestId: row.id, + $partnerCode: persistedPolicy.partnerCode, + $outId: persistedPolicy.outId, + $provider: persistedPolicy.provider, + $skin: persistedPolicy.skin, + $scope: persistedPolicy.scope, + $maxStake: persistedPolicy.maxStake, + $maxWin: persistedPolicy.maxWin, + $maxWinBasis: persistedPolicy.maxWinBasis, + $dailyLimit: persistedPolicy.dailyLimit, + $exposureLimit: persistedPolicy.exposureLimit, + $currency: persistedPolicy.currency, + $validFromMs: persistedPolicy.validFromMs, + $expiresAtMs: persistedPolicy.expiresAtMs, + $approvalHash: asPolicyHash(row.request_hash), + $telegramChatId: input.telegramChatId, + $telegramTopicId: input.telegramTopicId, + $telegramMessageId: input.telegramMessageId, + $approvingUserId: input.approvingUserId, + $nowMs: input.nowMs, + }) as AuthorizationRow; + + const changed = db + .query( + `UPDATE account_authorization_requests + SET status = 'approved', updated_at_ms = $nowMs + WHERE id = $requestId AND status = 'pending'`, + ) + .run({ $nowMs: input.nowMs, $requestId: row.id }); + if (changed.changes !== 1) throw new Error("authorization request changed during approval"); + + return { + ok: true, + code: "AUTHORIZATION_APPROVED", + authorization: mapAuthorization(grantRow), + }; + }); + return transaction.immediate(); + } catch (error) { + return { ok: false, code: "DATABASE_ERROR", reason: databaseFailure(error) }; + } +} + +/** Revoke every unrevoked grant for one exact partner, out, and skin. */ +export function revokeAuthorizations( + db: Database, + input: RevokeAuthorizationsInput, +): RevokeAuthorizationsResult { + const invalidTime = validateNowMs(input.nowMs); + if (invalidTime !== null) return { ok: false, code: "INVALID_INPUT", reason: invalidTime }; + try { + asPartnerCode(input.partnerCode); + asOutId(input.outId); + asSkinId(input.skin); + } catch (error) { + return { ok: false, code: "INVALID_INPUT", reason: databaseFailure(error) }; + } + + try { + const result = db + .query( + `UPDATE account_authorizations + SET revoked_at_ms = $nowMs, updated_at_ms = $nowMs + WHERE partner_code = $partnerCode + AND out_id = $outId + AND skin = $skin + AND revoked_at_ms IS NULL`, + ) + .run({ + $nowMs: input.nowMs, + $partnerCode: input.partnerCode, + $outId: input.outId, + $skin: input.skin, + }); + if (result.changes === 0) { + return { + ok: false, + code: "NO_ACTIVE_AUTHORIZATIONS", + reason: "no unrevoked authorizations found for the exact partner, out, and skin", + }; + } + return { ok: true, code: "AUTHORIZATIONS_REVOKED", revokedCount: result.changes }; + } catch (error) { + return { ok: false, code: "DATABASE_ERROR", reason: databaseFailure(error) }; + } +} + +/** Revoke every skin for one out from its exact Telegram channel after an allowlist check. */ +export function revokeOutFromTelegram( + db: Database, + input: RevokeOutFromTelegramInput, +): RevokeOutFromTelegramResult { + const invalidTime = validateNowMs(input.nowMs); + if (invalidTime !== null) return { ok: false, code: "INVALID_INPUT", reason: invalidTime }; + try { + asOutId(input.outId); + asTelegramChatId(input.telegramChatId); + if (input.telegramTopicId !== null) asTelegramTopicId(input.telegramTopicId); + asTelegramMessageId(input.telegramMessageId); + asTelegramUserId(input.revokingUserId); + } catch (error) { + return { ok: false, code: "INVALID_INPUT", reason: databaseFailure(error) }; + } + + try { + const transaction = db.transaction((): RevokeOutFromTelegramResult => { + const replay = db + .query( + `SELECT partner_code, out_id, count(*) AS revoked_count + FROM account_authorization_revocations + WHERE out_id = $outId + AND telegram_chat_id = $chatId + AND telegram_topic_id IS $topicId + AND telegram_message_id = $messageId + AND telegram_revoking_user_id = $userId + GROUP BY partner_code, out_id`, + ) + .get({ + $outId: input.outId, + $chatId: input.telegramChatId, + $topicId: input.telegramTopicId, + $messageId: input.telegramMessageId, + $userId: input.revokingUserId, + }) as + | { partner_code: string; out_id: string; revoked_count: number } // brand-ok — SQLite replay row + | null; + if (replay !== null) { + return { + ok: true, + code: "OUT_AUTHORIZATIONS_REVOKED", + partnerCode: asPartnerCode(replay.partner_code), + outId: asOutId(replay.out_id), + revokedCount: replay.revoked_count, + }; + } + + const allRows = db + .query( + `SELECT id, partner_code, out_id, skin + FROM account_authorizations + WHERE out_id = $outId AND revoked_at_ms IS NULL + ORDER BY id`, + ) + .all({ $outId: input.outId }) as Array<{ + id: number; + partner_code: string; + out_id: string; // brand-ok — SQLite wire value, constrained by input out brand + skin: string; + }>; + if (allRows.length === 0) { + return { + ok: false, + code: "NO_ACTIVE_AUTHORIZATIONS", + reason: "no unrevoked authorizations found for this out", + }; + } + + const channelRows = db + .query( + `SELECT id, partner_code, out_id, skin + FROM account_authorizations + WHERE out_id = $outId + AND revoked_at_ms IS NULL + AND telegram_chat_id = $chatId + AND telegram_topic_id IS $topicId + ORDER BY id`, + ) + .all({ + $outId: input.outId, + $chatId: input.telegramChatId, + $topicId: input.telegramTopicId, + }) as typeof allRows; + if (channelRows.length === 0 || channelRows.length !== allRows.length) { + return { + ok: false, + code: "CHANNEL_MISMATCH", + reason: "active grants for this out are not bound exclusively to this chat and topic", + }; + } + + const partnerCodes = new Set(channelRows.map((row) => row.partner_code)); + if (partnerCodes.size !== 1) { + return { + ok: false, + code: "AMBIGUOUS_OUT", + reason: "out ID resolves to more than one partner", + }; + } + const partnerCode = asPartnerCode(channelRows[0]!.partner_code); + + const approver = db + .query( + `SELECT 1 AS allowed + FROM account_authorization_approvers + WHERE partner_code = $partnerCode + AND telegram_user_id = $telegramUserId + AND (out_id = $outId OR out_id IS NULL) + LIMIT 1`, + ) + .get({ + $partnerCode: partnerCode, + $outId: input.outId, + $telegramUserId: input.revokingUserId, + }) as { allowed: number } | null; + if (approver === null) { + return { + ok: false, + code: "APPROVER_NOT_ALLOWED", + reason: "Telegram user is not allowlisted to revoke this partner and out", + }; + } + + const insertEvidence = db.query( + `INSERT INTO account_authorization_revocations ( + authorization_id, partner_code, out_id, skin, + telegram_chat_id, telegram_topic_id, telegram_message_id, + telegram_revoking_user_id, revoked_at_ms + ) VALUES ( + $authorizationId, $partnerCode, $outId, $skin, + $chatId, $topicId, $messageId, $userId, $nowMs + )`, + ); + for (const row of channelRows) { + insertEvidence.run({ + $authorizationId: row.id, + $partnerCode: partnerCode, + $outId: input.outId, + $skin: row.skin, + $chatId: input.telegramChatId, + $topicId: input.telegramTopicId, + $messageId: input.telegramMessageId, + $userId: input.revokingUserId, + $nowMs: input.nowMs, + }); + } + + const updated = db + .query( + `UPDATE account_authorizations + SET revoked_at_ms = $nowMs, updated_at_ms = $nowMs + WHERE out_id = $outId + AND partner_code = $partnerCode + AND revoked_at_ms IS NULL + AND telegram_chat_id = $chatId + AND telegram_topic_id IS $topicId`, + ) + .run({ + $nowMs: input.nowMs, + $outId: input.outId, + $partnerCode: partnerCode, + $chatId: input.telegramChatId, + $topicId: input.telegramTopicId, + }); + if (updated.changes !== channelRows.length) { + throw new Error("authorization set changed during out revocation"); + } + + return { + ok: true, + code: "OUT_AUTHORIZATIONS_REVOKED", + partnerCode, + outId: input.outId, + revokedCount: updated.changes, + }; + }); + return transaction.immediate(); + } catch (error) { + return { ok: false, code: "DATABASE_ERROR", reason: databaseFailure(error) }; + } +} diff --git a/src/partner/authorization/sql.ts b/src/partner/authorization/sql.ts index d3f3604..dd57938 100644 --- a/src/partner/authorization/sql.ts +++ b/src/partner/authorization/sql.ts @@ -130,6 +130,80 @@ export const AUTHORIZATION_MIGRATIONS = [ WHERE out_id IS NOT NULL; `, }, + { + id: "002_account_authorization_receipt_outbox", + sql: ` + CREATE TABLE IF NOT EXISTS account_authorization_receipt_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dedupe_key TEXT NOT NULL UNIQUE CHECK ( + length(dedupe_key) BETWEEN 1 AND 256 + ), + telegram_chat_id TEXT NOT NULL, + telegram_topic_id TEXT, + payload_json TEXT NOT NULL CHECK (json_valid(payload_json)), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'dead')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK ( + typeof(attempts) = 'integer' AND attempts >= 0 + ), + available_at_ms INTEGER NOT NULL CHECK ( + typeof(available_at_ms) = 'integer' AND available_at_ms >= 0 + ), + lease_owner TEXT CHECK ( + lease_owner IS NULL OR length(lease_owner) BETWEEN 1 AND 128 + ), + lease_expires_at_ms INTEGER CHECK ( + lease_expires_at_ms IS NULL OR ( + typeof(lease_expires_at_ms) = 'integer' AND lease_expires_at_ms >= 0 + ) + ), + last_error TEXT, + sent_at_ms INTEGER CHECK ( + sent_at_ms IS NULL OR (typeof(sent_at_ms) = 'integer' AND sent_at_ms >= 0) + ), + created_at_ms INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)), + updated_at_ms INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)), + CHECK ( + (lease_owner IS NULL AND lease_expires_at_ms IS NULL) + OR (lease_owner IS NOT NULL AND lease_expires_at_ms IS NOT NULL) + ), + CHECK ( + status = 'pending' + OR (lease_owner IS NULL AND lease_expires_at_ms IS NULL) + ), + CHECK ( + (status = 'sent' AND sent_at_ms IS NOT NULL) + OR (status != 'sent' AND sent_at_ms IS NULL) + ) + ); + + CREATE INDEX IF NOT EXISTS idx_auth_receipt_outbox_due + ON account_authorization_receipt_outbox ( + status, available_at_ms, lease_expires_at_ms, id + ); + `, + }, + { + id: "003_account_authorization_revocations", + sql: ` + CREATE TABLE IF NOT EXISTS account_authorization_revocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + authorization_id INTEGER NOT NULL UNIQUE REFERENCES account_authorizations(id), + partner_code TEXT NOT NULL, + out_id TEXT NOT NULL, + skin TEXT NOT NULL, + telegram_chat_id TEXT NOT NULL, + telegram_topic_id TEXT, + telegram_message_id TEXT NOT NULL, + telegram_revoking_user_id TEXT NOT NULL, + revoked_at_ms INTEGER NOT NULL CHECK ( + typeof(revoked_at_ms) = 'integer' AND revoked_at_ms >= 0 + ) + ); + + CREATE INDEX IF NOT EXISTS idx_auth_revocations_out + ON account_authorization_revocations (partner_code, out_id, revoked_at_ms); + `, + }, ] as const; type MigrationRow = { diff --git a/src/partner/domain.ts b/src/partner/domain.ts index 316a1a9..6a69b44 100644 --- a/src/partner/domain.ts +++ b/src/partner/domain.ts @@ -110,7 +110,14 @@ export const PARTNER_DOMAIN_LAYERS: readonly DomainLayer[] = [ name: "Telegram long-poll bot", maturity: "partial", where: "src/telegram/bot.ts", - notes: "Calibration digest / dashboard — not partner /capacity /add yet", + notes: "Calibration digest/dashboard plus permissioned /approve and /revoke_out routing", + }, + { + id: "telegram-authorization-flow", + name: "Telegram authorization + durable receipt outbox", + maturity: "built", + where: "src/telegram/authorization-*.ts · src/partner/authorization/", + notes: "Numeric chat/topic/user binding, hash-verified grants, revocation evidence, retries", }, { id: "telegram-subscribers", @@ -121,9 +128,9 @@ export const PARTNER_DOMAIN_LAYERS: readonly DomainLayer[] = [ { id: "partner-telegram-link", name: "partners.telegram_chat_id / topicId", - maturity: "planned", - where: "—", - notes: "Not on partners row; watch-fantasy-events uses global TELEGRAM_CHAT_ID", + maturity: "partial", + where: "account_authorization_requests · account_authorizations", + notes: "Authorization provenance is bound; partner-level channel preferences remain planned", }, { id: "inventory-telegram", @@ -170,6 +177,13 @@ export const PARTNER_DOMAIN_LAYERS: readonly DomainLayer[] = [ where: "listEligibleOutSkinPairs · concentrationByOut", notes: "Helpers only — no full proposal router CLI yet", }, + { + id: "authorized-execution-wrapper", + name: "Transactional authorized execution wrapper", + maturity: "planned", + where: "—", + notes: "Gate recheck, exposure reservation, provider placement, and ticket receipt remain next", + }, ], }, { @@ -307,7 +321,8 @@ export function buildDomainStatusReport( layers, totals, orchestration: { - ssot: "event-store SQLite (partners, betting_accounts, partner_events) + Proton Pass + env", + ssot: + "event-store SQLite (partners, betting_accounts, partner_events, account_authorizations) + Proton Pass + env", clis: [ "partner:domain", "partner:capacity", diff --git a/src/telegram/api.ts b/src/telegram/api.ts index 50341f1..92bdc1b 100644 --- a/src/telegram/api.ts +++ b/src/telegram/api.ts @@ -2,16 +2,35 @@ * Telegram Bot API wrapper — Bun-native, zero deps. * @see https://core.telegram.org/bots/api */ -const TOKEN = Bun.env.TELEGRAM_BOT_TOKEN; -if (!TOKEN) throw new Error("TELEGRAM_BOT_TOKEN not set"); +function telegramBaseUrl(): string { + const token = Bun.env.TELEGRAM_BOT_TOKEN; + if (!token) throw new Error("TELEGRAM_BOT_TOKEN not set"); + return `https://api.telegram.org/bot${token}`; +} + +export type TelegramUser = { + id: number; + is_bot?: boolean; + username?: string; + first_name: string; +}; -const BASE = `https://api.telegram.org/bot${TOKEN}`; +export type TelegramChat = { + id: number; + type?: "private" | "group" | "supergroup" | "channel"; + username?: string; + first_name?: string; + title?: string; +}; export type TelegramMessage = { message_id: number; - chat: { id: number; username?: string; first_name?: string }; + message_thread_id?: number; + from?: TelegramUser; + chat: TelegramChat; text?: string; date: number; + reply_to_message?: TelegramMessage; }; export type TelegramUpdate = { @@ -20,23 +39,48 @@ export type TelegramUpdate = { }; export async function getUpdates(offset = 0, limit = 100): Promise { - const url = `${BASE}/getUpdates?offset=${offset}&limit=${limit}`; + const url = `${telegramBaseUrl()}/getUpdates?offset=${offset}&limit=${limit}`; const res = await fetch(url); const data = (await res.json()) as { ok: boolean; result: TelegramUpdate[] }; if (!data.ok) throw new Error("getUpdates failed"); return data.result; } -export async function sendMessage(chatId: number, text: string, opts?: { parseMode?: "Markdown" | "HTML" }): Promise { +export type SendMessageOptions = { + parseMode?: "Markdown" | "MarkdownV2" | "HTML"; + messageThreadId?: number; + replyToMessageId?: number; + disableNotification?: boolean; +}; + +export async function sendMessage( + chatId: number | string, + text: string, + opts?: SendMessageOptions, +): Promise { const body: Record = { chat_id: chatId, text }; if (opts?.parseMode) body.parse_mode = opts.parseMode; - const res = await fetch(`${BASE}/sendMessage`, { + if (opts?.messageThreadId !== undefined) body.message_thread_id = opts.messageThreadId; + if (opts?.replyToMessageId !== undefined) { + body.reply_parameters = { message_id: opts.replyToMessageId }; + } + if (opts?.disableNotification !== undefined) { + body.disable_notification = opts.disableNotification; + } + const res = await fetch(`${telegramBaseUrl()}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - const data = (await res.json()) as { ok: boolean }; - if (!data.ok) console.error("sendMessage failed", { chatId, text: text.slice(0, 80) }); + const data = (await res.json()) as { + ok: boolean; + result?: TelegramMessage; + description?: string; + }; + if (!data.ok || !data.result) { + throw new Error(`sendMessage failed: ${data.description ?? `HTTP ${res.status}`}`); + } + return data.result; } export async function sendPhoto(chatId: number, photoPath: string, caption?: string): Promise { @@ -45,26 +89,26 @@ export async function sendPhoto(chatId: number, photoPath: string, caption?: str form.append("chat_id", String(chatId)); form.append("photo", new Blob([await file.arrayBuffer()], { type: file.type || "image/png" })); if (caption) form.append("caption", caption); - const res = await fetch(`${BASE}/sendPhoto`, { method: "POST", body: form }); + const res = await fetch(`${telegramBaseUrl()}/sendPhoto`, { method: "POST", body: form }); const data = (await res.json()) as { ok: boolean }; if (!data.ok) console.error("sendPhoto failed", { chatId, photoPath }); } export async function setWebhook(url: string): Promise { - const res = await fetch(`${BASE}/setWebhook?url=${encodeURIComponent(url)}`); + const res = await fetch(`${telegramBaseUrl()}/setWebhook?url=${encodeURIComponent(url)}`); const data = (await res.json()) as { ok: boolean }; if (!data.ok) console.error("setWebhook failed"); } export async function deleteWebhook(): Promise { - await fetch(`${BASE}/deleteWebhook`); + await fetch(`${telegramBaseUrl()}/deleteWebhook`); } // ── Chat / profile management ────────────────────────────────── /** Get basic bot info: id, username, name. */ export async function getMe(): Promise<{ id: number; username: string; first_name: string }> { - const res = await fetch(`${BASE}/getMe`); + const res = await fetch(`${telegramBaseUrl()}/getMe`); const data = (await res.json()) as { ok: boolean; result: { id: number; username: string; first_name: string } }; if (!data.ok) throw new Error("getMe failed"); return data.result; @@ -76,7 +120,7 @@ export async function setChatPhoto(chatId: number, photoPath: string): Promise { const body: Record = { chat_id: chatId }; if (description) body.description = description; - const res = await fetch(`${BASE}/setChatDescription`, { + const res = await fetch(`${telegramBaseUrl()}/setChatDescription`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -99,11 +143,11 @@ export async function createForumTopic( chatId: number, name: string, iconColor = 0x6FB9F0, - iconCustomEmojiId?: string, + iconCustomEmojiId?: string, // brand-ok — Telegram API wire value passed through unchanged ): Promise { const body: Record = { chat_id: chatId, name, icon_color: iconColor }; if (iconCustomEmojiId) body.icon_custom_emoji_id = iconCustomEmojiId; - const res = await fetch(`${BASE}/createForumTopic`, { + const res = await fetch(`${telegramBaseUrl()}/createForumTopic`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -115,7 +159,7 @@ export async function createForumTopic( /** Set bot commands scoped to a specific chat. */ export async function setMyCommands(chatId: number, commands: Array<{ command: string; description: string }>): Promise { - const res = await fetch(`${BASE}/setMyCommands`, { + const res = await fetch(`${telegramBaseUrl()}/setMyCommands`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commands, scope: { type: "chat", chat_id: chatId } }), @@ -126,7 +170,7 @@ export async function setMyCommands(chatId: number, commands: Array<{ command: s /** Set bot's display name. */ export async function setMyName(name: string): Promise { - const res = await fetch(`${BASE}/setMyName`, { + const res = await fetch(`${telegramBaseUrl()}/setMyName`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), @@ -137,7 +181,7 @@ export async function setMyName(name: string): Promise { /** Set bot's description shown in the profile. */ export async function setMyDescription(description: string): Promise { - const res = await fetch(`${BASE}/setMyDescription`, { + const res = await fetch(`${telegramBaseUrl()}/setMyDescription`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description }), @@ -148,7 +192,7 @@ export async function setMyDescription(description: string): Promise { /** Get member count for a chat. */ export async function getChatMemberCount(chatId: number): Promise { - const res = await fetch(`${BASE}/getChatMemberCount?chat_id=${chatId}`); + const res = await fetch(`${telegramBaseUrl()}/getChatMemberCount?chat_id=${chatId}`); const data = (await res.json()) as { ok: boolean; result: number }; if (!data.ok) throw new Error("getChatMemberCount failed"); return data.result; @@ -156,7 +200,7 @@ export async function getChatMemberCount(chatId: number): Promise { /** Get chat administrators. */ export async function getChatAdministrators(chatId: number): Promise> { - const res = await fetch(`${BASE}/getChatAdministrators?chat_id=${chatId}`); + const res = await fetch(`${telegramBaseUrl()}/getChatAdministrators?chat_id=${chatId}`); const data = (await res.json()) as { ok: boolean; result: Array<{ user: { id: number; username?: string; first_name: string } }> }; if (!data.ok) throw new Error("getChatAdministrators failed"); return data.result; diff --git a/src/telegram/authorization-commands.ts b/src/telegram/authorization-commands.ts new file mode 100644 index 0000000..a9b1e02 --- /dev/null +++ b/src/telegram/authorization-commands.ts @@ -0,0 +1,298 @@ +import type { Database } from "bun:sqlite"; +import type { + AuthorizationPolicy, + AuthorizationRequest, + AuthorizationRequestId, + TelegramUserId, +} from "../partner/authorization/domain.ts"; +import { + asAuthorizationRequestId, + asOutId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + asTelegramUserId, +} from "../partner/authorization/domain.ts"; +import { + asAuthorizationReceiptDedupeKey, + enqueueAuthorizationReceipt, + type AuthorizationReceiptOutboxId, +} from "../partner/authorization/outbox.ts"; +import { + approveAuthorizationRequest, + getCurrentAuthorizationPolicy, + getAuthorizationRequest, + revokeOutFromTelegram, +} from "../partner/authorization/service.ts"; +import { parseTelegramCommand } from "./commands.ts"; +import type { TelegramMessage } from "./api.ts"; + +export type CurrentAuthorizationPolicyResolver = ( + request: AuthorizationRequest, +) => AuthorizationPolicy | null; + +export interface AuthorizationCommandDependencies { + db: Database; + botUsername?: string; + resolveCurrentPolicy?: CurrentAuthorizationPolicyResolver; +} + +export type AuthorizationCommandResult = + | { handled: false } + | { + handled: true; + ok: boolean; + code: string; + receiptOutboxId: AuthorizationReceiptOutboxId | null; + }; + +type CommandReceipt = { + ok: boolean; + code: string; + text: string; +}; + +function parseRequestId(value: string | undefined): AuthorizationRequestId | null { + if (value === undefined || !/^[1-9]\d*$/.test(value)) return null; + const numeric = Number(value); + try { + return asAuthorizationRequestId(numeric); + } catch { + return null; + } +} + +function resultReceipt(code: string, reason: string, context: string): CommandReceipt { + return { + ok: false, + code, + text: + `⛔ Authorization command denied\n` + + `${Bun.escapeHTML(context)}\n` + + `Code: ${Bun.escapeHTML(code)}\n` + + `Reason: ${Bun.escapeHTML(reason)}`, + }; +} + +/** Route only authorization commands. All responses are durably queued, never sent inline. */ +export function handleAuthorizationCommand( + dependencies: AuthorizationCommandDependencies, + message: TelegramMessage, + nowMs = Date.now(), +): AuthorizationCommandResult { + const parsed = message.text === undefined ? null : parseTelegramCommand(message.text); + if (parsed === null || (parsed.name !== "approve" && parsed.name !== "revoke_out")) { + return { handled: false }; + } + if ( + parsed.botUsername !== null && + (dependencies.botUsername === undefined || + parsed.botUsername !== dependencies.botUsername.toLowerCase()) + ) { + return { handled: false }; + } + + let chatId; + let topicId; + let commandMessageId; + try { + chatId = asTelegramChatId(String(message.chat.id)); + topicId = + message.message_thread_id === undefined + ? null + : asTelegramTopicId(String(message.message_thread_id)); + commandMessageId = asTelegramMessageId(String(message.message_id)); + } catch { + return { + handled: true, + ok: false, + code: "INVALID_TELEGRAM_MESSAGE", + receiptOutboxId: null, + }; + } + + const dedupeKey = asAuthorizationReceiptDedupeKey( + `authorization-command:${chatId}:${commandMessageId}`, + ); + + try { + const transaction = dependencies.db.transaction((): AuthorizationCommandResult => { + let receipt: CommandReceipt; + + const senderId = telegramUserId(message); + if (senderId === null) { + receipt = resultReceipt( + "SENDER_ID_REQUIRED", + "Authorization commands require a numeric Telegram user identity", + `Command: /${parsed.name}`, + ); + } else if (parsed.name === "approve") { + receipt = approveReceipt(dependencies, parsed.args, { + chatId, + topicId, + commandMessageId, + userId: senderId, + nowMs, + }); + } else { + receipt = revokeReceipt(dependencies, parsed.args, { + chatId, + topicId, + commandMessageId, + userId: senderId, + nowMs, + }); + } + + const queued = enqueueAuthorizationReceipt( + dependencies.db, + { + dedupeKey, + telegramChatId: chatId, + telegramTopicId: topicId, + payload: { + text: receipt.text, + parseMode: "HTML", + replyToMessageId: commandMessageId, + }, + }, + nowMs, + ); + return { + handled: true, + ok: receipt.ok, + code: receipt.code, + receiptOutboxId: queued.item.id, + }; + }); + return transaction.immediate(); + } catch { + return { + handled: true, + ok: false, + code: "COMMAND_DATABASE_ERROR", + receiptOutboxId: null, + }; + } +} + +type TelegramCommandProvenance = { + chatId: ReturnType; + topicId: ReturnType | null; + commandMessageId: ReturnType; + userId: TelegramUserId; + nowMs: number; +}; + +function approveReceipt( + dependencies: AuthorizationCommandDependencies, + args: readonly string[], + provenance: TelegramCommandProvenance, +): CommandReceipt { + const requestId = args.length === 1 ? parseRequestId(args[0]) : null; + if (requestId === null) { + return resultReceipt( + "INVALID_APPROVE_COMMAND", + "Usage: /approve ", + "Command: /approve", + ); + } + + const request = getAuthorizationRequest(dependencies.db, requestId); + if (request === null) { + return resultReceipt( + "REQUEST_NOT_FOUND", + "Authorization request not found", + `Request: ${requestId}`, + ); + } + const currentPolicy = + dependencies.resolveCurrentPolicy?.(request) ?? + (dependencies.resolveCurrentPolicy === undefined + ? getCurrentAuthorizationPolicy(dependencies.db, request) + : null); + if (currentPolicy === null) { + return resultReceipt( + "CURRENT_POLICY_UNAVAILABLE", + "Current authorization policy could not be resolved", + `Request: ${requestId}`, + ); + } + + const result = approveAuthorizationRequest(dependencies.db, { + requestId, + currentPolicy, + telegramChatId: provenance.chatId, + telegramTopicId: provenance.topicId, + telegramMessageId: provenance.commandMessageId, + approvingUserId: provenance.userId, + nowMs: provenance.nowMs, + }); + if (!result.ok) return resultReceipt(result.code, result.reason, `Request: ${requestId}`); + + return { + ok: true, + code: result.code, + text: + `✅ Authorization active\n` + + `Request: ${requestId}\n` + + `Partner: ${Bun.escapeHTML(result.authorization.partnerCode)}\n` + + `Out: ${Bun.escapeHTML(result.authorization.outId)}\n` + + `Skin: ${Bun.escapeHTML(result.authorization.skin)}\n` + + `Scope: ${result.authorization.scope}`, + }; +} + +function revokeReceipt( + dependencies: AuthorizationCommandDependencies, + args: readonly string[], + provenance: TelegramCommandProvenance, +): CommandReceipt { + if (args.length !== 1) { + return resultReceipt( + "INVALID_REVOKE_COMMAND", + "Usage: /revoke_out ", + "Command: /revoke_out", + ); + } + + let outId; + try { + outId = asOutId(args[0]!); + } catch (error) { + return resultReceipt( + "INVALID_REVOKE_COMMAND", + error instanceof Error ? error.message : "Invalid revoke command", + "Command: /revoke_out", + ); + } + + const result = revokeOutFromTelegram(dependencies.db, { + outId, + telegramChatId: provenance.chatId, + telegramTopicId: provenance.topicId, + telegramMessageId: provenance.commandMessageId, + revokingUserId: provenance.userId, + nowMs: provenance.nowMs, + }); + if (!result.ok) return resultReceipt(result.code, result.reason, `Out: ${outId}`); + + return { + ok: true, + code: result.code, + text: + `🛑 Out authorization revoked\n` + + `Partner: ${Bun.escapeHTML(result.partnerCode)}\n` + + `Out: ${Bun.escapeHTML(result.outId)}\n` + + `Grants revoked: ${result.revokedCount}`, + }; +} + +function telegramUserId(message: TelegramMessage): TelegramUserId | null { + if (message.from === undefined) return null; + try { + return asTelegramUserId(String(message.from.id)); + } catch { + return null; + } +} diff --git a/src/telegram/authorization-outbox-worker.ts b/src/telegram/authorization-outbox-worker.ts new file mode 100644 index 0000000..b3ea9ff --- /dev/null +++ b/src/telegram/authorization-outbox-worker.ts @@ -0,0 +1,91 @@ +import type { Database } from "bun:sqlite"; +import { + claimDueAuthorizationReceipts, + markAuthorizationReceiptFailed, + markAuthorizationReceiptSent, + type AuthorizationReceiptLeaseOwner, +} from "../partner/authorization/outbox.ts"; +import type { AuthorizationMessageSender } from "./authorization-requests.ts"; + +export interface DeliverAuthorizationReceiptsInput { + nowMs: number; + leaseOwner: AuthorizationReceiptLeaseOwner; + send: AuthorizationMessageSender; + limit?: number; + leaseDurationMs?: number; + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + clock?: () => number; +} + +export interface DeliverAuthorizationReceiptsResult { + claimed: number; + sent: number; + failed: number; + dead: number; +} + +/** Deliver one bounded outbox batch. Telegram failures remain durable for retry. */ +export async function deliverAuthorizationReceiptBatch( + db: Database, + input: DeliverAuthorizationReceiptsInput, +): Promise { + const claimed = claimDueAuthorizationReceipts(db, { + nowMs: input.nowMs, + leaseOwner: input.leaseOwner, + leaseDurationMs: input.leaseDurationMs ?? 30_000, + limit: input.limit, + }); + const result: DeliverAuthorizationReceiptsResult = { + claimed: claimed.length, + sent: 0, + failed: 0, + dead: 0, + }; + + for (const item of claimed) { + try { + const topic = numericTelegramId(item.telegramTopicId); + const reply = numericTelegramId(item.payload.replyToMessageId ?? null); + await input.send(item.telegramChatId, item.payload.text, { + parseMode: item.payload.parseMode, + ...(topic === null ? {} : { messageThreadId: topic }), + ...(reply === null ? {} : { replyToMessageId: reply }), + disableNotification: item.payload.disableNotification, + }); + const completedAtMs = input.clock?.() ?? Date.now(); + const sent = markAuthorizationReceiptSent(db, { + id: item.id, + leaseOwner: input.leaseOwner, + nowMs: completedAtMs, + }); + if (sent === null) throw new Error("authorization receipt delivery lease expired"); + result.sent += 1; + } catch (error) { + const failedAtMs = input.clock?.() ?? Date.now(); + const failed = markAuthorizationReceiptFailed(db, { + id: item.id, + leaseOwner: input.leaseOwner, + nowMs: failedAtMs, + error: error instanceof Error ? error.message : "Telegram receipt delivery failed", + maxAttempts: input.maxAttempts, + baseDelayMs: input.baseDelayMs, + maxDelayMs: input.maxDelayMs, + }); + result.failed += 1; + if (failed?.status === "dead") result.dead += 1; + } + } + + return result; +} + +function numericTelegramId(value: string | null): number | null { + if (value === null) return null; + const numeric = Number(value); + if (!Number.isSafeInteger(numeric) || numeric <= 0) { + throw new TypeError("Telegram message or topic ID exceeds the safe integer range"); + } + return numeric; +} diff --git a/src/telegram/authorization-requests.ts b/src/telegram/authorization-requests.ts new file mode 100644 index 0000000..3321391 --- /dev/null +++ b/src/telegram/authorization-requests.ts @@ -0,0 +1,181 @@ +import type { Database } from "bun:sqlite"; +import type { + AuthorizationPolicy, + AuthorizationRequest, + TelegramChatId, + TelegramTopicId, +} from "../partner/authorization/domain.ts"; +import { + asTelegramChatId, + asTelegramMessageId, +} from "../partner/authorization/domain.ts"; +import { computePolicyHash } from "../partner/authorization/hash.ts"; +import { + asAuthorizationReceiptDedupeKey, + enqueueAuthorizationReceipt, +} from "../partner/authorization/outbox.ts"; +import { createAuthorizationRequest } from "../partner/authorization/service.ts"; +import type { SendMessageOptions, TelegramMessage } from "./api.ts"; + +export type AuthorizationMessageSender = ( + chatId: number | string, + text: string, + options?: SendMessageOptions, +) => Promise; + +export interface PostAuthorizationRequestInput { + policy: AuthorizationPolicy; + telegramChatId: TelegramChatId; + telegramTopicId: TelegramTopicId | null; + nowMs: number; +} + +export type PostAuthorizationRequestResult = + | { ok: true; code: "REQUEST_POSTED"; request: AuthorizationRequest } + | { + ok: false; + code: + | "INVALID_INPUT" + | "TELEGRAM_SEND_FAILED" + | "TELEGRAM_RESPONSE_MISMATCH" + | "REQUEST_PERSIST_FAILED"; + reason: string; + }; + +export function formatAuthorizationRequest(policy: AuthorizationPolicy): string { + const hash = computePolicyHash(policy); + const validFrom = new Date(policy.validFromMs).toISOString(); + const expiry = + policy.expiresAtMs === null ? "No expiration" : new Date(policy.expiresAtMs).toISOString(); + return ( + `📋 Authorization Request\n` + + `Partner: ${Bun.escapeHTML(policy.partnerCode)}\n` + + `Out: ${Bun.escapeHTML(policy.outId)}\n` + + `Provider: ${Bun.escapeHTML(policy.provider)}\n` + + `Skin: ${Bun.escapeHTML(policy.skin)}\n` + + `Scope: ${policy.scope}\n` + + `Max stake: ${policy.maxStake} ${policy.currency} minor units\n` + + `Max win: ${policy.maxWin} ${policy.currency} minor units (${policy.maxWinBasis})\n` + + `Daily limit: ${policy.dailyLimit ?? "none"}\n` + + `Exposure limit: ${policy.exposureLimit ?? "none"}\n` + + `Valid from: ${validFrom}\n` + + `Expires: ${expiry}\n` + + `Hash: ${hash}\n\n` + + `The bot will reply with the numeric request ID required for approval.` + ); +} + +/** Post the immutable snapshot, persist its returned message identity, then queue instructions. */ +export async function postAuthorizationRequest( + db: Database, + input: PostAuthorizationRequestInput, + send: AuthorizationMessageSender, +): Promise { + if (!Number.isSafeInteger(input.nowMs) || input.nowMs < 0) { + return { ok: false, code: "INVALID_INPUT", reason: "nowMs must be an epoch-millisecond integer" }; + } + let topicNumber: number | null; + let formattedRequest: string; + try { + asTelegramChatId(input.telegramChatId); + topicNumber = telegramNumericId(input.telegramTopicId); + formattedRequest = formatAuthorizationRequest(input.policy); + if (input.policy.expiresAtMs !== null && input.policy.expiresAtMs <= input.nowMs) { + throw new TypeError("authorization policy is already expired"); + } + } catch (error) { + return { + ok: false, + code: "INVALID_INPUT", + reason: error instanceof Error ? error.message : "Invalid authorization request", + }; + } + let posted: TelegramMessage; + try { + posted = await send(input.telegramChatId, formattedRequest, { + parseMode: "HTML", + ...(topicNumber === null ? {} : { messageThreadId: topicNumber }), + }); + } catch (error) { + return { + ok: false, + code: "TELEGRAM_SEND_FAILED", + reason: error instanceof Error ? error.message : "Telegram request send failed", + }; + } + + if ( + String(posted.chat.id) !== input.telegramChatId || + (input.telegramTopicId === null + ? posted.message_thread_id !== undefined + : String(posted.message_thread_id) !== input.telegramTopicId) + ) { + return { + ok: false, + code: "TELEGRAM_RESPONSE_MISMATCH", + reason: "Telegram returned a different chat or topic for the request message", + }; + } + + let requestMessageId; + try { + requestMessageId = asTelegramMessageId(String(posted.message_id)); + } catch (error) { + return { + ok: false, + code: "TELEGRAM_RESPONSE_MISMATCH", + reason: error instanceof Error ? error.message : "Telegram returned an invalid message ID", + }; + } + + try { + const transaction = db.transaction((): PostAuthorizationRequestResult => { + const created = createAuthorizationRequest(db, { + policy: input.policy, + telegramChatId: input.telegramChatId, + telegramTopicId: input.telegramTopicId, + telegramMessageId: requestMessageId, + nowMs: input.nowMs, + }); + if (!created.ok) { + return { ok: false, code: "REQUEST_PERSIST_FAILED", reason: created.reason }; + } + + enqueueAuthorizationReceipt( + db, + { + dedupeKey: asAuthorizationReceiptDedupeKey( + `authorization-request:${created.request.id}:approval-instructions`, + ), + telegramChatId: input.telegramChatId, + telegramTopicId: input.telegramTopicId, + payload: { + text: + `Authorization request ${created.request.id} is pending.\n` + + `Approve with /approve ${created.request.id}.`, + parseMode: "HTML", + replyToMessageId: requestMessageId, + }, + }, + input.nowMs, + ); + return { ok: true, code: "REQUEST_POSTED", request: created.request }; + }); + return transaction.immediate(); + } catch (error) { + return { + ok: false, + code: "REQUEST_PERSIST_FAILED", + reason: error instanceof Error ? error.message : "Authorization request persistence failed", + }; + } +} + +function telegramNumericId(value: TelegramTopicId | null): number | null { + if (value === null) return null; + const numeric = Number(value); + if (!Number.isSafeInteger(numeric) || numeric <= 0) { + throw new TypeError("Telegram topic ID exceeds the safe integer range"); + } + return numeric; +} diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index bb811f4..b89595c 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -9,12 +9,30 @@ * /dashboard — send latest calibration chart image * /subscribe — add chat to weekly digest list * /unsubscribe— remove chat from digest list + * /approve — approve a pending partner authorization request + * /revoke_out — revoke all active grants for a permissioned out * /help — command reference */ -import { getUpdates, sendMessage, sendPhoto } from "./api.ts"; +import type { Database } from "bun:sqlite"; +import { openEventStore } from "../institutions/event-store/open-db.ts"; +import { + asAuthorizationReceiptLeaseOwner, +} from "../partner/authorization/outbox.ts"; +import { migrateAuthorizationSchema } from "../partner/authorization/sql.ts"; import { addSubscriber, removeSubscriber, listSubscribers } from "./subscribers.ts"; import { joinPath } from "../research/paths.ts"; -import { getChatMemberCount, getChatAdministrators } from "./api.ts"; +import { handleAuthorizationCommand } from "./authorization-commands.ts"; +import { deliverAuthorizationReceiptBatch } from "./authorization-outbox-worker.ts"; +import { parseTelegramCommand } from "./commands.ts"; +import { + getChatAdministrators, + getChatMemberCount, + getMe, + getUpdates, + sendMessage, + sendPhoto, + type TelegramMessage, +} from "./api.ts"; const DASHBOARD_DIR = joinPath(import.meta.dir, "../../research/calibration-dashboard"); const DASHBOARD_DATA = joinPath(DASHBOARD_DIR, "dashboard-data.json"); @@ -36,16 +54,43 @@ function fmtStatusLine(label: string, value: unknown): string { return `${label.padEnd(18)} ${v}`; } -async function handleCommand(chatId: number, text: string, username?: string, firstName?: string) { - const cmd = text.trim().toLowerCase(); +export interface TelegramBotCommandContext { + authorizationDb: Database; + botUsername: string; +} + +export async function handleCommand( + message: TelegramMessage, + context: TelegramBotCommandContext, +): Promise { + const parsed = message.text === undefined ? null : parseTelegramCommand(message.text); + if (parsed === null) return; + if (parsed.botUsername !== null && parsed.botUsername !== context.botUsername.toLowerCase()) { + return; + } + const authorization = handleAuthorizationCommand( + { + db: context.authorizationDb, + botUsername: context.botUsername, + }, + message, + ); + if (authorization.handled) return; + + const cmd = parsed.name; + const chatId = message.chat.id; + const username = message.from?.username ?? message.chat.username; + const firstName = message.from?.first_name ?? message.chat.first_name; - if (cmd === "/start") { + if (cmd === "start") { await sendMessage(chatId, `🎯 Kalshi Bot Research Agent\n\n` + `Commands:\n` + `/status — program metrics\n` + `/dashboard — calibration charts\n` + `/members — channel member count & admins\n` + + `/approve ID — approve partner authorization\n` + + `/revoke_out OUT — revoke out authorization\n` + `/subscribe — weekly digest\n` + `/unsubscribe— stop digest\n` + `/help — this help`, @@ -53,20 +98,22 @@ async function handleCommand(chatId: number, text: string, username?: string, fi return; } - if (cmd === "/help") { + if (cmd === "help") { await sendMessage(chatId, `*Kalshi Bot Commands*\n\n` + `*/status* — live program metrics from shadow logs\n` + `*/dashboard* — latest seaborn calibration charts\n` + `*/subscribe* — add this chat to weekly Sunday digest\n` + `*/unsubscribe* — remove from digest\n\n` + + `*/approve ID* — approve a permissioned partner request\n` + + `*/revoke_out OUT* — revoke every active grant for an out\n\n` + `Dashboard refreshes every Sunday at 07:17 UTC.`, { parseMode: "Markdown" }, ); return; } - if (cmd === "/subscribe") { + if (cmd === "subscribe") { const added = await addSubscriber({ chatId, username, @@ -77,13 +124,13 @@ async function handleCommand(chatId: number, text: string, username?: string, fi return; } - if (cmd === "/unsubscribe") { + if (cmd === "unsubscribe") { const removed = await removeSubscriber(chatId); await sendMessage(chatId, removed ? "✅ Unsubscribed from digest." : "ℹ️ Not currently subscribed."); return; } - if (cmd === "/status") { + if (cmd === "status") { const dashboard = await loadDashboard(); if (!dashboard) { await sendMessage(chatId, "❌ No dashboard data found. Run `bun run dashboard:generate` first."); @@ -105,7 +152,7 @@ async function handleCommand(chatId: number, text: string, username?: string, fi return; } - if (cmd === "/dashboard") { + if (cmd === "dashboard") { const dashboard = await loadDashboard(); if (!dashboard) { await sendMessage(chatId, "❌ No dashboard data. Run `bun run dashboard:generate` first."); @@ -120,7 +167,7 @@ async function handleCommand(chatId: number, text: string, username?: string, fi return; } - if (cmd === "/members") { + if (cmd === "members") { try { const count = await getChatMemberCount(chatId); const admins = await getChatAdministrators(chatId); @@ -142,7 +189,17 @@ async function handleCommand(chatId: number, text: string, username?: string, fi } async function pollLoop() { - console.log("🤖 Kalshi Telegram Bot started — long-polling"); + const authorizationDb = openEventStore(); + migrateAuthorizationSchema(authorizationDb); + const bot = await getMe(); + const commandContext: TelegramBotCommandContext = { + authorizationDb, + botUsername: bot.username, + }; + const leaseOwner = asAuthorizationReceiptLeaseOwner( + `telegram-authorization-bot-${process.pid}`, + ); + console.log(`🤖 Kalshi Telegram Bot @${bot.username} started — long-polling`); let offset = 0; // eslint-disable-next-line no-constant-condition while (true) { @@ -153,13 +210,15 @@ async function pollLoop() { const msg = u.message; if (!msg || !msg.text) continue; if (!msg.text.startsWith("/")) continue; - await handleCommand( - msg.chat.id, - msg.text, - msg.chat.username, - msg.chat.first_name, - ); + await handleCommand(msg, commandContext); } + await deliverAuthorizationReceiptBatch(authorizationDb, { + nowMs: Date.now(), + leaseOwner, + send: sendMessage, + limit: 25, + clock: Date.now, + }); } catch (err) { console.error("Poll error:", err); await Bun.sleep(5000); diff --git a/src/telegram/commands.ts b/src/telegram/commands.ts new file mode 100644 index 0000000..489d231 --- /dev/null +++ b/src/telegram/commands.ts @@ -0,0 +1,28 @@ +export type ParsedTelegramCommand = Readonly<{ + name: string; + botUsername: string | null; + args: readonly string[]; +}>; + +/** Parse a Bot API command while preserving argument case and punctuation. */ +export function parseTelegramCommand(text: string): ParsedTelegramCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) return null; + + const [head, ...args] = trimmed.split(/\s+/); + const token = head?.slice(1) ?? ""; + const separator = token.indexOf("@"); + const rawName = separator === -1 ? token : token.slice(0, separator); + const rawBotUsername = separator === -1 ? null : token.slice(separator + 1); + + if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(rawName)) return null; + if (rawBotUsername !== null && !/^[A-Za-z][A-Za-z0-9_]{2,31}$/.test(rawBotUsername)) { + return null; + } + + return Object.freeze({ + name: rawName.toLowerCase(), + botUsername: rawBotUsername?.toLowerCase() ?? null, + args: Object.freeze(args), + }); +} diff --git a/tests/partner/authorization/hash.test.ts b/tests/partner/authorization/hash.test.ts index afab92e..143b8a4 100644 --- a/tests/partner/authorization/hash.test.ts +++ b/tests/partner/authorization/hash.test.ts @@ -5,6 +5,10 @@ import { asPartnerCode, asProviderId, asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + asTelegramUserId, canonicalPolicySnapshot, computePolicyHash, type AuthorizationPolicy, @@ -83,4 +87,14 @@ describe("authorization policy hash", () => { computePolicyHash(policy({ expiresAtMs: 1_700_000_000_000 })), ).toThrow("later than validFromMs"); }); + + test("allows negative chat IDs but requires positive actor and message IDs", () => { + expect(String(asTelegramChatId("-100123"))).toBe("-100123"); + expect(() => asTelegramChatId("0")).toThrow("numeric Telegram ID"); + expect(() => asTelegramChatId("-0")).toThrow("numeric Telegram ID"); + for (const parse of [asTelegramTopicId, asTelegramMessageId, asTelegramUserId]) { + expect(() => parse("-1")).toThrow("positive numeric"); + expect(() => parse("0")).toThrow("positive numeric"); + } + }); }); diff --git a/tests/partner/authorization/outbox.test.ts b/tests/partner/authorization/outbox.test.ts new file mode 100644 index 0000000..2a0f666 --- /dev/null +++ b/tests/partner/authorization/outbox.test.ts @@ -0,0 +1,277 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { + AuthorizationReceiptDedupeConflictError, + asAuthorizationReceiptDedupeKey, + asAuthorizationReceiptLeaseOwner, + claimDueAuthorizationReceipts, + enqueueAuthorizationReceipt, + getAuthorizationReceiptOutboxItem, + markAuthorizationReceiptFailed, + markAuthorizationReceiptSent, +} from "../../../src/partner/authorization/outbox.ts"; +import { + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, +} from "../../../src/partner/authorization/domain.ts"; +import { migrateAuthorizationSchema } from "../../../src/partner/authorization/sql.ts"; + +const NOW_MS = 1_700_000_000_000; +const WORKER_A = asAuthorizationReceiptLeaseOwner("receipt-worker-a"); +const WORKER_B = asAuthorizationReceiptLeaseOwner("receipt-worker-b"); + +function openDb(): Database { + const db = new Database(":memory:"); + migrateAuthorizationSchema(db, NOW_MS); + return db; +} + +function enqueue(db: Database, suffix = "approved", availableAtMs?: number) { + return enqueueAuthorizationReceipt( + db, + { + dedupeKey: asAuthorizationReceiptDedupeKey(`authorization:request-1:${suffix}`), + telegramChatId: asTelegramChatId("-100123"), + telegramTopicId: asTelegramTopicId("42"), + payload: { + text: `Authorization request 1 ${suffix}`, + parseMode: "HTML", + disableNotification: false, + replyToMessageId: asTelegramMessageId("99"), + }, + availableAtMs, + }, + NOW_MS, + ); +} + +describe("authorization receipt outbox", () => { + test("enqueues idempotently and rejects a conflicting dedupe key", () => { + const db = openDb(); + const first = enqueue(db); + const replay = enqueueAuthorizationReceipt( + db, + { + dedupeKey: first.item.dedupeKey, + telegramChatId: first.item.telegramChatId, + telegramTopicId: first.item.telegramTopicId, + payload: first.item.payload, + }, + NOW_MS + 5_000, + ); + + expect(first.created).toBeTrue(); + expect(replay.created).toBeFalse(); + expect(replay.item.id).toBe(first.item.id); + expect(replay.item.availableAtMs).toBe(NOW_MS); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_receipt_outbox").get(), + ).toEqual({ count: 1 }); + + expect(() => + enqueueAuthorizationReceipt( + db, + { + dedupeKey: first.item.dedupeKey, + telegramChatId: first.item.telegramChatId, + telegramTopicId: first.item.telegramTopicId, + payload: { text: "different receipt" }, + }, + NOW_MS + 10_000, + ), + ).toThrow(AuthorizationReceiptDedupeConflictError); + db.close(); + }); + + test("claims only due rows and protects them with an expiring lease", () => { + const db = openDb(); + const due = enqueue(db, "due").item; + enqueue(db, "future", NOW_MS + 10_000); + + const firstClaim = claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS, + leaseOwner: WORKER_A, + leaseDurationMs: 5_000, + limit: 10, + }); + expect(firstClaim).toHaveLength(1); + expect(firstClaim[0]).toMatchObject({ + id: due.id, + attempts: 1, + leaseOwner: WORKER_A, + leaseExpiresAtMs: NOW_MS + 5_000, + }); + + expect( + claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 4_999, + leaseOwner: WORKER_B, + leaseDurationMs: 5_000, + }), + ).toEqual([]); + + const reclaimed = claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 5_000, + leaseOwner: WORKER_B, + leaseDurationMs: 5_000, + }); + expect(reclaimed).toHaveLength(1); + expect(reclaimed[0]).toMatchObject({ attempts: 2, leaseOwner: WORKER_B }); + db.close(); + }); + + test("marks sent only for the owner of an unexpired lease", () => { + const db = openDb(); + const queued = enqueue(db).item; + claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS, + leaseOwner: WORKER_A, + leaseDurationMs: 1_000, + }); + + expect( + markAuthorizationReceiptSent(db, { + id: queued.id, + leaseOwner: WORKER_B, + nowMs: NOW_MS + 1, + }), + ).toBeNull(); + + const sent = markAuthorizationReceiptSent(db, { + id: queued.id, + leaseOwner: WORKER_A, + nowMs: NOW_MS + 1, + }); + expect(sent).toMatchObject({ + status: "sent", + attempts: 1, + sentAtMs: NOW_MS + 1, + leaseOwner: null, + leaseExpiresAtMs: null, + lastError: null, + }); + expect( + claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 2_000, + leaseOwner: WORKER_B, + leaseDurationMs: 1_000, + }), + ).toEqual([]); + db.close(); + }); + + test("retries with bounded exponential backoff and dead-letters at the threshold", () => { + const db = openDb(); + const queued = enqueue(db).item; + + const [firstClaim] = claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS, + leaseOwner: WORKER_A, + leaseDurationMs: 1_000, + }); + const firstFailure = markAuthorizationReceiptFailed(db, { + id: firstClaim.id, + leaseOwner: WORKER_A, + nowMs: NOW_MS + 1, + error: "temporary failure", + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 150, + }); + expect(firstFailure).toMatchObject({ + status: "pending", + attempts: 1, + availableAtMs: NOW_MS + 101, + lastError: "temporary failure", + }); + + expect( + claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 100, + leaseOwner: WORKER_B, + leaseDurationMs: 1_000, + }), + ).toEqual([]); + + const [secondClaim] = claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 101, + leaseOwner: WORKER_B, + leaseDurationMs: 1_000, + }); + expect(secondClaim.attempts).toBe(2); + const secondFailure = markAuthorizationReceiptFailed(db, { + id: queued.id, + leaseOwner: WORKER_B, + nowMs: NOW_MS + 102, + error: "still unavailable", + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 150, + }); + expect(secondFailure).toMatchObject({ + status: "pending", + attempts: 2, + availableAtMs: NOW_MS + 252, + }); + + const [thirdClaim] = claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 252, + leaseOwner: WORKER_A, + leaseDurationMs: 1_000, + }); + expect(thirdClaim.attempts).toBe(3); + const dead = markAuthorizationReceiptFailed(db, { + id: queued.id, + leaseOwner: WORKER_A, + nowMs: NOW_MS + 253, + error: "permanent failure", + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 150, + }); + expect(dead).toMatchObject({ + status: "dead", + attempts: 3, + lastError: "permanent failure", + leaseOwner: null, + leaseExpiresAtMs: null, + }); + expect( + claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 10_000, + leaseOwner: WORKER_B, + leaseDurationMs: 1_000, + }), + ).toEqual([]); + expect(getAuthorizationReceiptOutboxItem(db, queued.id)?.status).toBe("dead"); + db.close(); + }); + + test("rejects stale completion and invalid millisecond boundaries", () => { + const db = openDb(); + const queued = enqueue(db).item; + claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS, + leaseOwner: WORKER_A, + leaseDurationMs: 1, + }); + + expect( + markAuthorizationReceiptFailed(db, { + id: queued.id, + leaseOwner: WORKER_A, + nowMs: NOW_MS + 1, + error: "too late", + }), + ).toBeNull(); + expect(() => + claimDueAuthorizationReceipts(db, { + nowMs: NOW_MS + 0.5, + leaseOwner: WORKER_A, + leaseDurationMs: 100, + }), + ).toThrow("epoch-millisecond integer"); + db.close(); + }); +}); diff --git a/tests/partner/authorization/service.test.ts b/tests/partner/authorization/service.test.ts new file mode 100644 index 0000000..a142679 --- /dev/null +++ b/tests/partner/authorization/service.test.ts @@ -0,0 +1,498 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import type { AuthorizationPolicy } from "../../../src/partner/authorization/domain.ts"; +import { + asAuthorizationRequestId, + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + asTelegramUserId, +} from "../../../src/partner/authorization/domain.ts"; +import { computePolicyHash } from "../../../src/partner/authorization/hash.ts"; +import { + approveAuthorizationRequest, + createAuthorizationRequest, + revokeAuthorizations, + revokeOutFromTelegram, +} from "../../../src/partner/authorization/service.ts"; +import { migrateAuthorizationSchema } from "../../../src/partner/authorization/sql.ts"; + +const NOW_MS = 1_700_000_000_000; +const CHAT_ID = asTelegramChatId("-123456"); +const TOPIC_ID = asTelegramTopicId("42"); +const REQUEST_MESSAGE_ID = asTelegramMessageId("100"); +const APPROVAL_MESSAGE_ID = asTelegramMessageId("101"); +const APPROVER_ID = asTelegramUserId("789"); + +function policy(overrides: Partial = {}): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("TEST"), + outId: asOutId("out-TEST-1"), + provider: asProviderId("test-provider"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + dailyLimit: 1_000_000, + exposureLimit: 500_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 1_000, + expiresAtMs: NOW_MS + 60_000, + ...overrides, + }; +} + +function database(): Database { + const db = new Database(":memory:"); + migrateAuthorizationSchema(db, NOW_MS); + return db; +} + +function allowApprover( + db: Database, + approvedPolicy: AuthorizationPolicy, + userId = APPROVER_ID, + partnerWide = false, +): void { + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id, created_at_ms + ) VALUES ($partnerCode, $outId, $telegramUserId, $nowMs)`, + ).run({ + $partnerCode: approvedPolicy.partnerCode, + $outId: partnerWide ? null : approvedPolicy.outId, + $telegramUserId: userId, + $nowMs: NOW_MS, + }); +} + +function createRequest(db: Database, requestedPolicy = policy()) { + const result = createAuthorizationRequest(db, { + policy: requestedPolicy, + telegramChatId: CHAT_ID, + telegramTopicId: TOPIC_ID, + telegramMessageId: REQUEST_MESSAGE_ID, + nowMs: NOW_MS, + }); + expect(result.ok).toBeTrue(); + if (!result.ok) throw new Error(result.reason); + return result.request; +} + +function approveRequest( + db: Database, + requestedPolicy: AuthorizationPolicy, + requestId: ReturnType, +) { + return approveAuthorizationRequest(db, { + requestId, + currentPolicy: requestedPolicy, + telegramChatId: CHAT_ID, + telegramTopicId: TOPIC_ID, + telegramMessageId: APPROVAL_MESSAGE_ID, + approvingUserId: APPROVER_ID, + nowMs: NOW_MS, + }); +} + +describe("authorization application service", () => { + test("persists a request with an immutable policy hash and integer timestamps", () => { + const db = database(); + try { + const requestedPolicy = policy(); + const result = createAuthorizationRequest(db, { + policy: requestedPolicy, + telegramChatId: CHAT_ID, + telegramTopicId: TOPIC_ID, + telegramMessageId: REQUEST_MESSAGE_ID, + nowMs: NOW_MS, + }); + + expect(result.ok).toBeTrue(); + if (!result.ok) return; + expect(result.code).toBe("REQUEST_CREATED"); + expect(result.request.requestHash).toBe(computePolicyHash(requestedPolicy)); + expect(result.request.createdAtMs).toBe(NOW_MS); + expect(result.request.updatedAtMs).toBe(NOW_MS); + + const row = db + .query( + `SELECT status, request_hash, created_at_ms, updated_at_ms + FROM account_authorization_requests WHERE id = $id`, + ) + .get({ $id: result.request.id }) as { + status: string; + request_hash: string; + created_at_ms: number; + updated_at_ms: number; + }; + expect(row).toEqual({ + status: "pending", + request_hash: computePolicyHash(requestedPolicy), + created_at_ms: NOW_MS, + updated_at_ms: NOW_MS, + }); + } finally { + db.close(); + } + }); + + test("rejects invalid creation time and already-expired policy without a write", () => { + const db = database(); + try { + const badTime = createAuthorizationRequest(db, { + policy: policy(), + telegramChatId: CHAT_ID, + telegramTopicId: null, + telegramMessageId: REQUEST_MESSAGE_ID, + nowMs: 1.5, + }); + expect(badTime).toMatchObject({ ok: false, code: "INVALID_INPUT" }); + + const expired = createAuthorizationRequest(db, { + policy: policy({ expiresAtMs: NOW_MS }), + telegramChatId: CHAT_ID, + telegramTopicId: null, + telegramMessageId: REQUEST_MESSAGE_ID, + nowMs: NOW_MS, + }); + expect(expired).toMatchObject({ ok: false, code: "POLICY_ALREADY_EXPIRED" }); + expect( + ( + db.query("SELECT count(*) AS count FROM account_authorization_requests").get() as { + count: number; + } + ).count, + ).toBe(0); + } finally { + db.close(); + } + }); + + test("approves an allowlisted exact-chat and exact-topic request atomically", () => { + const db = database(); + try { + const requestedPolicy = policy(); + allowApprover(db, requestedPolicy); + const request = createRequest(db, requestedPolicy); + const result = approveRequest(db, requestedPolicy, request.id); + + expect(result.ok).toBeTrue(); + if (!result.ok) return; + expect(result.code).toBe("AUTHORIZATION_APPROVED"); + expect(result.authorization.approvalHash).toBe(request.requestHash); + expect(result.authorization.telegramMessageId).toBe(APPROVAL_MESSAGE_ID); + expect(result.authorization.approvingUserId).toBe(APPROVER_ID); + + const persisted = db + .query( + `SELECT r.status, count(a.id) AS grants + FROM account_authorization_requests r + LEFT JOIN account_authorizations a ON a.request_id = r.id + WHERE r.id = $requestId + GROUP BY r.id`, + ) + .get({ $requestId: request.id }) as { status: string; grants: number }; + expect(persisted).toEqual({ status: "approved", grants: 1 }); + } finally { + db.close(); + } + }); + + test("honors explicit partner-wide approvers but rejects other partners and outs", () => { + const db = database(); + try { + const requestedPolicy = policy(); + allowApprover(db, requestedPolicy, APPROVER_ID, true); + const allowedRequest = createRequest(db, requestedPolicy); + expect(approveRequest(db, requestedPolicy, allowedRequest.id).ok).toBeTrue(); + + const otherPartnerPolicy = policy({ partnerCode: asPartnerCode("OTHER") }); + const otherPartnerRequest = createRequest(db, otherPartnerPolicy); + expect(approveRequest(db, otherPartnerPolicy, otherPartnerRequest.id)).toMatchObject({ + ok: false, + code: "APPROVER_NOT_ALLOWED", + }); + + const exactDb = database(); + try { + allowApprover(exactDb, requestedPolicy); + const otherOutPolicy = policy({ outId: asOutId("out-TEST-2") }); + const otherOutRequest = createRequest(exactDb, otherOutPolicy); + expect(approveRequest(exactDb, otherOutPolicy, otherOutRequest.id)).toMatchObject({ + ok: false, + code: "APPROVER_NOT_ALLOWED", + }); + } finally { + exactDb.close(); + } + } finally { + db.close(); + } + }); + + test("fails closed for chat, topic, approver, and policy mismatches", () => { + const cases = [ + { + code: "CHAT_MISMATCH", + override: { telegramChatId: asTelegramChatId("-999") }, + }, + { + code: "TOPIC_MISMATCH", + override: { telegramTopicId: asTelegramTopicId("99") }, + }, + { + code: "APPROVER_NOT_ALLOWED", + override: { approvingUserId: asTelegramUserId("999") }, + }, + { + code: "POLICY_HASH_MISMATCH", + override: { currentPolicy: policy({ maxStake: 50_001 }) }, + }, + ] as const; + + for (const testCase of cases) { + const db = database(); + try { + const requestedPolicy = policy(); + allowApprover(db, requestedPolicy); + const request = createRequest(db, requestedPolicy); + const result = approveAuthorizationRequest(db, { + requestId: request.id, + currentPolicy: requestedPolicy, + telegramChatId: CHAT_ID, + telegramTopicId: TOPIC_ID, + telegramMessageId: APPROVAL_MESSAGE_ID, + approvingUserId: APPROVER_ID, + nowMs: NOW_MS, + ...testCase.override, + }); + expect(result).toMatchObject({ ok: false, code: testCase.code }); + expect( + (db.query("SELECT count(*) AS count FROM account_authorizations").get() as { count: number }) + .count, + ).toBe(0); + } finally { + db.close(); + } + } + }); + + test("rejects a tampered persisted policy even when current policy is unchanged", () => { + const db = database(); + try { + const requestedPolicy = policy(); + allowApprover(db, requestedPolicy); + const request = createRequest(db, requestedPolicy); + db.query( + `UPDATE account_authorization_requests + SET requested_max_stake = requested_max_stake + 1 + WHERE id = $requestId`, + ).run({ $requestId: request.id }); + + expect(approveRequest(db, requestedPolicy, request.id)).toMatchObject({ + ok: false, + code: "POLICY_HASH_MISMATCH", + }); + expect( + (db.query("SELECT count(*) AS count FROM account_authorizations").get() as { count: number }) + .count, + ).toBe(0); + } finally { + db.close(); + } + }); + + test("makes repeated approval idempotent with exactly one grant", () => { + const db = database(); + try { + const requestedPolicy = policy(); + allowApprover(db, requestedPolicy); + const request = createRequest(db, requestedPolicy); + const first = approveRequest(db, requestedPolicy, request.id); + db.query("DELETE FROM account_authorization_approvers").run(); + const second = approveRequest(db, requestedPolicy, request.id); + + expect(first).toMatchObject({ ok: true, code: "AUTHORIZATION_APPROVED" }); + expect(second).toMatchObject({ ok: true, code: "ALREADY_APPROVED" }); + if (first.ok && second.ok) expect(second.authorization.id).toBe(first.authorization.id); + expect( + (db.query("SELECT count(*) AS count FROM account_authorizations").get() as { count: number }) + .count, + ).toBe(1); + } finally { + db.close(); + } + }); + + test("expires a pending request instead of creating a grant", () => { + const db = database(); + try { + const requestedPolicy = policy({ expiresAtMs: NOW_MS + 1 }); + allowApprover(db, requestedPolicy); + const request = createRequest(db, requestedPolicy); + const result = approveAuthorizationRequest(db, { + requestId: request.id, + currentPolicy: requestedPolicy, + telegramChatId: CHAT_ID, + telegramTopicId: TOPIC_ID, + telegramMessageId: APPROVAL_MESSAGE_ID, + approvingUserId: APPROVER_ID, + nowMs: NOW_MS + 1, + }); + + expect(result).toMatchObject({ ok: false, code: "REQUEST_EXPIRED" }); + expect( + ( + db.query("SELECT status FROM account_authorization_requests WHERE id = $id").get({ + $id: request.id, + }) as { status: string } + ).status, + ).toBe("expired"); + expect( + (db.query("SELECT count(*) AS count FROM account_authorizations").get() as { count: number }) + .count, + ).toBe(0); + } finally { + db.close(); + } + }); + + test("revokes all unrevoked grants for only the exact partner, out, and skin", () => { + const db = database(); + try { + const mainPolicy = policy(); + const otherSkinPolicy = policy({ skin: asSkinId("alternate") }); + allowApprover(db, mainPolicy); + + for (const requestedPolicy of [mainPolicy, mainPolicy, otherSkinPolicy]) { + const request = createRequest(db, requestedPolicy); + expect(approveRequest(db, requestedPolicy, request.id).ok).toBeTrue(); + } + + const result = revokeAuthorizations(db, { + partnerCode: mainPolicy.partnerCode, + outId: mainPolicy.outId, + skin: mainPolicy.skin, + nowMs: NOW_MS + 10, + }); + expect(result).toEqual({ + ok: true, + code: "AUTHORIZATIONS_REVOKED", + revokedCount: 2, + }); + expect( + ( + db + .query( + `SELECT count(*) AS count FROM account_authorizations + WHERE revoked_at_ms IS NULL`, + ) + .get() as { count: number } + ).count, + ).toBe(1); + + expect( + revokeAuthorizations(db, { + partnerCode: mainPolicy.partnerCode, + outId: mainPolicy.outId, + skin: mainPolicy.skin, + nowMs: NOW_MS + 11, + }), + ).toMatchObject({ ok: false, code: "NO_ACTIVE_AUTHORIZATIONS" }); + } finally { + db.close(); + } + }); + + test("Telegram revoke binds channel and approver, records evidence, and is replay-safe", () => { + const db = database(); + try { + const mainPolicy = policy(); + const alternatePolicy = policy({ skin: asSkinId("alternate") }); + allowApprover(db, mainPolicy); + for (const requestedPolicy of [mainPolicy, alternatePolicy]) { + const request = createRequest(db, requestedPolicy); + expect(approveRequest(db, requestedPolicy, request.id).ok).toBeTrue(); + } + + const input = { + outId: mainPolicy.outId, + telegramChatId: CHAT_ID, + telegramTopicId: TOPIC_ID, + telegramMessageId: asTelegramMessageId("222"), + revokingUserId: APPROVER_ID, + nowMs: NOW_MS + 20, + }; + expect(revokeOutFromTelegram(db, input)).toMatchObject({ + ok: true, + code: "OUT_AUTHORIZATIONS_REVOKED", + revokedCount: 2, + }); + expect(revokeOutFromTelegram(db, input)).toMatchObject({ + ok: true, + code: "OUT_AUTHORIZATIONS_REVOKED", + revokedCount: 2, + }); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_revocations").get(), + ).toEqual({ count: 2 }); + expect( + db + .query( + `SELECT telegram_chat_id, telegram_topic_id, telegram_message_id, + telegram_revoking_user_id + FROM account_authorization_revocations LIMIT 1`, + ) + .get(), + ).toEqual({ + telegram_chat_id: CHAT_ID, + telegram_topic_id: TOPIC_ID, + telegram_message_id: "222", + telegram_revoking_user_id: APPROVER_ID, + }); + } finally { + db.close(); + } + }); + + test("Telegram revoke rejects the wrong channel and a removed approver", () => { + for (const denied of ["channel", "approver"] as const) { + const db = database(); + try { + const requestedPolicy = policy(); + allowApprover(db, requestedPolicy); + const request = createRequest(db, requestedPolicy); + expect(approveRequest(db, requestedPolicy, request.id).ok).toBeTrue(); + if (denied === "approver") { + db.query("DELETE FROM account_authorization_approvers").run(); + } + + const result = revokeOutFromTelegram(db, { + outId: requestedPolicy.outId, + telegramChatId: + denied === "channel" ? asTelegramChatId("-999") : CHAT_ID, + telegramTopicId: TOPIC_ID, + telegramMessageId: asTelegramMessageId("223"), + revokingUserId: APPROVER_ID, + nowMs: NOW_MS + 20, + }); + expect(result).toMatchObject({ + ok: false, + code: denied === "channel" ? "CHANNEL_MISMATCH" : "APPROVER_NOT_ALLOWED", + }); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_revocations").get(), + ).toEqual({ count: 0 }); + } finally { + db.close(); + } + } + }); +}); diff --git a/tests/partner/authorization/sql.test.ts b/tests/partner/authorization/sql.test.ts index 55392c2..9e269d2 100644 --- a/tests/partner/authorization/sql.test.ts +++ b/tests/partner/authorization/sql.test.ts @@ -104,7 +104,11 @@ function insertGrant(db: Database, approvedPolicy = policy()): AuthorizationId { describe("authorization SQL boundary", () => { test("migrates idempotently and records epoch milliseconds", () => { const db = new Database(":memory:"); - expect(migrateAuthorizationSchema(db, NOW_MS)).toEqual(["001_account_authorization_core"]); + expect(migrateAuthorizationSchema(db, NOW_MS)).toEqual([ + "001_account_authorization_core", + "002_account_authorization_receipt_outbox", + "003_account_authorization_revocations", + ]); expect(migrateAuthorizationSchema(db, NOW_MS + 1)).toEqual([]); const migration = db diff --git a/tests/telegram/api.test.ts b/tests/telegram/api.test.ts new file mode 100644 index 0000000..ae6d79c --- /dev/null +++ b/tests/telegram/api.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { sendMessage } from "../../src/telegram/api.ts"; + +const originalFetch = globalThis.fetch; +const originalToken = Bun.env.TELEGRAM_BOT_TOKEN; + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalToken === undefined) delete Bun.env.TELEGRAM_BOT_TOKEN; + else Bun.env.TELEGRAM_BOT_TOKEN = originalToken; +}); + +describe("Telegram API boundary", () => { + test("can be imported without a token and fails only when called", async () => { + delete Bun.env.TELEGRAM_BOT_TOKEN; + expect(sendMessage(-123, "hello")).rejects.toThrow("TELEGRAM_BOT_TOKEN not set"); + }); + + test("sends topic and reply identity and returns the Telegram message", async () => { + Bun.env.TELEGRAM_BOT_TOKEN = "test-token"; + const capturedBodies: Array> = []; + globalThis.fetch = (async (_input, init) => { + capturedBodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json({ + ok: true, + result: { + message_id: 44, + message_thread_id: 7, + chat: { id: -123, type: "supergroup" }, + date: 1_700_000_000, + text: "hello", + }, + }); + }) as typeof fetch; + + const message = await sendMessage("-123", "hello", { + parseMode: "HTML", + messageThreadId: 7, + replyToMessageId: 43, + }); + + expect(message.message_id).toBe(44); + expect(capturedBodies[0]).toEqual({ + chat_id: "-123", + text: "hello", + parse_mode: "HTML", + message_thread_id: 7, + reply_parameters: { message_id: 43 }, + }); + }); +}); diff --git a/tests/telegram/authorization-commands.test.ts b/tests/telegram/authorization-commands.test.ts new file mode 100644 index 0000000..e9092d1 --- /dev/null +++ b/tests/telegram/authorization-commands.test.ts @@ -0,0 +1,208 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import type { AuthorizationPolicy } from "../../src/partner/authorization/domain.ts"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, +} from "../../src/partner/authorization/domain.ts"; +import { getAuthorizationReceiptOutboxItem } from "../../src/partner/authorization/outbox.ts"; +import { createAuthorizationRequest } from "../../src/partner/authorization/service.ts"; +import { migrateAuthorizationSchema } from "../../src/partner/authorization/sql.ts"; +import { handleAuthorizationCommand } from "../../src/telegram/authorization-commands.ts"; +import type { TelegramMessage } from "../../src/telegram/api.ts"; + +const NOW_MS = 1_700_000_000_000; + +function policy(overrides: Partial = {}): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("provider-x"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + dailyLimit: 1_000_000, + exposureLimit: 500_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 1_000, + expiresAtMs: NOW_MS + 60_000, + ...overrides, + }; +} + +function database() { + const db = new Database(":memory:"); + migrateAuthorizationSchema(db, NOW_MS); + return db; +} + +function seedRequest(db: Database, requestedPolicy = policy()) { + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id, created_at_ms + ) VALUES ($partner, $out, '789', $nowMs)`, + ).run({ + $partner: requestedPolicy.partnerCode, + $out: requestedPolicy.outId, + $nowMs: NOW_MS, + }); + const request = createAuthorizationRequest(db, { + policy: requestedPolicy, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + telegramMessageId: asTelegramMessageId("100"), + nowMs: NOW_MS, + }); + if (!request.ok) throw new Error(request.reason); + return request.request; +} + +function message(text: string, overrides: Partial = {}): TelegramMessage { + return { + message_id: 200, + message_thread_id: 7, + from: { id: 789, first_name: "Partner" }, + chat: { id: -123, type: "supergroup" }, + text, + date: Math.floor(NOW_MS / 1_000), + ...overrides, + }; +} + +describe("Telegram authorization commands", () => { + test("approves once, queues one deterministic receipt, and replays idempotently", () => { + const db = database(); + const request = seedRequest(db); + const update = message(`/approve@FactoryWagerBot ${request.id}`); + const dependencies = { db, botUsername: "FactoryWagerBot" }; + + const first = handleAuthorizationCommand(dependencies, update, NOW_MS + 1); + const replay = handleAuthorizationCommand(dependencies, update, NOW_MS + 2); + expect(first).toMatchObject({ handled: true, ok: true, code: "AUTHORIZATION_APPROVED" }); + expect(replay).toMatchObject({ handled: true, ok: true, code: "ALREADY_APPROVED" }); + expect( + db.query("SELECT count(*) AS count FROM account_authorizations").get(), + ).toEqual({ count: 1 }); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_receipt_outbox").get(), + ).toEqual({ count: 1 }); + if (first.handled && first.receiptOutboxId !== null) { + expect(getAuthorizationReceiptOutboxItem(db, first.receiptOutboxId)?.payload.text).toContain( + "Authorization active", + ); + } + db.close(); + }); + + test("fails closed for stale policy, wrong topic, and absent sender", () => { + const cases = [ + { + expected: "POLICY_HASH_MISMATCH", + dependencies: (db: Database) => ({ + db, + resolveCurrentPolicy: () => policy({ maxStake: 50_001 }), + }), + update: (requestId: number) => message(`/approve ${requestId}`), + }, + { + expected: "TOPIC_MISMATCH", + dependencies: (db: Database) => ({ db }), + update: (requestId: number) => + message(`/approve ${requestId}`, { message_thread_id: 8 }), + }, + { + expected: "SENDER_ID_REQUIRED", + dependencies: (db: Database) => ({ db }), + update: (requestId: number) => message(`/approve ${requestId}`, { from: undefined }), + }, + ]; + + for (const testCase of cases) { + const db = database(); + const request = seedRequest(db); + const result = handleAuthorizationCommand( + testCase.dependencies(db), + testCase.update(request.id), + NOW_MS + 1, + ); + expect(result).toMatchObject({ handled: true, ok: false, code: testCase.expected }); + expect( + db.query("SELECT count(*) AS count FROM account_authorizations").get(), + ).toEqual({ count: 0 }); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_receipt_outbox").get(), + ).toEqual({ count: 1 }); + db.close(); + } + }); + + test("rejects an older request after a newer policy snapshot supersedes it", () => { + const db = database(); + const original = seedRequest(db); + const superseding = createAuthorizationRequest(db, { + policy: policy({ maxStake: 40_000 }), + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + telegramMessageId: asTelegramMessageId("101"), + nowMs: NOW_MS + 1, + }); + expect(superseding.ok).toBeTrue(); + + const result = handleAuthorizationCommand( + { db }, + message(`/approve ${original.id}`), + NOW_MS + 2, + ); + + expect(result).toMatchObject({ + handled: true, + ok: false, + code: "POLICY_HASH_MISMATCH", + }); + expect(db.query("SELECT count(*) AS count FROM account_authorizations").get()).toEqual({ + count: 0, + }); + db.close(); + }); + + test("preserves out ID case and records replay-safe revocation provenance", () => { + const db = database(); + const request = seedRequest(db); + expect( + handleAuthorizationCommand({ db }, message(`/approve ${request.id}`), NOW_MS + 1), + ).toMatchObject({ handled: true, ok: true }); + + const revokeMessage = message("/revoke_out out-SPORTS-1", { message_id: 201 }); + const first = handleAuthorizationCommand({ db }, revokeMessage, NOW_MS + 2); + const replay = handleAuthorizationCommand({ db }, revokeMessage, NOW_MS + 3); + expect(first).toMatchObject({ handled: true, ok: true, code: "OUT_AUTHORIZATIONS_REVOKED" }); + expect(replay).toMatchObject({ handled: true, ok: true, code: "OUT_AUTHORIZATIONS_REVOKED" }); + expect( + db.query("SELECT out_id, telegram_message_id FROM account_authorization_revocations").get(), + ).toEqual({ out_id: "out-SPORTS-1", telegram_message_id: "201" }); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_receipt_outbox").get(), + ).toEqual({ count: 2 }); + db.close(); + }); + + test("ignores commands addressed to another bot", () => { + const db = database(); + expect( + handleAuthorizationCommand( + { db, botUsername: "FactoryWagerBot" }, + message("/approve@OtherBot 1"), + NOW_MS, + ), + ).toEqual({ handled: false }); + db.close(); + }); +}); diff --git a/tests/telegram/authorization-outbox-worker.test.ts b/tests/telegram/authorization-outbox-worker.test.ts new file mode 100644 index 0000000..8b8ad3a --- /dev/null +++ b/tests/telegram/authorization-outbox-worker.test.ts @@ -0,0 +1,77 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, +} from "../../src/partner/authorization/domain.ts"; +import { + asAuthorizationReceiptDedupeKey, + asAuthorizationReceiptLeaseOwner, + enqueueAuthorizationReceipt, + getAuthorizationReceiptOutboxItem, +} from "../../src/partner/authorization/outbox.ts"; +import { migrateAuthorizationSchema } from "../../src/partner/authorization/sql.ts"; +import { deliverAuthorizationReceiptBatch } from "../../src/telegram/authorization-outbox-worker.ts"; + +const NOW_MS = 1_700_000_000_000; + +describe("Telegram authorization outbox worker", () => { + test("retries transport failures and delivers topic-bound receipts", async () => { + const db = new Database(":memory:"); + migrateAuthorizationSchema(db, NOW_MS); + const queued = enqueueAuthorizationReceipt( + db, + { + dedupeKey: asAuthorizationReceiptDedupeKey("command:-123:200"), + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + payload: { + text: "approved", + parseMode: "HTML", + replyToMessageId: asTelegramMessageId("200"), + }, + }, + NOW_MS, + ).item; + const worker = asAuthorizationReceiptLeaseOwner("test-worker"); + let attempts = 0; + const send = async (chatId: number | string, text: string, options?: { messageThreadId?: number; replyToMessageId?: number }) => { + attempts += 1; + if (attempts === 1) throw new Error("Telegram offline"); + expect(chatId).toBe("-123"); + expect(text).toBe("approved"); + expect(options).toMatchObject({ messageThreadId: 7, replyToMessageId: 200 }); + return { + message_id: 201, + message_thread_id: 7, + chat: { id: -123, type: "supergroup" as const }, + date: Math.floor(NOW_MS / 1_000), + text, + }; + }; + + expect( + await deliverAuthorizationReceiptBatch(db, { + nowMs: NOW_MS, + leaseOwner: worker, + send, + baseDelayMs: 100, + clock: () => NOW_MS, + }), + ).toMatchObject({ claimed: 1, sent: 0, failed: 1 }); + expect(getAuthorizationReceiptOutboxItem(db, queued.id)?.status).toBe("pending"); + + expect( + await deliverAuthorizationReceiptBatch(db, { + nowMs: NOW_MS + 100, + leaseOwner: worker, + send, + baseDelayMs: 100, + clock: () => NOW_MS + 100, + }), + ).toMatchObject({ claimed: 1, sent: 1, failed: 0 }); + expect(getAuthorizationReceiptOutboxItem(db, queued.id)?.status).toBe("sent"); + db.close(); + }); +}); diff --git a/tests/telegram/authorization-requests.test.ts b/tests/telegram/authorization-requests.test.ts new file mode 100644 index 0000000..345ca18 --- /dev/null +++ b/tests/telegram/authorization-requests.test.ts @@ -0,0 +1,144 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramTopicId, + type AuthorizationPolicy, +} from "../../src/partner/authorization/domain.ts"; +import { migrateAuthorizationSchema } from "../../src/partner/authorization/sql.ts"; +import { + formatAuthorizationRequest, + postAuthorizationRequest, +} from "../../src/telegram/authorization-requests.ts"; + +const NOW_MS = 1_700_000_000_000; + +function policy(): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("provider-x"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + dailyLimit: 1_000_000, + exposureLimit: 500_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS, + expiresAtMs: NOW_MS + 60_000, + }; +} + +function database() { + const db = new Database(":memory:"); + migrateAuthorizationSchema(db, NOW_MS); + return db; +} + +describe("Telegram authorization request posting", () => { + test("posts the immutable snapshot, persists provenance, and queues approval instructions", async () => { + const db = database(); + const sent: Array<{ chatId: number | string; text: string; topic?: number }> = []; + const result = await postAuthorizationRequest( + db, + { + policy: policy(), + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + nowMs: NOW_MS, + }, + async (chatId, text, options) => { + sent.push({ chatId, text, topic: options?.messageThreadId }); + return { + message_id: 100, + message_thread_id: 7, + chat: { id: -123, type: "supergroup" }, + date: Math.floor(NOW_MS / 1_000), + text, + }; + }, + ); + + expect(result).toMatchObject({ ok: true, code: "REQUEST_POSTED" }); + expect(sent).toHaveLength(1); + expect(sent[0]?.topic).toBe(7); + expect(sent[0]?.text).toContain("Hash:"); + expect(sent[0]?.text).not.toContain("Balance:"); + expect( + db.query("SELECT telegram_message_id, status FROM account_authorization_requests").get(), + ).toEqual({ telegram_message_id: "100", status: "pending" }); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_receipt_outbox").get(), + ).toEqual({ count: 1 }); + db.close(); + }); + + test("fails without persistence when Telegram send fails or returns another topic", async () => { + for (const mismatch of [false, true]) { + const db = database(); + const result = await postAuthorizationRequest( + db, + { + policy: policy(), + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + nowMs: NOW_MS, + }, + async (_chatId, text) => { + if (!mismatch) throw new Error("offline"); + return { + message_id: 100, + message_thread_id: 8, + chat: { id: -123, type: "supergroup" }, + date: Math.floor(NOW_MS / 1_000), + text, + }; + }, + ); + expect(result.ok).toBeFalse(); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_requests").get(), + ).toEqual({ count: 0 }); + db.close(); + } + }); + + test("rejects an expired policy before contacting Telegram", async () => { + const db = database(); + let sends = 0; + const result = await postAuthorizationRequest( + db, + { + policy: { ...policy(), expiresAtMs: NOW_MS }, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + nowMs: NOW_MS, + }, + async () => { + sends += 1; + throw new Error("must not be called"); + }, + ); + + expect(result).toMatchObject({ ok: false, code: "INVALID_INPUT" }); + expect(sends).toBe(0); + expect(db.query("SELECT count(*) AS count FROM account_authorization_requests").get()).toEqual({ + count: 0, + }); + db.close(); + }); + + test("formatter exposes only immutable authorization terms", () => { + const text = formatAuthorizationRequest(policy()); + expect(text).toContain("out-SPORTS-1"); + expect(text).toContain("50000 USD minor units"); + expect(text).not.toContain("balance"); + }); +}); diff --git a/tests/telegram/commands.test.ts b/tests/telegram/commands.test.ts new file mode 100644 index 0000000..a81ef4b --- /dev/null +++ b/tests/telegram/commands.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { parseTelegramCommand } from "../../src/telegram/commands.ts"; + +describe("Telegram command parser", () => { + test("case-folds only the command and preserves arguments", () => { + expect(parseTelegramCommand(" /REVOKE_OUT out-SPORTS-1 ")).toEqual({ + name: "revoke_out", + botUsername: null, + args: ["out-SPORTS-1"], + }); + }); + + test("accepts group command suffixes", () => { + expect(parseTelegramCommand("/Approve@FactoryWagerBot 42")).toEqual({ + name: "approve", + botUsername: "factorywagerbot", + args: ["42"], + }); + }); + + test("rejects malformed commands", () => { + for (const text of ["approve 1", "/", "/approve@ 1", "/approve bad\narg"]) { + if (text === "/approve bad\narg") { + expect(parseTelegramCommand(text)?.args).toEqual(["bad", "arg"]); + } else { + expect(parseTelegramCommand(text)).toBeNull(); + } + } + }); +}); diff --git a/tools/telegram/setup-alert-hub.ts b/tools/telegram/setup-alert-hub.ts index 3a440ee..5f78dce 100644 --- a/tools/telegram/setup-alert-hub.ts +++ b/tools/telegram/setup-alert-hub.ts @@ -71,9 +71,12 @@ if (createTopics) { await setMyCommands(chatId, [ { command: "status", description: "Current pipeline health snapshot" }, - { command: "alerts", description: "Recent alert activity" }, + { command: "dashboard", description: "Latest calibration dashboard" }, { command: "members", description: "Channel member count and admins" }, - { command: "dash", description: "Link to ops dashboard" }, + { command: "subscribe", description: "Subscribe this chat to the digest" }, + { command: "unsubscribe", description: "Remove this chat from the digest" }, + { command: "approve", description: "Approve an authorization request by ID" }, + { command: "revoke_out", description: "Revoke active grants for an out" }, ]); console.log("✅ Bot commands registered"); From 241dd7d554c85f55f5ff519e583698ae93dd24a4 Mon Sep 17 00:00:00 2001 From: nolarose Date: Thu, 6 Aug 2026 02:09:32 -0500 Subject: [PATCH 3/7] feat(partner): add authorized execution reservations --- src/bot/kalshi-client.ts | 4 +- src/partner/domain.ts | 15 +- src/partner/execution/domain.ts | 165 ++++++ src/partner/execution/executor.ts | 470 +++++++++++++++++ src/partner/execution/index.ts | 6 + src/partner/execution/kalshi.ts | 49 ++ src/partner/execution/maintenance.ts | 29 ++ src/partner/execution/reservation.ts | 529 ++++++++++++++++++++ src/partner/execution/sql.ts | 93 ++++ src/partner/index.ts | 2 + src/telegram/bot.ts | 9 +- tests/bot/kalshi-client.test.ts | 7 + tests/partner/execution/executor.test.ts | 269 ++++++++++ tests/partner/execution/kalshi.test.ts | 93 ++++ tests/partner/execution/reservation.test.ts | 242 +++++++++ 15 files changed, 1975 insertions(+), 7 deletions(-) create mode 100644 src/partner/execution/domain.ts create mode 100644 src/partner/execution/executor.ts create mode 100644 src/partner/execution/index.ts create mode 100644 src/partner/execution/kalshi.ts create mode 100644 src/partner/execution/maintenance.ts create mode 100644 src/partner/execution/reservation.ts create mode 100644 src/partner/execution/sql.ts create mode 100644 tests/partner/execution/executor.test.ts create mode 100644 tests/partner/execution/kalshi.test.ts create mode 100644 tests/partner/execution/reservation.test.ts diff --git a/src/bot/kalshi-client.ts b/src/bot/kalshi-client.ts index ce07c93..b8b2f94 100644 --- a/src/bot/kalshi-client.ts +++ b/src/bot/kalshi-client.ts @@ -27,6 +27,8 @@ export type KalshiOrderRequest = { dryRun: boolean; /** Resting-maker entry — default true (maker-first doctrine). */ postOnly?: boolean; + /** Stable UUID used by authorized execution to make provider retries idempotent. */ + clientOrderId?: string; }; export type KalshiOrderResult = { @@ -164,7 +166,7 @@ export function createKalshiClient(options: KalshiClientOptions = {}): KalshiCli side: request.side, action: "buy", count: request.count, - client_order_id: crypto.randomUUID(), + client_order_id: request.clientOrderId ?? crypto.randomUUID(), time_in_force: "good_till_canceled", post_only: request.postOnly ?? true, cancel_order_on_pause: true, diff --git a/src/partner/domain.ts b/src/partner/domain.ts index 6a69b44..d42589c 100644 --- a/src/partner/domain.ts +++ b/src/partner/domain.ts @@ -180,9 +180,16 @@ export const PARTNER_DOMAIN_LAYERS: readonly DomainLayer[] = [ { id: "authorized-execution-wrapper", name: "Transactional authorized execution wrapper", + maturity: "built", + where: "src/partner/execution/", + notes: "Immediate reservation transaction, gate recheck, idempotent dispatch, ambiguous-outcome reconciliation, and durable receipts", + }, + { + id: "provider-execution-bindings", + name: "Live provider execution bindings", maturity: "planned", - where: "—", - notes: "Gate recheck, exposure reservation, provider placement, and ticket receipt remain next", + where: "src/bot/kalshi-client.ts · src/partner/fantasy-ultra/adapter.ts", + notes: "Explicit order translation, balance/liquidity snapshot loader, and reconciliation poller are not wired", }, ], }, @@ -322,7 +329,7 @@ export function buildDomainStatusReport( totals, orchestration: { ssot: - "event-store SQLite (partners, betting_accounts, partner_events, account_authorizations) + Proton Pass + env", + "event-store SQLite (partners, betting_accounts, partner_events, account_authorizations, exposure_reservations) + Proton Pass + env", clis: [ "partner:domain", "partner:capacity", @@ -341,7 +348,7 @@ export function buildDomainStatusReport( "partners.telegram_chat_id + topic preferences", "Telegram /capacity /add command router", "partner_ledger + split → report pipeline", - "live placeOrder after real HAR + auto eventCoefficients subscribe", + "provider binding into executeAuthorizedBet + reconciliation poller", ], }, }; diff --git a/src/partner/execution/domain.ts b/src/partner/execution/domain.ts new file mode 100644 index 0000000..f484793 --- /dev/null +++ b/src/partner/execution/domain.ts @@ -0,0 +1,165 @@ +import type { + ApprovedAuthorization, + AuthorizationPolicy, + OutId, + PartnerCode, + ProviderId, + SkinId, +} from "../authorization/domain.ts"; + +declare const marketIdBrand: unique symbol; +declare const ticketIdBrand: unique symbol; +declare const reservationIdBrand: unique symbol; +declare const executionKeyBrand: unique symbol; +declare const placementOwnerBrand: unique symbol; + +export type MarketId = string & { readonly [marketIdBrand]: true }; +export type TicketId = string & { readonly [ticketIdBrand]: true }; +export type ExposureReservationId = number & { readonly [reservationIdBrand]: true }; +export type ExecutionIdempotencyKey = string & { readonly [executionKeyBrand]: true }; +export type PlacementOwner = string & { readonly [placementOwnerBrand]: true }; + +export const EXPOSURE_RESERVATION_STATUSES = [ + "pending", + "placing", + "confirmed", + "rejected", + "unknown", + "cancelled", + "settled", +] as const; +export type ExposureReservationStatus = (typeof EXPOSURE_RESERVATION_STATUSES)[number]; + +export interface BetRequest { + partnerCode: PartnerCode; + outId: OutId; + skin: SkinId; + marketId: MarketId; + idempotencyKey: ExecutionIdempotencyKey; + requestedStake: number; + decimalOdds: number; +} + +/** Fresh runtime inputs gathered immediately before the reservation transaction. */ +export interface ExecutionSnapshot { + currentPolicy: AuthorizationPolicy; + oddsFresh: boolean; + providerSessionValid: boolean; + riskHealthy: boolean; + sitePerBetMax: number; + availableBalance: number; + marketLiquidity: number; +} + +export interface ProviderPlacementInput { + authorization: ApprovedAuthorization; + request: BetRequest; + effectiveStake: number; + /** Must be forwarded to providers that support idempotent order creation. */ + idempotencyKey: ExecutionIdempotencyKey; +} + +export type ProviderPlacementResult = + | { + accepted: true; + ticketId: TicketId; + /** Sanitized, secret-free summary only. */ + responseSummary?: unknown; + } + | { + accepted: false; + reason: string; + /** Sanitized, secret-free summary only. */ + responseSummary?: unknown; + }; + +export interface ExecutionDependencies { + loadSnapshot: ( + authorization: ApprovedAuthorization, + request: BetRequest, + ) => Promise | ExecutionSnapshot; + placeBet: (input: ProviderPlacementInput) => Promise; + now?: () => number; + reservationTtlMs?: number; +} + +export interface ExposureReservation { + id: ExposureReservationId; + idempotencyKey: ExecutionIdempotencyKey; + partnerCode: PartnerCode; + outId: OutId; + skin: SkinId; + provider: ProviderId; + authorizationId: ApprovedAuthorization["id"]; + requestedStake: number; + effectiveStake: number; + marketId: MarketId; + decimalOdds: number; + status: ExposureReservationStatus; + reservationExpiresAtMs: number; + placementOwner: PlacementOwner | null; + ticketId: TicketId | null; + providerResponse: unknown | null; + failureReason: string | null; + createdAtMs: number; + updatedAtMs: number; +} + +export type ExecutionDenialCode = + | "INVALID_REQUEST" + | "NO_ACTIVE_AUTHORIZATION" + | "SNAPSHOT_UNAVAILABLE" + | "GATE_DENIED" + | "RESERVATION_CONFLICT" + | "RESERVATION_FAILED" + | "PLACEMENT_IN_PROGRESS" + | "PROVIDER_REJECTED" + | "PROVIDER_OUTCOME_UNKNOWN" + | "PERSISTENCE_UNCERTAIN"; + +export type AuthorizedBetResult = + | { + success: true; + code: "BET_CONFIRMED" | "ALREADY_CONFIRMED"; + ticketId: TicketId; + effectiveStake: number; + reservationId: ExposureReservationId; + } + | { + success: false; + code: ExecutionDenialCode; + reason: string; + reservationId?: ExposureReservationId; + effectiveStake?: number; + }; + +export function asMarketId(value: string): MarketId { + return brandBoundedString(value, "market ID", 256); +} + +export function asTicketId(value: string): TicketId { + return brandBoundedString(value, "ticket ID", 256); +} + +export function asExecutionIdempotencyKey(value: string): ExecutionIdempotencyKey { + return brandBoundedString(value, "execution idempotency key", 256); +} + +export function asPlacementOwner(value: string): PlacementOwner { + return brandBoundedString(value, "placement owner", 128); +} + +export function asExposureReservationId(value: number): ExposureReservationId { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError("exposure reservation ID must be a positive safe integer"); + } + return value as ExposureReservationId; +} + +function brandBoundedString(value: string, label: string, max: number): T { + const normalized = value.trim(); + if (normalized.length === 0) throw new TypeError(`${label} must not be empty`); + if (normalized.length > max) throw new TypeError(`${label} must be at most ${max} characters`); + if (/\p{Cc}/u.test(normalized)) throw new TypeError(`${label} must not contain control characters`); + return normalized as T; +} diff --git a/src/partner/execution/executor.ts b/src/partner/execution/executor.ts new file mode 100644 index 0000000..588443a --- /dev/null +++ b/src/partner/execution/executor.ts @@ -0,0 +1,470 @@ +import type { Database } from "bun:sqlite"; +import { evaluateExecutionGate } from "../authorization/gate.ts"; +import { + asOutId, + asPartnerCode, + asSkinId, + type ApprovedAuthorization, +} from "../authorization/domain.ts"; +import { + asAuthorizationReceiptDedupeKey, + enqueueAuthorizationReceipt, +} from "../authorization/outbox.ts"; +import { getActiveLiveTradeAuthorization } from "../authorization/sql.ts"; +import { + asExecutionIdempotencyKey, + asMarketId, + asPlacementOwner, + type AuthorizedBetResult, + type BetRequest, + type ExecutionDependencies, + type ExposureReservation, +} from "./domain.ts"; +import { + claimReservationForPlacement, + computeDailyUsage, + computeOutstandingExposure, + computeReservedMarketLiquidity, + confirmReservation, + createPendingReservation, + getReservationByIdempotencyKey, + markReservationUnknown, + rejectReservation, + releaseExpiredReservations, +} from "./reservation.ts"; +import { ensureExecutionSchema } from "./sql.ts"; + +const DEFAULT_RESERVATION_TTL_MS = 30_000; + +/** + * Reserve under BEGIN IMMEDIATE, dispatch outside SQLite, then durably finalize. + * Provider throws are ambiguous and remain exposure-bearing (`unknown`). + */ +export async function executeAuthorizedBet( + db: Database, + request: BetRequest, + dependencies: ExecutionDependencies, +): Promise { + const requestError = validateRequest(request); + if (requestError !== null) { + return { success: false, code: "INVALID_REQUEST", reason: requestError }; + } + const initialNow = dependencies.now?.() ?? Date.now(); + try { + ensureExecutionSchema(db, initialNow); + } catch (error) { + return { success: false, code: "RESERVATION_FAILED", reason: errorMessage(error) }; + } + + const existing = getReservationByIdempotencyKey(db, request.idempotencyKey); + if (existing !== null) { + return reservationMatchesRequest(existing, request) + ? replayResult(existing) + : { + success: false, + code: "RESERVATION_CONFLICT", + reason: "Execution idempotency key is already bound to different bet terms", + reservationId: existing.id, + }; + } + + const initialAuthorization = getActiveLiveTradeAuthorization(db, { + partnerCode: request.partnerCode, + outId: request.outId, + skin: request.skin, + nowMs: initialNow, + }); + if (initialAuthorization === null) { + return { + success: false, + code: "NO_ACTIVE_AUTHORIZATION", + reason: "No active live-trade authorization exists for this partner, out, and skin", + }; + } + + let snapshot; + try { + snapshot = await dependencies.loadSnapshot(initialAuthorization, request); + } catch (error) { + return { success: false, code: "SNAPSHOT_UNAVAILABLE", reason: errorMessage(error) }; + } + + const placementOwner = asPlacementOwner(crypto.randomUUID()); + const ttlMs = dependencies.reservationTtlMs ?? DEFAULT_RESERVATION_TTL_MS; + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) { + return { + success: false, + code: "INVALID_REQUEST", + reason: "reservation TTL must be a positive safe integer", + }; + } + + let prepared: + | { kind: "place"; authorization: ApprovedAuthorization; reservation: ExposureReservation } + | { kind: "result"; result: AuthorizedBetResult }; + try { + const transaction = db.transaction(() => { + const nowMs = dependencies.now?.() ?? initialNow; + releaseExpiredReservations(db, nowMs); + + const replay = getReservationByIdempotencyKey(db, request.idempotencyKey); + if (replay !== null) { + return { + kind: "result" as const, + result: reservationMatchesRequest(replay, request) + ? replayResult(replay) + : { + success: false as const, + code: "RESERVATION_CONFLICT" as const, + reason: "Execution idempotency key is already bound to different bet terms", + reservationId: replay.id, + }, + }; + } + + const authorization = getActiveLiveTradeAuthorization(db, { + partnerCode: request.partnerCode, + outId: request.outId, + skin: request.skin, + nowMs, + }); + if (authorization === null || authorization.id !== initialAuthorization.id) { + return { + kind: "result" as const, + result: { + success: false as const, + code: "NO_ACTIVE_AUTHORIZATION" as const, + reason: "Authorization changed or became inactive before reservation", + }, + }; + } + + const lane = { + partnerCode: request.partnerCode, + outId: request.outId, + skin: request.skin, + }; + const outstandingExposure = computeOutstandingExposure(db, lane); + const reservedMarketLiquidity = computeReservedMarketLiquidity( + db, + lane, + request.marketId, + request.decimalOdds, + ); + const gate = evaluateExecutionGate({ + authorization, + currentPolicy: snapshot.currentPolicy, + nowMs, + oddsFresh: snapshot.oddsFresh, + providerSessionValid: snapshot.providerSessionValid, + riskHealthy: snapshot.riskHealthy, + stakeInput: { + requestedStake: request.requestedStake, + sitePerBetMax: snapshot.sitePerBetMax, + decimalOdds: request.decimalOdds, + availableBalance: Math.max(snapshot.availableBalance - outstandingExposure, 0), + dailyUsed: computeDailyUsage(db, lane, utcDayStartMs(nowMs)), + outstandingExposure, + marketLiquidity: Math.max( + snapshot.marketLiquidity - reservedMarketLiquidity, + 0, + ), + }, + }); + if (!gate.allowed) { + return { + kind: "result" as const, + result: { + success: false as const, + code: "GATE_DENIED" as const, + reason: `${gate.code}: ${gate.reason}`, + }, + }; + } + + const expiresAtMs = safeAdd(nowMs, ttlMs, "reservation expiry"); + const created = createPendingReservation(db, { + authorization, + request, + effectiveStake: gate.effectiveStake, + expiresAtMs, + nowMs, + }); + if (!created.ok) { + return { + kind: "result" as const, + result: { + success: false as const, + code: + created.code === "IDEMPOTENCY_CONFLICT" + ? ("RESERVATION_CONFLICT" as const) + : ("RESERVATION_FAILED" as const), + reason: created.reason, + }, + }; + } + if (!created.created) { + return { kind: "result" as const, result: replayResult(created.reservation) }; + } + const claimed = claimReservationForPlacement(db, { + id: created.reservation.id, + placementOwner, + nowMs, + }); + if (claimed === null) throw new Error("new reservation could not be claimed for placement"); + return { kind: "place" as const, authorization, reservation: claimed }; + }); + prepared = transaction.immediate(); + } catch (error) { + return { success: false, code: "RESERVATION_FAILED", reason: errorMessage(error) }; + } + + if (prepared.kind === "result") return prepared.result; + const { authorization, reservation } = prepared; + + let providerResult; + try { + providerResult = await dependencies.placeBet({ + authorization, + request, + effectiveStake: reservation.effectiveStake, + idempotencyKey: request.idempotencyKey, + }); + } catch (error) { + const reason = errorMessage(error); + try { + const transaction = db.transaction(() => { + const nowMs = dependencies.now?.() ?? Date.now(); + const unknown = markReservationUnknown(db, { + id: reservation.id, + placementOwner, + reason, + nowMs, + }); + if (unknown === null) throw new Error("reservation ownership changed before unknown result"); + enqueueExecutionReceipt(db, authorization, unknown, "unknown", reason, nowMs); + }); + transaction.immediate(); + } catch (persistenceError) { + return { + success: false, + code: "PERSISTENCE_UNCERTAIN", + reason: `Provider outcome and reservation persistence are uncertain: ${errorMessage(persistenceError)}`, + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + return { + success: false, + code: "PROVIDER_OUTCOME_UNKNOWN", + reason, + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + + if (!providerResult.accepted) { + try { + const transaction = db.transaction(() => { + const nowMs = dependencies.now?.() ?? Date.now(); + const rejected = rejectReservation(db, { + id: reservation.id, + placementOwner, + reason: providerResult.reason, + providerResponse: providerResult.responseSummary, + nowMs, + }); + if (rejected === null) throw new Error("reservation ownership changed before rejection"); + enqueueExecutionReceipt( + db, + authorization, + rejected, + "rejected", + providerResult.reason, + nowMs, + ); + }); + transaction.immediate(); + } catch (error) { + return { + success: false, + code: "PERSISTENCE_UNCERTAIN", + reason: `Provider rejected the bet but persistence failed: ${errorMessage(error)}`, + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + return { + success: false, + code: "PROVIDER_REJECTED", + reason: providerResult.reason, + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + + try { + const transaction = db.transaction(() => { + const nowMs = dependencies.now?.() ?? Date.now(); + const confirmed = confirmReservation(db, { + id: reservation.id, + placementOwner, + ticketId: providerResult.ticketId, + providerResponse: providerResult.responseSummary, + nowMs, + }); + if (confirmed === null) throw new Error("reservation ownership changed before confirmation"); + enqueueExecutionReceipt(db, authorization, confirmed, "confirmed", null, nowMs); + }); + transaction.immediate(); + } catch (error) { + return { + success: false, + code: "PERSISTENCE_UNCERTAIN", + reason: `Provider accepted the bet but confirmation persistence failed: ${errorMessage(error)}`, + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + return { + success: true, + code: "BET_CONFIRMED", + ticketId: providerResult.ticketId, + effectiveStake: reservation.effectiveStake, + reservationId: reservation.id, + }; +} + +function replayResult(reservation: ExposureReservation): AuthorizedBetResult { + if (reservation.status === "confirmed" && reservation.ticketId !== null) { + return { + success: true, + code: "ALREADY_CONFIRMED", + ticketId: reservation.ticketId, + effectiveStake: reservation.effectiveStake, + reservationId: reservation.id, + }; + } + if (reservation.status === "unknown") { + return { + success: false, + code: "PROVIDER_OUTCOME_UNKNOWN", + reason: reservation.failureReason ?? "Provider outcome requires reconciliation", + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + if (reservation.status === "placing" || reservation.status === "pending") { + return { + success: false, + code: "PLACEMENT_IN_PROGRESS", + reason: `Execution reservation is ${reservation.status}`, + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + if (reservation.status === "rejected") { + return { + success: false, + code: "PROVIDER_REJECTED", + reason: reservation.failureReason ?? "Provider rejected the bet", + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; + } + return { + success: false, + code: "RESERVATION_CONFLICT", + reason: `Execution idempotency key is bound to a ${reservation.status} reservation`, + reservationId: reservation.id, + effectiveStake: reservation.effectiveStake, + }; +} + +function reservationMatchesRequest( + reservation: ExposureReservation, + request: BetRequest, +): boolean { + return ( + reservation.partnerCode === request.partnerCode && + reservation.outId === request.outId && + reservation.skin === request.skin && + reservation.marketId === request.marketId && + reservation.requestedStake === request.requestedStake && + reservation.decimalOdds === request.decimalOdds + ); +} + +function enqueueExecutionReceipt( + db: Database, + authorization: ApprovedAuthorization, + reservation: ExposureReservation, + outcome: "confirmed" | "rejected" | "unknown", + reason: string | null, + nowMs: number, +): void { + const headline = + outcome === "confirmed" + ? "✅ Bet confirmed" + : outcome === "rejected" + ? "⛔ Bet rejected" + : "⚠️ Bet outcome unknown — reconciliation required"; + const detail = + outcome === "confirmed" + ? `Ticket: ${Bun.escapeHTML(reservation.ticketId ?? "missing")}` + : `Reason: ${Bun.escapeHTML(reason ?? "not provided")}`; + enqueueAuthorizationReceipt( + db, + { + dedupeKey: asAuthorizationReceiptDedupeKey( + `execution:${reservation.id}:${outcome}`, + ), + telegramChatId: authorization.telegramChatId, + telegramTopicId: authorization.telegramTopicId, + payload: { + text: + `${headline}\n` + + `Out: ${Bun.escapeHTML(reservation.outId)}\n` + + `Market: ${Bun.escapeHTML(reservation.marketId)}\n` + + `Stake: ${reservation.effectiveStake} minor units\n` + + `Odds: ${reservation.decimalOdds}\n` + + detail, + parseMode: "HTML", + }, + }, + nowMs, + ); +} + +function validateRequest(request: BetRequest): string | null { + try { + asPartnerCode(request.partnerCode); + asOutId(request.outId); + asSkinId(request.skin); + asMarketId(request.marketId); + asExecutionIdempotencyKey(request.idempotencyKey); + if (!Number.isSafeInteger(request.requestedStake) || request.requestedStake <= 0) { + throw new TypeError("requested stake must be a positive safe integer in minor units"); + } + if (!Number.isFinite(request.decimalOdds) || request.decimalOdds <= 1) { + throw new TypeError("decimal odds must be finite and greater than one"); + } + return null; + } catch (error) { + return errorMessage(error); + } +} + +function utcDayStartMs(nowMs: number): number { + return Math.floor(nowMs / 86_400_000) * 86_400_000; +} + +function safeAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value)) throw new TypeError(`${label} exceeds safe integer range`); + return value; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "execution operation failed"; +} diff --git a/src/partner/execution/index.ts b/src/partner/execution/index.ts new file mode 100644 index 0000000..ca20add --- /dev/null +++ b/src/partner/execution/index.ts @@ -0,0 +1,6 @@ +export * from "./domain.ts"; +export * from "./executor.ts"; +export * from "./kalshi.ts"; +export * from "./maintenance.ts"; +export * from "./reservation.ts"; +export * from "./sql.ts"; diff --git a/src/partner/execution/kalshi.ts b/src/partner/execution/kalshi.ts new file mode 100644 index 0000000..b7fb345 --- /dev/null +++ b/src/partner/execution/kalshi.ts @@ -0,0 +1,49 @@ +import type { + KalshiClient, + KalshiOrderRequest, +} from "../../bot/kalshi-client.ts"; +import { asTicketId, type ProviderPlacementInput } from "./domain.ts"; + +export type KalshiExecutionOrder = Omit< + KalshiOrderRequest, + "dryRun" | "clientOrderId" +>; + +export type KalshiExecutionOrderMapper = ( + input: ProviderPlacementInput, +) => KalshiExecutionOrder; + +/** Bind the generic authorized executor to the existing signed Kalshi client. */ +export function createKalshiExecutionPlacer( + client: Pick, + mapOrder: KalshiExecutionOrderMapper, +) { + return async (input: ProviderPlacementInput) => { + const clientOrderId = executionIdempotencyKeyToUuid(input.idempotencyKey); + const result = await client.placeOrder({ + ...mapOrder(input), + dryRun: false, + clientOrderId, + }); + if (result.dryRun) throw new Error("Kalshi execution unexpectedly returned a dry-run order"); + return { + accepted: true as const, + ticketId: asTicketId(result.orderId), + responseSummary: { + environment: client.environment, + orderId: result.orderId, + clientOrderId, + }, + }; + }; +} + +/** Deterministic RFC-4122 UUIDv5-shaped key derived without exposing the source key. */ +export function executionIdempotencyKeyToUuid(key: string): string { + const digest = new Bun.CryptoHasher("sha256").update(key).digest() as Uint8Array; + const bytes = Uint8Array.from(digest.slice(0, 16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/src/partner/execution/maintenance.ts b/src/partner/execution/maintenance.ts new file mode 100644 index 0000000..0ef13df --- /dev/null +++ b/src/partner/execution/maintenance.ts @@ -0,0 +1,29 @@ +import type { Database } from "bun:sqlite"; +import { releaseExpiredReservations } from "./reservation.ts"; + +export interface ExecutionMaintenanceResult { + releasedPending: number; + placing: number; + unknown: number; +} + +/** Bounded maintenance tick; ambiguous/placing outcomes are reported, never auto-released. */ +export function runExecutionMaintenance( + db: Database, + nowMs = Date.now(), +): ExecutionMaintenanceResult { + const releasedPending = releaseExpiredReservations(db, nowMs); + const counts = db + .query( + `SELECT + SUM(CASE WHEN status = 'placing' THEN 1 ELSE 0 END) AS placing, + SUM(CASE WHEN status = 'unknown' THEN 1 ELSE 0 END) AS unknown + FROM exposure_reservations`, + ) + .get() as { placing: number | null; unknown: number | null }; + return { + releasedPending, + placing: counts.placing ?? 0, + unknown: counts.unknown ?? 0, + }; +} diff --git a/src/partner/execution/reservation.ts b/src/partner/execution/reservation.ts new file mode 100644 index 0000000..3f9a2ec --- /dev/null +++ b/src/partner/execution/reservation.ts @@ -0,0 +1,529 @@ +import type { Database } from "bun:sqlite"; +import { + asAuthorizationId, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + type ApprovedAuthorization, + type OutId, + type PartnerCode, + type SkinId, +} from "../authorization/domain.ts"; +import { + asExecutionIdempotencyKey, + asExposureReservationId, + asMarketId, + asPlacementOwner, + asTicketId, + type BetRequest, + type ExecutionIdempotencyKey, + type ExposureReservation, + type ExposureReservationId, + type ExposureReservationStatus, + type PlacementOwner, + type TicketId, +} from "./domain.ts"; + +type ReservationRow = { + id: number; + idempotency_key: string; + partner_code: string; + out_id: string; // brand-ok — SQLite wire value; parsed by mapReservation + skin: string; + provider: string; + authorization_id: number; + requested_stake: number; + effective_stake: number; + market_id: string; // brand-ok — SQLite wire value; parsed by mapReservation + decimal_odds: number; + status: ExposureReservationStatus; + reservation_expires_at_ms: number; + placement_owner: string | null; + ticket_id: string | null; // brand-ok — SQLite wire value; parsed by mapReservation + provider_response_json: string | null; + failure_reason: string | null; + created_at_ms: number; + updated_at_ms: number; +}; + +export type CreatePendingReservationResult = + | { ok: true; created: true; reservation: ExposureReservation } + | { ok: true; created: false; reservation: ExposureReservation } + | { ok: false; code: "IDEMPOTENCY_CONFLICT" | "INVALID_INPUT"; reason: string }; + +export interface ReservationLane { + partnerCode: PartnerCode; + outId: OutId; + skin: SkinId; +} + +export function createPendingReservation( + db: Database, + input: { + authorization: ApprovedAuthorization; + request: BetRequest; + effectiveStake: number; + expiresAtMs: number; + nowMs: number; + }, +): CreatePendingReservationResult { + try { + assertTimestamp(input.nowMs, "reservation creation time"); + assertTimestamp(input.expiresAtMs, "reservation expiry"); + if (input.expiresAtMs <= input.nowMs) throw new TypeError("reservation expiry must be future"); + assertPositiveMinorUnits(input.request.requestedStake, "requested stake"); + assertPositiveMinorUnits(input.effectiveStake, "effective stake"); + if (!Number.isFinite(input.request.decimalOdds) || input.request.decimalOdds <= 1) { + throw new TypeError("decimal odds must be finite and greater than one"); + } + } catch (error) { + return { ok: false, code: "INVALID_INPUT", reason: errorMessage(error) }; + } + + const inserted = db + .query( + `INSERT INTO exposure_reservations ( + idempotency_key, partner_code, out_id, skin, provider, authorization_id, + requested_stake, effective_stake, market_id, decimal_odds, + status, reservation_expires_at_ms, created_at_ms, updated_at_ms + ) VALUES ( + $idempotencyKey, $partnerCode, $outId, $skin, $provider, $authorizationId, + $requestedStake, $effectiveStake, $marketId, $decimalOdds, + 'pending', $expiresAtMs, $nowMs, $nowMs + ) + ON CONFLICT(idempotency_key) DO NOTHING + RETURNING *`, + ) + .get({ + $idempotencyKey: input.request.idempotencyKey, + $partnerCode: input.authorization.partnerCode, + $outId: input.authorization.outId, + $skin: input.authorization.skin, + $provider: input.authorization.provider, + $authorizationId: input.authorization.id, + $requestedStake: input.request.requestedStake, + $effectiveStake: input.effectiveStake, + $marketId: input.request.marketId, + $decimalOdds: input.request.decimalOdds, + $expiresAtMs: input.expiresAtMs, + $nowMs: input.nowMs, + }) as ReservationRow | null; + if (inserted !== null) return { ok: true, created: true, reservation: mapReservation(inserted) }; + + const existing = getReservationByIdempotencyKey(db, input.request.idempotencyKey); + if (existing === null) throw new Error("execution idempotency lookup failed"); + if ( + existing.partnerCode !== input.request.partnerCode || + existing.outId !== input.request.outId || + existing.skin !== input.request.skin || + existing.authorizationId !== input.authorization.id || + existing.marketId !== input.request.marketId || + existing.requestedStake !== input.request.requestedStake || + existing.decimalOdds !== input.request.decimalOdds + ) { + return { + ok: false, + code: "IDEMPOTENCY_CONFLICT", + reason: "execution idempotency key is already bound to different bet terms", + }; + } + return { ok: true, created: false, reservation: existing }; +} + +export function claimReservationForPlacement( + db: Database, + input: { + id: ExposureReservationId; + placementOwner: PlacementOwner; + nowMs: number; + }, +): ExposureReservation | null { + assertTimestamp(input.nowMs, "placement claim time"); + const row = db + .query( + `UPDATE exposure_reservations + SET status = 'placing', placement_owner = $owner, updated_at_ms = $nowMs + WHERE id = $id + AND status = 'pending' + AND reservation_expires_at_ms > $nowMs + RETURNING *`, + ) + .get({ $id: input.id, $owner: input.placementOwner, $nowMs: input.nowMs }) as + | ReservationRow + | null; + return row === null ? null : mapReservation(row); +} + +export function confirmReservation( + db: Database, + input: { + id: ExposureReservationId; + placementOwner: PlacementOwner; + ticketId: TicketId; + providerResponse?: unknown; + nowMs: number; + }, +): ExposureReservation | null { + return completePlacement(db, { + ...input, + status: "confirmed", + failureReason: null, + }); +} + +export function rejectReservation( + db: Database, + input: { + id: ExposureReservationId; + placementOwner: PlacementOwner; + reason: string; + providerResponse?: unknown; + nowMs: number; + }, +): ExposureReservation | null { + return completePlacement(db, { + ...input, + status: "rejected", + ticketId: null, + failureReason: input.reason, + }); +} + +/** A thrown/ambiguous provider call remains exposure-bearing until reconciled. */ +export function markReservationUnknown( + db: Database, + input: { + id: ExposureReservationId; + placementOwner: PlacementOwner; + reason: string; + nowMs: number; + }, +): ExposureReservation | null { + return completePlacement(db, { + ...input, + status: "unknown", + ticketId: null, + providerResponse: { error: normalizeReason(input.reason) }, + failureReason: input.reason, + }); +} + +export function cancelPendingReservation( + db: Database, + id: ExposureReservationId, + nowMs = Date.now(), +): boolean { + assertTimestamp(nowMs, "reservation cancellation time"); + return ( + db + .query( + `UPDATE exposure_reservations + SET status = 'cancelled', updated_at_ms = $nowMs + WHERE id = $id AND status = 'pending'`, + ) + .run({ $id: id, $nowMs: nowMs }).changes === 1 + ); +} + +/** Release only never-dispatched reservations. Placing/unknown rows require reconciliation. */ +export function releaseExpiredReservations(db: Database, nowMs = Date.now()): number { + assertTimestamp(nowMs, "reservation release time"); + return db + .query( + `UPDATE exposure_reservations + SET status = 'cancelled', updated_at_ms = $nowMs + WHERE status = 'pending' AND reservation_expires_at_ms <= $nowMs`, + ) + .run({ $nowMs: nowMs }).changes; +} + +export function computeOutstandingExposure(db: Database, lane: ReservationLane): number { + return sumExposure( + db, + lane, + "status IN ('pending', 'placing', 'confirmed', 'unknown')", + ); +} + +export function computeReservedMarketLiquidity( + db: Database, + lane: ReservationLane, + marketId: BetRequest["marketId"], + decimalOdds: number, +): number { + const row = db + .query( + `SELECT COALESCE(SUM(effective_stake), 0) AS total + FROM exposure_reservations + WHERE partner_code = $partnerCode + AND out_id = $outId + AND skin = $skin + AND market_id = $marketId + AND decimal_odds = $decimalOdds + AND status IN ('pending', 'placing', 'confirmed', 'unknown')`, + ) + .get({ + $partnerCode: lane.partnerCode, + $outId: lane.outId, + $skin: lane.skin, + $marketId: marketId, + $decimalOdds: decimalOdds, + }) as { total: number }; + return assertAggregate(row.total); +} + +/** Reserved and placed stakes all consume the daily budget until conclusively rejected/cancelled. */ +export function computeDailyUsage( + db: Database, + lane: ReservationLane, + dayStartMs: number, +): number { + assertTimestamp(dayStartMs, "daily usage start"); + const row = db + .query( + `SELECT COALESCE(SUM(effective_stake), 0) AS total + FROM exposure_reservations + WHERE partner_code = $partnerCode + AND out_id = $outId + AND skin = $skin + AND created_at_ms >= $dayStartMs + AND status IN ('pending', 'placing', 'confirmed', 'unknown', 'settled')`, + ) + .get({ + $partnerCode: lane.partnerCode, + $outId: lane.outId, + $skin: lane.skin, + $dayStartMs: dayStartMs, + }) as { total: number }; + return assertAggregate(row.total); +} + +export function getReservation( + db: Database, + id: ExposureReservationId, +): ExposureReservation | null { + const row = db + .query("SELECT * FROM exposure_reservations WHERE id = $id") + .get({ $id: id }) as ReservationRow | null; + return row === null ? null : mapReservation(row); +} + +export function getReservationByIdempotencyKey( + db: Database, + key: ExecutionIdempotencyKey, +): ExposureReservation | null { + const row = db + .query("SELECT * FROM exposure_reservations WHERE idempotency_key = $key") + .get({ $key: key }) as ReservationRow | null; + return row === null ? null : mapReservation(row); +} + +/** Reconcile an ambiguous placement after querying the provider by idempotency key. */ +export function reconcileUnknownAsConfirmed( + db: Database, + input: { + id: ExposureReservationId; + ticketId: TicketId; + providerResponse?: unknown; + nowMs: number; + }, +): ExposureReservation | null { + return reconcileUnknown(db, { + ...input, + status: "confirmed", + failureReason: null, + }); +} + +export function reconcileUnknownAsRejected( + db: Database, + input: { + id: ExposureReservationId; + reason: string; + providerResponse?: unknown; + nowMs: number; + }, +): ExposureReservation | null { + return reconcileUnknown(db, { + id: input.id, + status: "rejected", + ticketId: null, + providerResponse: input.providerResponse, + failureReason: input.reason, + nowMs: input.nowMs, + }); +} + +export function settleConfirmedReservation( + db: Database, + id: ExposureReservationId, + nowMs = Date.now(), +): ExposureReservation | null { + assertTimestamp(nowMs, "reservation settlement time"); + const row = db + .query( + `UPDATE exposure_reservations + SET status = 'settled', updated_at_ms = $nowMs + WHERE id = $id AND status = 'confirmed' + RETURNING *`, + ) + .get({ $id: id, $nowMs: nowMs }) as ReservationRow | null; + return row === null ? null : mapReservation(row); +} + +function completePlacement( + db: Database, + input: { + id: ExposureReservationId; + placementOwner: PlacementOwner; + status: "confirmed" | "rejected" | "unknown"; + ticketId: TicketId | null; + providerResponse?: unknown; + failureReason: string | null; + nowMs: number; + }, +): ExposureReservation | null { + assertTimestamp(input.nowMs, "placement completion time"); + const responseJson = serializeProviderResponse(input.providerResponse); + const failureReason = + input.failureReason === null ? null : normalizeReason(input.failureReason); + const row = db + .query( + `UPDATE exposure_reservations + SET status = $status, + ticket_id = $ticketId, + provider_response_json = $responseJson, + failure_reason = $failureReason, + updated_at_ms = $nowMs + WHERE id = $id + AND status = 'placing' + AND placement_owner = $owner + RETURNING *`, + ) + .get({ + $status: input.status, + $ticketId: input.ticketId, + $responseJson: responseJson, + $failureReason: failureReason, + $nowMs: input.nowMs, + $id: input.id, + $owner: input.placementOwner, + }) as ReservationRow | null; + return row === null ? null : mapReservation(row); +} + +function reconcileUnknown( + db: Database, + input: { + id: ExposureReservationId; + status: "confirmed" | "rejected"; + ticketId: TicketId | null; + providerResponse?: unknown; + failureReason: string | null; + nowMs: number; + }, +): ExposureReservation | null { + assertTimestamp(input.nowMs, "reservation reconciliation time"); + const row = db + .query( + `UPDATE exposure_reservations + SET status = $status, + ticket_id = $ticketId, + provider_response_json = $responseJson, + failure_reason = $failureReason, + updated_at_ms = $nowMs + WHERE id = $id AND status = 'unknown' + RETURNING *`, + ) + .get({ + $status: input.status, + $ticketId: input.ticketId, + $responseJson: serializeProviderResponse(input.providerResponse), + $failureReason: + input.failureReason === null ? null : normalizeReason(input.failureReason), + $nowMs: input.nowMs, + $id: input.id, + }) as ReservationRow | null; + return row === null ? null : mapReservation(row); +} + +function sumExposure(db: Database, lane: ReservationLane, statusSql: string): number { + const row = db + .query( + `SELECT COALESCE(SUM(effective_stake), 0) AS total + FROM exposure_reservations + WHERE partner_code = $partnerCode + AND out_id = $outId + AND skin = $skin + AND ${statusSql}`, + ) + .get({ + $partnerCode: lane.partnerCode, + $outId: lane.outId, + $skin: lane.skin, + }) as { total: number }; + return assertAggregate(row.total); +} + +function mapReservation(row: ReservationRow): ExposureReservation { + return { + id: asExposureReservationId(row.id), + idempotencyKey: asExecutionIdempotencyKey(row.idempotency_key), + partnerCode: asPartnerCode(row.partner_code), + outId: asOutId(row.out_id), + skin: asSkinId(row.skin), + provider: asProviderId(row.provider), + authorizationId: asAuthorizationId(row.authorization_id), + requestedStake: row.requested_stake, + effectiveStake: row.effective_stake, + marketId: asMarketId(row.market_id), + decimalOdds: row.decimal_odds, + status: row.status, + reservationExpiresAtMs: row.reservation_expires_at_ms, + placementOwner: row.placement_owner === null ? null : asPlacementOwner(row.placement_owner), + ticketId: row.ticket_id === null ? null : asTicketId(row.ticket_id), + providerResponse: + row.provider_response_json === null ? null : JSON.parse(row.provider_response_json), + failureReason: row.failure_reason, + createdAtMs: row.created_at_ms, + updatedAtMs: row.updated_at_ms, + }; +} + +function serializeProviderResponse(value: unknown): string | null { + if (value === undefined) return null; + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new TypeError("provider response summary is not JSON serializable"); + if (serialized.length > 16_384) { + throw new TypeError("provider response summary must be at most 16384 characters"); + } + return serialized; +} + +function normalizeReason(reason: string): string { + return (reason.trim() || "provider outcome unavailable").slice(0, 2_048); +} + +function assertTimestamp(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${label} must be a non-negative epoch-millisecond integer`); + } +} + +function assertPositiveMinorUnits(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer in minor units`); + } +} + +function assertAggregate(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("exposure aggregate is outside the safe integer range"); + } + return value; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "invalid exposure reservation input"; +} diff --git a/src/partner/execution/sql.ts b/src/partner/execution/sql.ts new file mode 100644 index 0000000..8b8d8f5 --- /dev/null +++ b/src/partner/execution/sql.ts @@ -0,0 +1,93 @@ +import type { Database } from "bun:sqlite"; +import { migrateAuthorizationSchema } from "../authorization/sql.ts"; + +export const EXECUTION_MIGRATIONS = [ + { + id: "001_exposure_reservations", + sql: ` + CREATE TABLE IF NOT EXISTS exposure_reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + idempotency_key TEXT NOT NULL UNIQUE CHECK (length(idempotency_key) BETWEEN 1 AND 256), + partner_code TEXT NOT NULL, + out_id TEXT NOT NULL, + skin TEXT NOT NULL, + provider TEXT NOT NULL, + authorization_id INTEGER NOT NULL REFERENCES account_authorizations(id), + requested_stake INTEGER NOT NULL CHECK ( + typeof(requested_stake) = 'integer' AND requested_stake > 0 + ), + effective_stake INTEGER NOT NULL CHECK ( + typeof(effective_stake) = 'integer' AND effective_stake > 0 + ), + market_id TEXT NOT NULL, + decimal_odds REAL NOT NULL CHECK (decimal_odds > 1.0), + status TEXT NOT NULL DEFAULT 'pending' CHECK ( + status IN ('pending', 'placing', 'confirmed', 'rejected', 'unknown', 'cancelled', 'settled') + ), + reservation_expires_at_ms INTEGER NOT NULL CHECK ( + typeof(reservation_expires_at_ms) = 'integer' AND reservation_expires_at_ms >= 0 + ), + placement_owner TEXT, + ticket_id TEXT, + provider_response_json TEXT CHECK ( + provider_response_json IS NULL OR json_valid(provider_response_json) + ), + failure_reason TEXT, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + CHECK (status != 'placing' OR placement_owner IS NOT NULL), + CHECK (status != 'confirmed' OR ticket_id IS NOT NULL) + ); + + CREATE INDEX IF NOT EXISTS idx_exposure_reservations_lane_status + ON exposure_reservations (partner_code, out_id, skin, status); + CREATE INDEX IF NOT EXISTS idx_exposure_reservations_pending_expiry + ON exposure_reservations (reservation_expires_at_ms, id) + WHERE status = 'pending'; + CREATE INDEX IF NOT EXISTS idx_exposure_reservations_daily + ON exposure_reservations (partner_code, out_id, skin, created_at_ms, status); + `, + }, +] as const; + +type MigrationRow = { migrationId: string }; // brand-ok — internal migration wire value + +/** Apply authorization prerequisites and then all execution migrations. */ +export function migrateExecutionSchema(db: Database, nowMs = Date.now()): string[] { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new TypeError("migration time must be a non-negative epoch-millisecond integer"); + } + migrateAuthorizationSchema(db, nowMs); + db.run("PRAGMA foreign_keys = ON"); + db.run(`CREATE TABLE IF NOT EXISTS _partner_execution_migrations ( + id TEXT PRIMARY KEY, + applied_at_ms INTEGER NOT NULL + )`); + const applied = new Set( + ( + db + .query("SELECT id AS migrationId FROM _partner_execution_migrations") + .all() as MigrationRow[] + ).map((row) => row.migrationId), + ); + const newlyApplied: string[] = []; + for (const migration of EXECUTION_MIGRATIONS) { + if (applied.has(migration.id)) continue; + db.run("BEGIN IMMEDIATE"); + try { + db.exec(migration.sql); + db.query( + `INSERT INTO _partner_execution_migrations (id, applied_at_ms) + VALUES ($id, $nowMs)`, + ).run({ $id: migration.id, $nowMs: nowMs }); + db.run("COMMIT"); + newlyApplied.push(migration.id); + } catch (error) { + db.run("ROLLBACK"); + throw error; + } + } + return newlyApplied; +} + +export const ensureExecutionSchema = migrateExecutionSchema; diff --git a/src/partner/index.ts b/src/partner/index.ts index ce9f1b6..ba48c47 100644 --- a/src/partner/index.ts +++ b/src/partner/index.ts @@ -15,6 +15,8 @@ export type { PartnerSportLeague, } from "./types.ts"; +export * from "./execution/index.ts"; + export { credentialsFromFantasyProfile, fantasyDeskEnvPresence, diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index b89595c..55f6901 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -18,7 +18,8 @@ import { openEventStore } from "../institutions/event-store/open-db.ts"; import { asAuthorizationReceiptLeaseOwner, } from "../partner/authorization/outbox.ts"; -import { migrateAuthorizationSchema } from "../partner/authorization/sql.ts"; +import { runExecutionMaintenance } from "../partner/execution/maintenance.ts"; +import { migrateExecutionSchema } from "../partner/execution/sql.ts"; import { addSubscriber, removeSubscriber, listSubscribers } from "./subscribers.ts"; import { joinPath } from "../research/paths.ts"; import { handleAuthorizationCommand } from "./authorization-commands.ts"; @@ -190,7 +191,7 @@ export async function handleCommand( async function pollLoop() { const authorizationDb = openEventStore(); - migrateAuthorizationSchema(authorizationDb); + migrateExecutionSchema(authorizationDb); const bot = await getMe(); const commandContext: TelegramBotCommandContext = { authorizationDb, @@ -219,6 +220,10 @@ async function pollLoop() { limit: 25, clock: Date.now, }); + const maintenance = runExecutionMaintenance(authorizationDb); + if (maintenance.releasedPending > 0) { + console.warn("Execution reservation maintenance", maintenance); + } } catch (err) { console.error("Poll error:", err); await Bun.sleep(5000); diff --git a/tests/bot/kalshi-client.test.ts b/tests/bot/kalshi-client.test.ts index f6568c1..68c830d 100644 --- a/tests/bot/kalshi-client.test.ts +++ b/tests/bot/kalshi-client.test.ts @@ -108,6 +108,13 @@ describe("kalshi-client placeOrder", () => { expect(body.post_only).toBe(false); }); + test("forwards an explicit execution idempotency UUID", async () => { + const { client, calls } = makeClient({ responses: [okOrder()] }); + const clientOrderId = "f47ac10b-58cc-5372-a567-0e02b2c3d479"; + await client.placeOrder({ ...ORDER, dryRun: false, clientOrderId }); + expect((calls[0]!.body as Record).client_order_id).toBe(clientOrderId); + }); + test("429 backs off and retries with the same request", async () => { const { client, calls, sleeps } = makeClient({ responses: [new Response("rate limited", { status: 429 }), okOrder("order-after-retry")], diff --git a/tests/partner/execution/executor.test.ts b/tests/partner/execution/executor.test.ts new file mode 100644 index 0000000..2fb113c --- /dev/null +++ b/tests/partner/execution/executor.test.ts @@ -0,0 +1,269 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + asTelegramUserId, + type ApprovedAuthorization, + type AuthorizationPolicy, +} from "../../../src/partner/authorization/domain.ts"; +import { + approveAuthorizationRequest, + createAuthorizationRequest, +} from "../../../src/partner/authorization/service.ts"; +import { + asExecutionIdempotencyKey, + asMarketId, + asTicketId, + type BetRequest, + type ExecutionDependencies, +} from "../../../src/partner/execution/domain.ts"; +import { executeAuthorizedBet } from "../../../src/partner/execution/executor.ts"; +import { + computeOutstandingExposure, + getReservation, +} from "../../../src/partner/execution/reservation.ts"; +import { migrateExecutionSchema } from "../../../src/partner/execution/sql.ts"; + +const NOW_MS = 1_700_000_000_000; + +function policy(overrides: Partial = {}): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("provider-x"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 5_000, + maxWin: 20_000, + maxWinBasis: "profit", + dailyLimit: 10_000, + exposureLimit: 1_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 1_000, + expiresAtMs: NOW_MS + 60_000, + ...overrides, + }; +} + +function setup(p = policy()): { db: Database; authorization: ApprovedAuthorization } { + const db = new Database(":memory:"); + migrateExecutionSchema(db, NOW_MS); + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id, created_at_ms + ) VALUES ($partner, $out, '789', $nowMs)`, + ).run({ $partner: p.partnerCode, $out: p.outId, $nowMs: NOW_MS }); + const request = createAuthorizationRequest(db, { + policy: p, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + telegramMessageId: asTelegramMessageId("100"), + nowMs: NOW_MS, + }); + if (!request.ok) throw new Error(request.reason); + const approved = approveAuthorizationRequest(db, { + requestId: request.request.id, + currentPolicy: p, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + telegramMessageId: asTelegramMessageId("101"), + approvingUserId: asTelegramUserId("789"), + nowMs: NOW_MS, + }); + if (!approved.ok) throw new Error(approved.reason); + return { db, authorization: approved.authorization }; +} + +function request(key: string, requestedStake = 700): BetRequest { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + skin: asSkinId("main"), + marketId: asMarketId("market-1"), + idempotencyKey: asExecutionIdempotencyKey(key), + requestedStake, + decimalOdds: 2, + }; +} + +function dependencies( + currentPolicy: AuthorizationPolicy, + placeBet: ExecutionDependencies["placeBet"], +): ExecutionDependencies { + return { + now: () => NOW_MS + 1, + loadSnapshot: () => ({ + currentPolicy, + oddsFresh: true, + providerSessionValid: true, + riskHealthy: true, + sitePerBetMax: 5_000, + availableBalance: 10_000, + marketLiquidity: 10_000, + }), + placeBet, + }; +} + +describe("authorized bet execution", () => { + test("reserves, places, confirms, queues a receipt, and replays without a second call", async () => { + const p = policy(); + const { db } = setup(p); + let calls = 0; + const deps = dependencies(p, async ({ effectiveStake, idempotencyKey }) => { + calls += 1; + expect(effectiveStake).toBe(700); + expect(String(idempotencyKey)).toBe("bet-success"); + return { accepted: true, ticketId: asTicketId("ticket-1"), responseSummary: { ok: true } }; + }); + + const first = await executeAuthorizedBet(db, request("bet-success"), deps); + const replay = await executeAuthorizedBet(db, request("bet-success"), { + ...deps, + loadSnapshot: () => { + throw new Error("replay must not reload state"); + }, + }); + expect(first).toMatchObject({ success: true, code: "BET_CONFIRMED", effectiveStake: 700 }); + expect(replay).toMatchObject({ success: true, code: "ALREADY_CONFIRMED" }); + expect(calls).toBe(1); + if (first.success) expect(getReservation(db, first.reservationId)?.status).toBe("confirmed"); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_receipt_outbox").get(), + ).toEqual({ count: 1 }); + db.close(); + }); + + test("serializes reservations so concurrent exposure cannot exceed the policy", async () => { + const p = policy({ exposureLimit: 1_000 }); + const { db, authorization } = setup(p); + const stakes: number[] = []; + const deps = dependencies(p, async ({ effectiveStake }) => { + stakes.push(effectiveStake); + return { accepted: true, ticketId: asTicketId(`ticket-${stakes.length}`) }; + }); + expect((await executeAuthorizedBet(db, request("bet-a", 700), deps)).success).toBeTrue(); + const second = await executeAuthorizedBet(db, request("bet-b", 700), deps); + expect(second).toMatchObject({ success: true, effectiveStake: 300 }); + expect(stakes).toEqual([700, 300]); + expect( + computeOutstandingExposure(db, { + partnerCode: authorization.partnerCode, + outId: authorization.outId, + skin: authorization.skin, + }), + ).toBe(1_000); + db.close(); + }); + + test("does not reuse a stale market-liquidity snapshot", async () => { + const p = policy({ exposureLimit: null }); + const { db } = setup(p); + const stakes: number[] = []; + const deps = dependencies(p, async ({ effectiveStake }) => { + stakes.push(effectiveStake); + return { accepted: true, ticketId: asTicketId(`ticket-${stakes.length}`) }; + }); + deps.loadSnapshot = () => ({ + currentPolicy: p, + oddsFresh: true, + providerSessionValid: true, + riskHealthy: true, + sitePerBetMax: 5_000, + availableBalance: 10_000, + marketLiquidity: 1_000, + }); + await executeAuthorizedBet(db, request("liquidity-a", 700), deps); + await executeAuthorizedBet(db, request("liquidity-b", 700), deps); + expect(stakes).toEqual([700, 300]); + db.close(); + }); + + test("fails closed for stale policy and authorization revoked during snapshot loading", async () => { + const p = policy(); + for (const mode of ["stale", "revoked"] as const) { + const { db, authorization } = setup(p); + let calls = 0; + const deps = dependencies(p, async () => { + calls += 1; + return { accepted: true, ticketId: asTicketId("must-not-place") }; + }); + deps.loadSnapshot = () => { + if (mode === "revoked") { + db.query( + `UPDATE account_authorizations + SET revoked_at_ms = $nowMs WHERE id = $id`, + ).run({ $nowMs: NOW_MS + 1, $id: authorization.id }); + } + return { + currentPolicy: mode === "stale" ? policy({ maxStake: 4_999 }) : p, + oddsFresh: true, + providerSessionValid: true, + riskHealthy: true, + sitePerBetMax: 5_000, + availableBalance: 10_000, + marketLiquidity: 10_000, + }; + }; + const result = await executeAuthorizedBet(db, request(`bet-${mode}`), deps); + expect(result.success).toBeFalse(); + expect(result.code).toBe(mode === "stale" ? "GATE_DENIED" : "NO_ACTIVE_AUTHORIZATION"); + expect(calls).toBe(0); + expect(db.query("SELECT count(*) AS count FROM exposure_reservations").get()).toEqual({ + count: 0, + }); + db.close(); + } + }); + + test("distinguishes known rejection from ambiguous provider failure", async () => { + const p = policy(); + for (const mode of ["rejected", "unknown"] as const) { + const { db, authorization } = setup(p); + const result = await executeAuthorizedBet( + db, + request(`bet-${mode}`), + dependencies(p, async () => { + if (mode === "unknown") throw new Error("socket reset after write"); + return { accepted: false, reason: "market suspended", responseSummary: { code: 409 } }; + }), + ); + expect(result).toMatchObject({ + success: false, + code: mode === "unknown" ? "PROVIDER_OUTCOME_UNKNOWN" : "PROVIDER_REJECTED", + }); + const row = db.query("SELECT status FROM exposure_reservations").get() as { status: string }; + expect(row.status).toBe(mode); + const exposure = computeOutstandingExposure(db, { + partnerCode: authorization.partnerCode, + outId: authorization.outId, + skin: authorization.skin, + }); + expect(exposure).toBe(mode === "unknown" ? 700 : 0); + expect( + db.query("SELECT count(*) AS count FROM account_authorization_receipt_outbox").get(), + ).toEqual({ count: 1 }); + db.close(); + } + }); + + test("rejects reuse of an idempotency key for different terms", async () => { + const p = policy(); + const { db } = setup(p); + const deps = dependencies(p, async () => ({ + accepted: true, + ticketId: asTicketId("ticket-1"), + })); + expect((await executeAuthorizedBet(db, request("same-key", 100), deps)).success).toBeTrue(); + const conflict = await executeAuthorizedBet(db, request("same-key", 101), deps); + expect(conflict).toMatchObject({ success: false, code: "RESERVATION_CONFLICT" }); + db.close(); + }); +}); diff --git a/tests/partner/execution/kalshi.test.ts b/tests/partner/execution/kalshi.test.ts new file mode 100644 index 0000000..ed0a957 --- /dev/null +++ b/tests/partner/execution/kalshi.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import type { KalshiClient } from "../../../src/bot/kalshi-client.ts"; +import { + asAuthorizationId, + asAuthorizationRequestId, + asCurrencyCode, + asOutId, + asPartnerCode, + asPolicyHash, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramUserId, +} from "../../../src/partner/authorization/domain.ts"; +import { + asExecutionIdempotencyKey, + asMarketId, +} from "../../../src/partner/execution/domain.ts"; +import { + createKalshiExecutionPlacer, + executionIdempotencyKeyToUuid, +} from "../../../src/partner/execution/kalshi.ts"; + +describe("Kalshi authorized execution adapter", () => { + test("maps a stable execution key to a deterministic UUID", () => { + const first = executionIdempotencyKeyToUuid("partner:out:bet-1"); + expect(first).toBe(executionIdempotencyKeyToUuid("partner:out:bet-1")); + expect(first).not.toBe(executionIdempotencyKeyToUuid("partner:out:bet-2")); + expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + test("forces live placement and forwards deterministic provider idempotency", async () => { + const calls: Array> = []; + const client = { + environment: "demo" as const, + placeOrder: async (order) => { + calls.push(order as unknown as Record); + return { orderId: "order-1", dryRun: false }; + }, + } satisfies Pick; + const place = createKalshiExecutionPlacer(client, ({ request, effectiveStake }) => ({ + ticker: request.marketId, + side: "yes", + count: effectiveStake, + priceCents: 42, + })); + const currentPolicy = { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("kalshi"), + skin: asSkinId("demo"), + scope: "live_trade" as const, + maxStake: 100, + maxWin: 100, + maxWinBasis: "profit" as const, + dailyLimit: null, + exposureLimit: null, + currency: asCurrencyCode("USD"), + validFromMs: 1, + expiresAtMs: null, + }; + const result = await place({ + authorization: { + ...currentPolicy, + id: asAuthorizationId(1), + requestId: asAuthorizationRequestId(1), + approvalHash: asPolicyHash("a".repeat(64)), + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: null, + telegramMessageId: asTelegramMessageId("1"), + approvingUserId: asTelegramUserId("2"), + revokedAtMs: null, + createdAtMs: 1, + updatedAtMs: 1, + }, + request: { + partnerCode: currentPolicy.partnerCode, + outId: currentPolicy.outId, + skin: currentPolicy.skin, + marketId: asMarketId("KXTEST"), + idempotencyKey: asExecutionIdempotencyKey("bet-1"), + requestedStake: 2, + decimalOdds: 2, + }, + effectiveStake: 2, + idempotencyKey: asExecutionIdempotencyKey("bet-1"), + }); + expect(result).toMatchObject({ accepted: true, ticketId: "order-1" }); + expect(calls[0]).toMatchObject({ dryRun: false, count: 2 }); + expect(calls[0]?.clientOrderId).toBe(executionIdempotencyKeyToUuid("bet-1")); + }); +}); diff --git a/tests/partner/execution/reservation.test.ts b/tests/partner/execution/reservation.test.ts new file mode 100644 index 0000000..90601b0 --- /dev/null +++ b/tests/partner/execution/reservation.test.ts @@ -0,0 +1,242 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramTopicId, + asTelegramUserId, + type ApprovedAuthorization, + type AuthorizationPolicy, +} from "../../../src/partner/authorization/domain.ts"; +import { + approveAuthorizationRequest, + createAuthorizationRequest, +} from "../../../src/partner/authorization/service.ts"; +import { + asExecutionIdempotencyKey, + asMarketId, + asPlacementOwner, + asTicketId, +} from "../../../src/partner/execution/domain.ts"; +import { + claimReservationForPlacement, + computeDailyUsage, + computeOutstandingExposure, + confirmReservation, + createPendingReservation, + getReservation, + markReservationUnknown, + reconcileUnknownAsConfirmed, + rejectReservation, + releaseExpiredReservations, + settleConfirmedReservation, +} from "../../../src/partner/execution/reservation.ts"; +import { migrateExecutionSchema } from "../../../src/partner/execution/sql.ts"; + +const NOW_MS = 1_700_000_000_000; + +function policy(): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("provider-x"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 50_000, + maxWin: 100_000, + maxWinBasis: "profit", + dailyLimit: 100_000, + exposureLimit: 50_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 1_000, + expiresAtMs: NOW_MS + 60_000, + }; +} + +function setup(): { db: Database; authorization: ApprovedAuthorization } { + const db = new Database(":memory:"); + expect(migrateExecutionSchema(db, NOW_MS)).toEqual(["001_exposure_reservations"]); + expect(migrateExecutionSchema(db, NOW_MS + 1)).toEqual([]); + const p = policy(); + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id, created_at_ms + ) VALUES ($partner, $out, '789', $nowMs)`, + ).run({ $partner: p.partnerCode, $out: p.outId, $nowMs: NOW_MS }); + const request = createAuthorizationRequest(db, { + policy: p, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + telegramMessageId: asTelegramMessageId("100"), + nowMs: NOW_MS, + }); + if (!request.ok) throw new Error(request.reason); + const approved = approveAuthorizationRequest(db, { + requestId: request.request.id, + currentPolicy: p, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: asTelegramTopicId("7"), + telegramMessageId: asTelegramMessageId("101"), + approvingUserId: asTelegramUserId("789"), + nowMs: NOW_MS, + }); + if (!approved.ok) throw new Error(approved.reason); + return { db, authorization: approved.authorization }; +} + +function request(key = "bet-1", stake = 1_000) { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + skin: asSkinId("main"), + marketId: asMarketId("market-1"), + idempotencyKey: asExecutionIdempotencyKey(key), + requestedStake: stake, + decimalOdds: 2, + }; +} + +function pending(db: Database, authorization: ApprovedAuthorization, key = "bet-1") { + const result = createPendingReservation(db, { + authorization, + request: request(key), + effectiveStake: 800, + expiresAtMs: NOW_MS + 30_000, + nowMs: NOW_MS, + }); + if (!result.ok) throw new Error(result.reason); + return result; +} + +describe("execution exposure reservations", () => { + test("migrates prerequisites and creates an idempotent pending reservation", () => { + const { db, authorization } = setup(); + const first = pending(db, authorization); + const replay = pending(db, authorization); + expect(first.created).toBeTrue(); + expect(replay.created).toBeFalse(); + expect(replay.reservation.id).toBe(first.reservation.id); + + const conflict = createPendingReservation(db, { + authorization, + request: request("bet-1", 1_001), + effectiveStake: 800, + expiresAtMs: NOW_MS + 30_000, + nowMs: NOW_MS, + }); + expect(conflict).toMatchObject({ ok: false, code: "IDEMPOTENCY_CONFLICT" }); + db.close(); + }); + + test("claims and confirms only with the placement owner", () => { + const { db, authorization } = setup(); + const created = pending(db, authorization).reservation; + const owner = asPlacementOwner("worker-a"); + const claimed = claimReservationForPlacement(db, { + id: created.id, + placementOwner: owner, + nowMs: NOW_MS + 1, + }); + expect(claimed?.status).toBe("placing"); + expect( + confirmReservation(db, { + id: created.id, + placementOwner: asPlacementOwner("worker-b"), + ticketId: asTicketId("ticket-1"), + nowMs: NOW_MS + 2, + }), + ).toBeNull(); + const confirmed = confirmReservation(db, { + id: created.id, + placementOwner: owner, + ticketId: asTicketId("ticket-1"), + providerResponse: { accepted: true }, + nowMs: NOW_MS + 2, + }); + expect(confirmed).toMatchObject({ status: "confirmed", ticketId: "ticket-1" }); + db.close(); + }); + + test("counts reserved and ambiguous exposure but releases only undispatched expiry", () => { + const { db, authorization } = setup(); + const lane = { + partnerCode: authorization.partnerCode, + outId: authorization.outId, + skin: authorization.skin, + }; + const expiring = pending(db, authorization, "expire").reservation; + const unknown = pending(db, authorization, "unknown").reservation; + const owner = asPlacementOwner("worker-a"); + claimReservationForPlacement(db, { id: unknown.id, placementOwner: owner, nowMs: NOW_MS + 1 }); + markReservationUnknown(db, { + id: unknown.id, + placementOwner: owner, + reason: "timeout", + nowMs: NOW_MS + 2, + }); + expect(computeOutstandingExposure(db, lane)).toBe(1_600); + expect(computeDailyUsage(db, lane, NOW_MS - 1)).toBe(1_600); + + expect(releaseExpiredReservations(db, NOW_MS + 30_000)).toBe(1); + expect(getReservation(db, expiring.id)?.status).toBe("cancelled"); + expect(getReservation(db, unknown.id)?.status).toBe("unknown"); + expect(computeOutstandingExposure(db, lane)).toBe(800); + db.close(); + }); + + test("known provider rejection releases exposure and daily budget", () => { + const { db, authorization } = setup(); + const created = pending(db, authorization).reservation; + const owner = asPlacementOwner("worker-a"); + claimReservationForPlacement(db, { id: created.id, placementOwner: owner, nowMs: NOW_MS + 1 }); + rejectReservation(db, { + id: created.id, + placementOwner: owner, + reason: "limit moved", + providerResponse: { code: "LIMIT" }, + nowMs: NOW_MS + 2, + }); + const lane = { + partnerCode: authorization.partnerCode, + outId: authorization.outId, + skin: authorization.skin, + }; + expect(computeOutstandingExposure(db, lane)).toBe(0); + expect(computeDailyUsage(db, lane, NOW_MS - 1)).toBe(0); + db.close(); + }); + + test("reconciles an ambiguous placement before settlement releases exposure", () => { + const { db, authorization } = setup(); + const created = pending(db, authorization).reservation; + const owner = asPlacementOwner("worker-a"); + claimReservationForPlacement(db, { id: created.id, placementOwner: owner, nowMs: NOW_MS + 1 }); + markReservationUnknown(db, { + id: created.id, + placementOwner: owner, + reason: "timeout", + nowMs: NOW_MS + 2, + }); + const reconciled = reconcileUnknownAsConfirmed(db, { + id: created.id, + ticketId: asTicketId("ticket-late"), + providerResponse: { foundByIdempotencyKey: true }, + nowMs: NOW_MS + 3, + }); + expect(reconciled).toMatchObject({ status: "confirmed", ticketId: "ticket-late" }); + expect(settleConfirmedReservation(db, created.id, NOW_MS + 4)?.status).toBe("settled"); + expect( + computeOutstandingExposure(db, { + partnerCode: authorization.partnerCode, + outId: authorization.outId, + skin: authorization.skin, + }), + ).toBe(0); + db.close(); + }); +}); From 9e050c88109f5854ad8eb3c9d1f8dd941980f2ee Mon Sep 17 00:00:00 2001 From: nolarose Date: Thu, 6 Aug 2026 03:07:13 -0500 Subject: [PATCH 4/7] feat(partner): bind Kalshi execution to live snapshots Migrate order creation to the V2 fixed-point contract, normalize immediate fill state, distinguish provider rejection from unknown outcomes, and bind authorization liquidity to persisted top-of-book snapshots. Quantize reservations to exact contract cost so accounting matches submitted orders.\n\nFocused execution tests, typecheck, Bun guard, glossary, and partner validation pass. The full suite retains pre-existing ops-server TDZ and Bun.TOML.stringify failures reproduced at base commit 920f7aa. --- src/bot/kalshi-client.ts | 130 ++++++++-- src/partner/execution/domain.ts | 2 + src/partner/execution/executor.ts | 29 ++- src/partner/execution/index.ts | 1 + src/partner/execution/kalshi-snapshot.ts | 240 ++++++++++++++++++ src/partner/execution/kalshi.ts | 113 ++++++++- tests/bot/kalshi-client.test.ts | 49 +++- tests/partner/execution/executor.test.ts | 24 ++ .../partner/execution/kalshi-snapshot.test.ts | 173 +++++++++++++ tests/partner/execution/kalshi.test.ts | 118 ++++++++- 10 files changed, 836 insertions(+), 43 deletions(-) create mode 100644 src/partner/execution/kalshi-snapshot.ts create mode 100644 tests/partner/execution/kalshi-snapshot.test.ts diff --git a/src/bot/kalshi-client.ts b/src/bot/kalshi-client.ts index b8b2f94..ab4fb76 100644 --- a/src/bot/kalshi-client.ts +++ b/src/bot/kalshi-client.ts @@ -1,4 +1,4 @@ -// @see https://docs.kalshi.com/api-reference/order/create-order +// @see https://docs.kalshi.com/api-reference/orders/create-order-v2 // @see https://docs.kalshi.com/getting_started/rate_limits // @see https://bun.com/docs/runtime/environment-variables /** @@ -33,9 +33,35 @@ export type KalshiOrderRequest = { export type KalshiOrderResult = { orderId: string; + clientOrderId: string; + fillCount: number; + remainingCount: number; + averageFillPriceCents: number | null; + averageFeePaidCents: number | null; + processedAtMs: number | null; dryRun: boolean; }; +/** A provider response that proves the order was not accepted. */ +export class KalshiRequestRejectedError extends Error { + constructor( + message: string, + readonly status: number, + readonly providerCode: string | null = null, + ) { + super(message); + this.name = "KalshiRequestRejectedError"; + } +} + +/** A transport/server result that cannot prove whether an order was accepted. */ +export class KalshiRequestOutcomeUnknownError extends Error { + constructor(message: string, readonly status: number | null = null) { + super(message); + this.name = "KalshiRequestOutcomeUnknownError"; + } +} + export const KALSHI_REST_BASE = { demo: OFFICIAL_URLS.kalshi.tradeApiV2BaseDemo, prod: OFFICIAL_URLS.kalshi.tradeApiV2Base, @@ -86,6 +112,35 @@ function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } +function fixedCount(value: unknown): number { + const parsed = typeof value === "string" || typeof value === "number" ? Number(value) : 0; + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return parsed; +} + +function fixedDollarsToCents(value: unknown): number | null { + if (typeof value !== "string" && typeof value !== "number") return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return Math.round(parsed * 100); +} + +function parseKalshiErrorDetail(text: string): { code: string | null; message: string } { + const fallback = text.trim().slice(0, 200); + if (!fallback) return { code: null, message: "" }; + try { + const parsed: unknown = JSON.parse(text); + const envelope = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : parsed; + if (!isRecord(envelope)) return { code: null, message: fallback }; + const code = typeof envelope.code === "string" ? envelope.code.slice(0, 80) : null; + const message = + typeof envelope.message === "string" ? envelope.message.slice(0, 200) : fallback; + return { code, message }; + } catch { + return { code: null, message: fallback }; + } +} + /** Exponential backoff with jitter — 429s carry no Retry-After. */ export function backoffMs(attempt: number, random: () => number = Math.random): number { return Math.min(10_000, 500 * 2 ** attempt) + Math.floor(random() * 250); @@ -136,9 +191,12 @@ export function createKalshiClient(options: KalshiClientOptions = {}): KalshiCli const retryable = res.status === 429 || res.status >= 500; if (!retryable || attempt >= maxRetries) { const text = await res.text().catch(() => ""); - throw new Error( - `Kalshi ${method} ${path}: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ""}`, - ); + const detail = parseKalshiErrorDetail(text); + const message = `Kalshi ${method} ${path}: ${res.status} ${res.statusText}${detail.message ? ` — ${detail.message}` : ""}`; + if (res.status >= 400 && res.status < 500) { + throw new KalshiRequestRejectedError(message, res.status, detail.code); + } + throw new KalshiRequestOutcomeUnknownError(message, res.status); } await sleep(backoffMs(attempt)); } @@ -153,7 +211,16 @@ export function createKalshiClient(options: KalshiClientOptions = {}): KalshiCli async function placeOrder(request: KalshiOrderRequest): Promise { if (request.dryRun) { - return { orderId: `dry-${request.ticker}-${Date.now()}`, dryRun: true }; + return { + orderId: `dry-${request.ticker}-${Date.now()}`, + clientOrderId: request.clientOrderId ?? crypto.randomUUID(), + fillCount: 0, + remainingCount: request.count, + averageFillPriceCents: null, + averageFeePaidCents: null, + processedAtMs: null, + dryRun: true, + }; } if (request.count < 1 || request.priceCents < 1 || request.priceCents > 99) { throw new Error( @@ -161,28 +228,44 @@ export function createKalshiClient(options: KalshiClientOptions = {}): KalshiCli ); } await governCreate(); + const clientOrderId = request.clientOrderId ?? crypto.randomUUID(); + const yesPriceCents = request.side === "yes" ? request.priceCents : 100 - request.priceCents; const body: Record = { ticker: request.ticker, - side: request.side, - action: "buy", - count: request.count, - client_order_id: request.clientOrderId ?? crypto.randomUUID(), + side: request.side === "yes" ? "bid" : "ask", + count: `${request.count}.00`, + price: (yesPriceCents / 100).toFixed(4), + client_order_id: clientOrderId, time_in_force: "good_till_canceled", post_only: request.postOnly ?? true, cancel_order_on_pause: true, self_trade_prevention_type: "taker_at_cross", }; - // Price field matches the quoted side (yes_price for YES, no_price for NO). - body[request.side === "yes" ? "yes_price" : "no_price"] = request.priceCents; - const res = await signedRequest("POST", "/portfolio/orders", body); - const orderId = - isRecord(res) && isRecord(res.order) && typeof res.order.order_id === "string" - ? res.order.order_id - : null; + const res = await signedRequest("POST", "/portfolio/events/orders", body); + const orderId = isRecord(res) && typeof res.order_id === "string" ? res.order_id : null; if (!orderId) { - throw new Error(`Kalshi order create: missing order_id in response for ${request.ticker}`); + throw new KalshiRequestOutcomeUnknownError( + `Kalshi order create: missing order_id in response for ${request.ticker}`, + ); } - return { orderId, dryRun: false }; + return { + orderId, + clientOrderId: + isRecord(res) && typeof res.client_order_id === "string" + ? res.client_order_id + : clientOrderId, + fillCount: fixedCount(isRecord(res) ? res.fill_count : null), + remainingCount: fixedCount(isRecord(res) ? res.remaining_count : null), + averageFillPriceCents: fixedDollarsToCents( + isRecord(res) ? res.average_fill_price : null, + ), + averageFeePaidCents: fixedDollarsToCents( + isRecord(res) ? res.average_fee_paid : null, + ), + processedAtMs: + isRecord(res) && Number.isSafeInteger(res.ts_ms) ? (res.ts_ms as number) : null, + dryRun: false, + }; } async function cancelOrder(orderId: string): Promise { @@ -229,7 +312,16 @@ function getDefaultClient(): KalshiClient { */ export async function placeOrder(request: KalshiOrderRequest): Promise { if (request.dryRun) { - return { orderId: `dry-${request.ticker}-${Date.now()}`, dryRun: true }; + return { + orderId: `dry-${request.ticker}-${Date.now()}`, + clientOrderId: request.clientOrderId ?? crypto.randomUUID(), + fillCount: 0, + remainingCount: request.count, + averageFillPriceCents: null, + averageFeePaidCents: null, + processedAtMs: null, + dryRun: true, + }; } return getDefaultClient().placeOrder(request); } diff --git a/src/partner/execution/domain.ts b/src/partner/execution/domain.ts index f484793..1620d2d 100644 --- a/src/partner/execution/domain.ts +++ b/src/partner/execution/domain.ts @@ -49,6 +49,8 @@ export interface ExecutionSnapshot { sitePerBetMax: number; availableBalance: number; marketLiquidity: number; + /** Optional provider order increment in minor units; effective stake rounds down to it. */ + stakeQuantum?: number; } export interface ProviderPlacementInput { diff --git a/src/partner/execution/executor.ts b/src/partner/execution/executor.ts index 588443a..441f12d 100644 --- a/src/partner/execution/executor.ts +++ b/src/partner/execution/executor.ts @@ -88,6 +88,16 @@ export async function executeAuthorizedBet( } catch (error) { return { success: false, code: "SNAPSHOT_UNAVAILABLE", reason: errorMessage(error) }; } + if ( + snapshot.stakeQuantum !== undefined && + (!Number.isSafeInteger(snapshot.stakeQuantum) || snapshot.stakeQuantum <= 0) + ) { + return { + success: false, + code: "SNAPSHOT_UNAVAILABLE", + reason: "Provider stake quantum must be a positive safe integer in minor units", + }; + } const placementOwner = asPlacementOwner(crypto.randomUUID()); const ttlMs = dependencies.reservationTtlMs ?? DEFAULT_RESERVATION_TTL_MS; @@ -182,11 +192,23 @@ export async function executeAuthorizedBet( }; } + const effectiveStake = quantizeStake(gate.effectiveStake, snapshot.stakeQuantum); + if (effectiveStake <= 0) { + return { + kind: "result" as const, + result: { + success: false as const, + code: "GATE_DENIED" as const, + reason: "EFFECTIVE_STAKE_ZERO: Effective stake is below the provider order minimum", + }, + }; + } + const expiresAtMs = safeAdd(nowMs, ttlMs, "reservation expiry"); const created = createPendingReservation(db, { authorization, request, - effectiveStake: gate.effectiveStake, + effectiveStake, expiresAtMs, nowMs, }); @@ -335,6 +357,11 @@ export async function executeAuthorizedBet( }; } +function quantizeStake(effectiveStake: number, quantum: number | undefined): number { + if (quantum === undefined) return effectiveStake; + return Math.floor(effectiveStake / quantum) * quantum; +} + function replayResult(reservation: ExposureReservation): AuthorizedBetResult { if (reservation.status === "confirmed" && reservation.ticketId !== null) { return { diff --git a/src/partner/execution/index.ts b/src/partner/execution/index.ts index ca20add..0f71ce9 100644 --- a/src/partner/execution/index.ts +++ b/src/partner/execution/index.ts @@ -1,6 +1,7 @@ export * from "./domain.ts"; export * from "./executor.ts"; export * from "./kalshi.ts"; +export * from "./kalshi-snapshot.ts"; export * from "./maintenance.ts"; export * from "./reservation.ts"; export * from "./sql.ts"; diff --git a/src/partner/execution/kalshi-snapshot.ts b/src/partner/execution/kalshi-snapshot.ts new file mode 100644 index 0000000..30dcba6 --- /dev/null +++ b/src/partner/execution/kalshi-snapshot.ts @@ -0,0 +1,240 @@ +import type { Database } from "bun:sqlite"; +import type { BookLevel, BookSnapshot } from "../../institutions/alpha-signal-types.ts"; +import type { + ApprovedAuthorization, + AuthorizationPolicy, +} from "../authorization/domain.ts"; +import type { + BetRequest, + ExecutionDependencies, + ExecutionSnapshot, +} from "./domain.ts"; +import { decimalOddsToKalshiPriceCents } from "./kalshi.ts"; + +export type KalshiExecutionSide = "yes" | "no"; + +export interface KalshiMarketExecutionQuote { + ticker: string; + side: KalshiExecutionSide; + priceCents: number; + decimalOdds: number; + availableContracts: number; + /** Executable top-level cost in integer minor currency units. */ + marketLiquidity: number; + observedAtMs: number; + ageMs: number; + fresh: boolean; + source: string; +} + +export interface LoadKalshiMarketQuoteInput { + ticker: string; + side: KalshiExecutionSide; + nowMs?: number; + maxAgeMs?: number; +} + +export interface KalshiExecutionSnapshotDependencies { + db: Database; + side: + | KalshiExecutionSide + | ((authorization: ApprovedAuthorization, request: BetRequest) => KalshiExecutionSide); + loadCurrentPolicy: ( + authorization: ApprovedAuthorization, + request: BetRequest, + ) => Promise | AuthorizationPolicy; + loadSitePerBetMax: ( + authorization: ApprovedAuthorization, + request: BetRequest, + ) => Promise | number; + loadAvailableBalance: ( + authorization: ApprovedAuthorization, + request: BetRequest, + ) => Promise | number; + isProviderSessionValid: ( + authorization: ApprovedAuthorization, + ) => Promise | boolean; + isRiskHealthy: () => Promise | boolean; + now?: () => number; + maxAgeMs?: number; +} + +type BookTickRow = { + ts: number; + recvTs: number | null; + levelsJson: string; + source: string; +}; + +const DEFAULT_MAX_BOOK_AGE_MS = 5_000; + +/** Load the latest persisted Kalshi book and derive the executable quote for one outcome side. */ +export function loadKalshiMarketExecutionQuote( + db: Database, + input: LoadKalshiMarketQuoteInput, +): KalshiMarketExecutionQuote { + const ticker = input.ticker.trim(); + if (!ticker) throw new TypeError("Kalshi market ticker must not be empty"); + const nowMs = input.nowMs ?? Date.now(); + const maxAgeMs = input.maxAgeMs ?? DEFAULT_MAX_BOOK_AGE_MS; + assertEpochMs(nowMs, "quote clock"); + if (!Number.isSafeInteger(maxAgeMs) || maxAgeMs < 0) { + throw new TypeError("maximum Kalshi book age must be a non-negative safe integer"); + } + + const row = db + .query( + `SELECT ts, recv_ts AS recvTs, levels_json AS levelsJson, source + FROM book_ticks + WHERE ticker = $ticker + ORDER BY COALESCE(recv_ts, ts) DESC, id DESC + LIMIT 1`, + ) + .get({ $ticker: ticker }) as BookTickRow | null; + if (row === null) throw new Error(`No Kalshi book snapshot is available for ${ticker}`); + + const book = parsePersistedBook(row.levelsJson, ticker); + if (book.crossed || isCrossed(book)) { + throw new Error(`Kalshi book snapshot is crossed for ${ticker}`); + } + const level = bestBuyLevel(book, input.side); + if (level === null) { + throw new Error(`Kalshi book has no executable ${input.side.toUpperCase()} liquidity for ${ticker}`); + } + const observedAtMs = row.recvTs ?? row.ts; + assertEpochMs(observedAtMs, "book observation"); + const ageMs = nowMs - observedAtMs; + const marketLiquidity = safeMultiply(level.priceCents, level.size); + return { + ticker, + side: input.side, + priceCents: level.priceCents, + decimalOdds: 100 / level.priceCents, + availableContracts: level.size, + marketLiquidity, + observedAtMs, + ageMs, + fresh: ageMs >= 0 && ageMs <= maxAgeMs, + source: row.source, + }; +} + +/** Compose the persisted quote with account/policy health for executeAuthorizedBet(). */ +export function createKalshiExecutionSnapshotLoader( + dependencies: KalshiExecutionSnapshotDependencies, +): ExecutionDependencies["loadSnapshot"] { + return async (authorization, request): Promise => { + const side = + typeof dependencies.side === "function" + ? dependencies.side(authorization, request) + : dependencies.side; + const nowMs = dependencies.now?.() ?? Date.now(); + const quote = loadKalshiMarketExecutionQuote(dependencies.db, { + ticker: request.marketId, + side, + nowMs, + maxAgeMs: dependencies.maxAgeMs, + }); + const [ + currentPolicy, + sitePerBetMax, + availableBalance, + providerSessionValid, + riskHealthy, + ] = await Promise.all([ + dependencies.loadCurrentPolicy(authorization, request), + dependencies.loadSitePerBetMax(authorization, request), + dependencies.loadAvailableBalance(authorization, request), + dependencies.isProviderSessionValid(authorization), + dependencies.isRiskHealthy(), + ]); + return { + currentPolicy, + oddsFresh: + quote.fresh && + decimalOddsToKalshiPriceCents(request.decimalOdds) === quote.priceCents, + providerSessionValid, + riskHealthy, + sitePerBetMax: requireMinorUnits(sitePerBetMax, "site per-bet maximum"), + availableBalance: requireMinorUnits(availableBalance, "available balance"), + marketLiquidity: quote.marketLiquidity, + stakeQuantum: quote.priceCents, + }; + }; +} + +function parsePersistedBook(levelsJson: string, ticker: string): BookSnapshot { + let parsed: unknown; + try { + parsed = JSON.parse(levelsJson); + } catch { + throw new Error(`Kalshi book snapshot JSON is malformed for ${ticker}`); + } + if (!isRecord(parsed) || !Array.isArray(parsed.bids) || !Array.isArray(parsed.asks)) { + throw new Error(`Kalshi book snapshot shape is invalid for ${ticker}`); + } + const bids = parsed.bids.map((level) => parseLevel(level, ticker)); + const asks = parsed.asks.map((level) => parseLevel(level, ticker)); + return { + ts: Number.isSafeInteger(parsed.ts) ? (parsed.ts as number) : 0, + seq: Number.isSafeInteger(parsed.seq) ? (parsed.seq as number) : 0, + bids, + asks, + ...(parsed.crossed === true ? { crossed: true } : {}), + }; +} + +function parseLevel(value: unknown, ticker: string): BookLevel { + if (!isRecord(value)) throw new Error(`Kalshi book contains an invalid level for ${ticker}`); + const priceCents = value.priceCents; + const size = value.size; + if ( + !Number.isSafeInteger(priceCents) || + (priceCents as number) < 1 || + (priceCents as number) > 99 || + !Number.isSafeInteger(size) || + (size as number) <= 0 + ) { + throw new Error(`Kalshi book contains an invalid price or size for ${ticker}`); + } + return { priceCents: priceCents as number, size: size as number }; +} + +function bestBuyLevel(book: BookSnapshot, side: KalshiExecutionSide): BookLevel | null { + if (side === "yes") { + return [...book.asks].sort((a, b) => a.priceCents - b.priceCents)[0] ?? null; + } + const bestYesBid = [...book.bids].sort((a, b) => b.priceCents - a.priceCents)[0]; + return bestYesBid + ? { priceCents: 100 - bestYesBid.priceCents, size: bestYesBid.size } + : null; +} + +function isCrossed(book: BookSnapshot): boolean { + const bestBid = Math.max(...book.bids.map((level) => level.priceCents), 0); + const bestAsk = Math.min(...book.asks.map((level) => level.priceCents), 100); + return bestBid > 0 && bestAsk < 100 && bestBid > bestAsk; +} + +function safeMultiply(left: number, right: number): number { + const value = left * right; + if (!Number.isSafeInteger(value)) throw new RangeError("Kalshi market liquidity exceeds safe integer range"); + return value; +} + +function requireMinorUnits(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${label} must be a non-negative safe integer in minor units`); + } + return value; +} + +function assertEpochMs(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${label} must be a non-negative epoch-millisecond integer`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/partner/execution/kalshi.ts b/src/partner/execution/kalshi.ts index b7fb345..0c869ce 100644 --- a/src/partner/execution/kalshi.ts +++ b/src/partner/execution/kalshi.ts @@ -2,6 +2,7 @@ import type { KalshiClient, KalshiOrderRequest, } from "../../bot/kalshi-client.ts"; +import { KalshiRequestRejectedError } from "../../bot/kalshi-client.ts"; import { asTicketId, type ProviderPlacementInput } from "./domain.ts"; export type KalshiExecutionOrder = Omit< @@ -13,6 +14,13 @@ export type KalshiExecutionOrderMapper = ( input: ProviderPlacementInput, ) => KalshiExecutionOrder; +export class KalshiOrderMappingError extends Error { + constructor(message: string) { + super(message); + this.name = "KalshiOrderMappingError"; + } +} + /** Bind the generic authorized executor to the existing signed Kalshi client. */ export function createKalshiExecutionPlacer( client: Pick, @@ -20,24 +28,109 @@ export function createKalshiExecutionPlacer( ) { return async (input: ProviderPlacementInput) => { const clientOrderId = executionIdempotencyKeyToUuid(input.idempotencyKey); - const result = await client.placeOrder({ - ...mapOrder(input), - dryRun: false, - clientOrderId, - }); + let order: KalshiExecutionOrder; + try { + order = mapOrder(input); + } catch (error) { + if (!(error instanceof KalshiOrderMappingError)) throw error; + return { accepted: false as const, reason: error.message }; + } + let result; + try { + result = await client.placeOrder({ ...order, dryRun: false, clientOrderId }); + } catch (error) { + if (!(error instanceof KalshiRequestRejectedError)) throw error; + return { + accepted: false as const, + reason: error.message, + responseSummary: { + environment: client.environment, + status: error.status, + providerCode: error.providerCode, + clientOrderId, + }, + }; + } if (result.dryRun) throw new Error("Kalshi execution unexpectedly returned a dry-run order"); + if (result.fillCount <= 0 && result.remainingCount <= 0) { + return { + accepted: false as const, + reason: "Kalshi processed the order without a fill or resting quantity", + responseSummary: summarizeKalshiOrderResult(client.environment, result), + }; + } return { accepted: true as const, ticketId: asTicketId(result.orderId), - responseSummary: { - environment: client.environment, - orderId: result.orderId, - clientOrderId, - }, + responseSummary: summarizeKalshiOrderResult(client.environment, result), + }; + }; +} + +/** Map authorized minor-unit risk to an integer Kalshi buy order. */ +export function createKalshiBuyOrderMapper( + side: KalshiExecutionOrder["side"], + options: { postOnly?: boolean } = {}, +): KalshiExecutionOrderMapper { + return ({ request, effectiveStake }) => { + const priceCents = decimalOddsToKalshiPriceCents(request.decimalOdds); + const count = Math.floor(effectiveStake / priceCents); + if (count < 1) { + throw new KalshiOrderMappingError( + `Effective stake ${effectiveStake} is below the ${priceCents}-cent cost of one ${side.toUpperCase()} contract`, + ); + } + return { + ticker: request.marketId, + side, + count, + priceCents, + // This helper consumes executable top-of-book liquidity. Callers that + // intentionally load a maker quote can opt back into post-only behavior. + postOnly: options.postOnly ?? false, }; }; } +/** Binary-contract total-return decimal odds map to the quoted side's price. */ +export function decimalOddsToKalshiPriceCents(decimalOdds: number): number { + if (!Number.isFinite(decimalOdds) || decimalOdds <= 1) { + throw new KalshiOrderMappingError("Decimal odds must be finite and greater than 1"); + } + const priceCents = Math.round(100 / decimalOdds); + if (priceCents < 1 || priceCents > 99) { + throw new KalshiOrderMappingError( + `Decimal odds ${decimalOdds} do not map to a Kalshi price between 1 and 99 cents`, + ); + } + return priceCents; +} + +function summarizeKalshiOrderResult( + environment: KalshiClient["environment"], + result: Awaited>, +) { + const state = + result.fillCount > 0 && result.remainingCount > 0 + ? "partially_filled" + : result.fillCount > 0 + ? "filled" + : result.remainingCount > 0 + ? "resting" + : "not_filled"; + return { + environment, + orderId: result.orderId, + clientOrderId: result.clientOrderId, + state, + fillCount: result.fillCount, + remainingCount: result.remainingCount, + averageFillPriceCents: result.averageFillPriceCents, + averageFeePaidCents: result.averageFeePaidCents, + processedAtMs: result.processedAtMs, + }; +} + /** Deterministic RFC-4122 UUIDv5-shaped key derived without exposing the source key. */ export function executionIdempotencyKeyToUuid(key: string): string { const digest = new Bun.CryptoHasher("sha256").update(key).digest() as Uint8Array; diff --git a/tests/bot/kalshi-client.test.ts b/tests/bot/kalshi-client.test.ts index 68c830d..7189e80 100644 --- a/tests/bot/kalshi-client.test.ts +++ b/tests/bot/kalshi-client.test.ts @@ -4,6 +4,7 @@ import { generateKeyPairSync } from "node:crypto"; import { backoffMs, createKalshiClient, + KalshiRequestOutcomeUnknownError, KALSHI_REST_BASE, placeOrder, resolveKalshiEnvironment, @@ -49,8 +50,16 @@ function makeClient(overrides: { } function okOrder(orderId = "order-123"): Response { - return new Response(JSON.stringify({ order: { order_id: orderId, status: "resting" } }), { - status: 200, + return new Response(JSON.stringify({ + order_id: orderId, + client_order_id: "provider-client-id", + fill_count: "2.00", + remaining_count: "3.00", + average_fill_price: "0.4200", + average_fee_paid: "0.0100", + ts_ms: 1_700_000_000_000, + }), { + status: 201, headers: { "Content-Type": "application/json" }, }); } @@ -75,11 +84,20 @@ describe("kalshi-client placeOrder", () => { test("signs request and posts maker-first body to demo", async () => { const { client, calls } = makeClient({ responses: [okOrder()] }); const result = await client.placeOrder({ ...ORDER, dryRun: false }); - expect(result).toEqual({ orderId: "order-123", dryRun: false }); + expect(result).toEqual({ + orderId: "order-123", + clientOrderId: "provider-client-id", + fillCount: 2, + remainingCount: 3, + averageFillPriceCents: 42, + averageFeePaidCents: 1, + processedAtMs: 1_700_000_000_000, + dryRun: false, + }); expect(calls).toHaveLength(1); const call = calls[0]!; - expect(call.url).toBe(`${KALSHI_REST_BASE.demo}/portfolio/orders`); + expect(call.url).toBe(`${KALSHI_REST_BASE.demo}/portfolio/events/orders`); expect(call.method).toBe("POST"); expect(call.headers["KALSHI-ACCESS-KEY"]).toBe("test-key-id"); expect(call.headers["KALSHI-ACCESS-TIMESTAMP"]).toMatch(/^\d+$/); @@ -87,11 +105,9 @@ describe("kalshi-client placeOrder", () => { const body = call.body as Record; expect(body.ticker).toBe(ORDER.ticker); - expect(body.side).toBe("yes"); - expect(body.action).toBe("buy"); - expect(body.count).toBe(5); - expect(body.yes_price).toBe(42); - expect(body.no_price).toBeUndefined(); + expect(body.side).toBe("bid"); + expect(body.count).toBe("5.00"); + expect(body.price).toBe("0.4200"); expect(typeof body.client_order_id).toBe("string"); expect(body.time_in_force).toBe("good_till_canceled"); expect(body.post_only).toBe(true); @@ -99,12 +115,12 @@ describe("kalshi-client placeOrder", () => { expect(body.self_trade_prevention_type).toBe("taker_at_cross"); }); - test("no-side orders quote no_price; post_only caller-overridable", async () => { + test("NO buys map to an ask on the V2 YES-denominated book", async () => { const { client, calls } = makeClient({ responses: [okOrder()] }); await client.placeOrder({ ...ORDER, side: "no", dryRun: false, postOnly: false }); const body = calls[0]!.body as Record; - expect(body.no_price).toBe(42); - expect(body.yes_price).toBeUndefined(); + expect(body.side).toBe("ask"); + expect(body.price).toBe("0.5800"); expect(body.post_only).toBe(false); }); @@ -136,6 +152,15 @@ describe("kalshi-client placeOrder", () => { expect(calls).toHaveLength(1); }); + test("a successful but unidentifiable create response is outcome-unknown", async () => { + const { client } = makeClient({ + responses: [new Response(JSON.stringify({ fill_count: "1.00" }), { status: 201 })], + }); + await expect(client.placeOrder({ ...ORDER, dryRun: false })).rejects.toBeInstanceOf( + KalshiRequestOutcomeUnknownError, + ); + }); + test("dryRun never touches fetch or env", async () => { const { client, calls } = makeClient({ responses: [] }); const viaClient = await client.placeOrder({ ...ORDER, dryRun: true }); diff --git a/tests/partner/execution/executor.test.ts b/tests/partner/execution/executor.test.ts index 2fb113c..6fa017c 100644 --- a/tests/partner/execution/executor.test.ts +++ b/tests/partner/execution/executor.test.ts @@ -186,6 +186,30 @@ describe("authorized bet execution", () => { db.close(); }); + test("quantizes reservations to the provider's exact minor-unit order increment", async () => { + const p = policy({ exposureLimit: null }); + const { db } = setup(p); + let placedStake = 0; + const deps = dependencies(p, async ({ effectiveStake }) => { + placedStake = effectiveStake; + return { accepted: true, ticketId: asTicketId("ticket-quantized") }; + }); + deps.loadSnapshot = () => ({ + currentPolicy: p, + oddsFresh: true, + providerSessionValid: true, + riskHealthy: true, + sitePerBetMax: 5_000, + availableBalance: 10_000, + marketLiquidity: 10_000, + stakeQuantum: 40, + }); + const result = await executeAuthorizedBet(db, request("quantized", 125), deps); + expect(result).toMatchObject({ success: true, effectiveStake: 120 }); + expect(placedStake).toBe(120); + db.close(); + }); + test("fails closed for stale policy and authorization revoked during snapshot loading", async () => { const p = policy(); for (const mode of ["stale", "revoked"] as const) { diff --git a/tests/partner/execution/kalshi-snapshot.test.ts b/tests/partner/execution/kalshi-snapshot.test.ts new file mode 100644 index 0000000..ddbe391 --- /dev/null +++ b/tests/partner/execution/kalshi-snapshot.test.ts @@ -0,0 +1,173 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + type ApprovedAuthorization, + type AuthorizationPolicy, +} from "../../../src/partner/authorization/domain.ts"; +import { + asExecutionIdempotencyKey, + asMarketId, +} from "../../../src/partner/execution/domain.ts"; +import { + createKalshiExecutionSnapshotLoader, + loadKalshiMarketExecutionQuote, +} from "../../../src/partner/execution/kalshi-snapshot.ts"; + +const NOW_MS = 1_700_000_000_000; + +function database(levelsJson = JSON.stringify({ + ts: NOW_MS - 100, + seq: 1, + bids: [{ priceCents: 60, size: 4 }, { priceCents: 55, size: 20 }], + asks: [{ priceCents: 65, size: 3 }, { priceCents: 70, size: 10 }], +})): Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE book_ticks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticker TEXT, + ts INTEGER NOT NULL, + recv_ts INTEGER, + levels_json TEXT NOT NULL, + source TEXT NOT NULL + ); + `); + db.query( + `INSERT INTO book_ticks (ticker, ts, recv_ts, levels_json, source) + VALUES ('KXTEST', $ts, $ts, $levels, 'kalshi-ws')`, + ).run({ $ts: NOW_MS - 100, $levels: levelsJson }); + return db; +} + +describe("Kalshi execution snapshot loader", () => { + test("derives YES and NO executable liquidity in minor units", () => { + const db = database(); + const yes = loadKalshiMarketExecutionQuote(db, { + ticker: "KXTEST", + side: "yes", + nowMs: NOW_MS, + maxAgeMs: 1_000, + }); + const no = loadKalshiMarketExecutionQuote(db, { + ticker: "KXTEST", + side: "no", + nowMs: NOW_MS, + maxAgeMs: 1_000, + }); + expect(yes).toMatchObject({ + priceCents: 65, + availableContracts: 3, + marketLiquidity: 195, + fresh: true, + }); + expect(no).toMatchObject({ + priceCents: 40, + availableContracts: 4, + marketLiquidity: 160, + fresh: true, + }); + expect(yes.decimalOdds).toBeCloseTo(100 / 65); + db.close(); + }); + + test("marks stale or future-dated books not fresh", () => { + const db = database(); + expect(loadKalshiMarketExecutionQuote(db, { + ticker: "KXTEST", + side: "yes", + nowMs: NOW_MS + 2_000, + maxAgeMs: 1_000, + }).fresh).toBeFalse(); + expect(loadKalshiMarketExecutionQuote(db, { + ticker: "KXTEST", + side: "yes", + nowMs: NOW_MS - 200, + maxAgeMs: 1_000, + }).fresh).toBeFalse(); + db.close(); + }); + + test("fails closed for missing, malformed, crossed, and empty books", () => { + const missing = database(); + expect(() => loadKalshiMarketExecutionQuote(missing, { + ticker: "OTHER", + side: "yes", + nowMs: NOW_MS, + })).toThrow(/No Kalshi book snapshot/); + missing.close(); + + for (const levels of [ + "not-json", + JSON.stringify({ bids: [{ priceCents: 70, size: 1 }], asks: [{ priceCents: 65, size: 1 }] }), + JSON.stringify({ bids: [], asks: [] }), + ]) { + const db = database(levels); + expect(() => loadKalshiMarketExecutionQuote(db, { + ticker: "KXTEST", + side: "yes", + nowMs: NOW_MS, + })).toThrow(); + db.close(); + } + }); + + test("binds gate freshness to the executable quote price", async () => { + const db = database(); + const currentPolicy = policy(); + const authorization = {} as ApprovedAuthorization; + const request = { + partnerCode: currentPolicy.partnerCode, + outId: currentPolicy.outId, + skin: currentPolicy.skin, + marketId: asMarketId("KXTEST"), + idempotencyKey: asExecutionIdempotencyKey("quote-bind"), + requestedStake: 100, + decimalOdds: 100 / 65, + }; + const load = createKalshiExecutionSnapshotLoader({ + db, + side: "yes", + now: () => NOW_MS, + maxAgeMs: 1_000, + loadCurrentPolicy: () => currentPolicy, + loadSitePerBetMax: () => 1_000, + loadAvailableBalance: () => 5_000, + isProviderSessionValid: () => true, + isRiskHealthy: () => true, + }); + expect(await load(authorization, request)).toMatchObject({ + oddsFresh: true, + marketLiquidity: 195, + stakeQuantum: 65, + sitePerBetMax: 1_000, + availableBalance: 5_000, + }); + expect(await load(authorization, { ...request, decimalOdds: 2 })).toMatchObject({ + oddsFresh: false, + }); + db.close(); + }); +}); + +function policy(): AuthorizationPolicy { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("kalshi"), + skin: asSkinId("demo"), + scope: "live_trade", + maxStake: 10_000, + maxWin: 20_000, + maxWinBasis: "profit", + dailyLimit: null, + exposureLimit: null, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 1_000, + expiresAtMs: null, + }; +} diff --git a/tests/partner/execution/kalshi.test.ts b/tests/partner/execution/kalshi.test.ts index ed0a957..6632e7f 100644 --- a/tests/partner/execution/kalshi.test.ts +++ b/tests/partner/execution/kalshi.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { KalshiClient } from "../../../src/bot/kalshi-client.ts"; +import { KalshiRequestRejectedError } from "../../../src/bot/kalshi-client.ts"; import { asAuthorizationId, asAuthorizationRequestId, @@ -19,6 +20,8 @@ import { } from "../../../src/partner/execution/domain.ts"; import { createKalshiExecutionPlacer, + createKalshiBuyOrderMapper, + decimalOddsToKalshiPriceCents, executionIdempotencyKeyToUuid, } from "../../../src/partner/execution/kalshi.ts"; @@ -36,7 +39,16 @@ describe("Kalshi authorized execution adapter", () => { environment: "demo" as const, placeOrder: async (order) => { calls.push(order as unknown as Record); - return { orderId: "order-1", dryRun: false }; + return { + orderId: "order-1", + clientOrderId: order.clientOrderId!, + fillCount: 1, + remainingCount: 1, + averageFillPriceCents: 42, + averageFeePaidCents: 1, + processedAtMs: 1_700_000_000_000, + dryRun: false, + }; }, } satisfies Pick; const place = createKalshiExecutionPlacer(client, ({ request, effectiveStake }) => ({ @@ -87,7 +99,111 @@ describe("Kalshi authorized execution adapter", () => { idempotencyKey: asExecutionIdempotencyKey("bet-1"), }); expect(result).toMatchObject({ accepted: true, ticketId: "order-1" }); + expect(result.responseSummary).toMatchObject({ + state: "partially_filled", + fillCount: 1, + remainingCount: 1, + }); expect(calls[0]).toMatchObject({ dryRun: false, count: 2 }); expect(calls[0]?.clientOrderId).toBe(executionIdempotencyKeyToUuid("bet-1")); }); + + test("maps minor-unit risk to contract count and the request's quoted price", () => { + expect(decimalOddsToKalshiPriceCents(2.5)).toBe(40); + const mapper = createKalshiBuyOrderMapper("no"); + const input = executionInput({ effectiveStake: 125, decimalOdds: 2.5 }); + expect(mapper(input)).toEqual({ + ticker: "KXTEST", + side: "no", + count: 3, + priceCents: 40, + postOnly: false, + }); + expect(() => mapper(executionInput({ effectiveStake: 39, decimalOdds: 2.5 }))).toThrow( + /below the 40-cent cost/, + ); + }); + + test("maps definite provider rejections but leaves ambiguous failures to the executor", async () => { + const rejectedClient = { + environment: "demo" as const, + placeOrder: async () => { + throw new KalshiRequestRejectedError("Kalshi rejected order", 400, "invalid_order"); + }, + } satisfies Pick; + const result = await createKalshiExecutionPlacer( + rejectedClient, + createKalshiBuyOrderMapper("yes"), + )(executionInput({ effectiveStake: 100, decimalOdds: 2 })); + expect(result).toMatchObject({ + accepted: false, + reason: "Kalshi rejected order", + responseSummary: { status: 400, providerCode: "invalid_order" }, + }); + }); + + test("treats a processed order with no fill and no resting quantity as rejected", async () => { + const client = { + environment: "demo" as const, + placeOrder: async (order) => ({ + orderId: "order-empty", + clientOrderId: order.clientOrderId!, + fillCount: 0, + remainingCount: 0, + averageFillPriceCents: null, + averageFeePaidCents: null, + processedAtMs: 1_700_000_000_000, + dryRun: false, + }), + } satisfies Pick; + const result = await createKalshiExecutionPlacer( + client, + createKalshiBuyOrderMapper("yes"), + )(executionInput({ effectiveStake: 100, decimalOdds: 2 })); + expect(result).toMatchObject({ accepted: false, responseSummary: { state: "not_filled" } }); + }); }); + +function executionInput(overrides: { effectiveStake?: number; decimalOdds?: number } = {}) { + const currentPolicy = { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("kalshi"), + skin: asSkinId("demo"), + scope: "live_trade" as const, + maxStake: 10_000, + maxWin: 10_000, + maxWinBasis: "profit" as const, + dailyLimit: null, + exposureLimit: null, + currency: asCurrencyCode("USD"), + validFromMs: 1, + expiresAtMs: null, + }; + return { + authorization: { + ...currentPolicy, + id: asAuthorizationId(1), + requestId: asAuthorizationRequestId(1), + approvalHash: asPolicyHash("a".repeat(64)), + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: null, + telegramMessageId: asTelegramMessageId("1"), + approvingUserId: asTelegramUserId("2"), + revokedAtMs: null, + createdAtMs: 1, + updatedAtMs: 1, + }, + request: { + partnerCode: currentPolicy.partnerCode, + outId: currentPolicy.outId, + skin: currentPolicy.skin, + marketId: asMarketId("KXTEST"), + idempotencyKey: asExecutionIdempotencyKey("bet-helper"), + requestedStake: overrides.effectiveStake ?? 100, + decimalOdds: overrides.decimalOdds ?? 2, + }, + effectiveStake: overrides.effectiveStake ?? 100, + idempotencyKey: asExecutionIdempotencyKey("bet-helper"), + }; +} From 3a254c703d3422c2b751a8906b69061239b820f5 Mon Sep 17 00:00:00 2001 From: nolarose Date: Thu, 6 Aug 2026 03:08:34 -0500 Subject: [PATCH 5/7] docs(partner): map Kalshi binding maturity Record the V2 mapper and persisted-book snapshot loader as partial provider integration while keeping runtime route composition and reconciliation explicitly open. Domain test and TypeScript pass; full-suite baseline exceptions are documented in the parent commit. --- src/partner/domain.ts | 6 +++--- tests/partner/domain.test.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/partner/domain.ts b/src/partner/domain.ts index d42589c..6b89790 100644 --- a/src/partner/domain.ts +++ b/src/partner/domain.ts @@ -187,9 +187,9 @@ export const PARTNER_DOMAIN_LAYERS: readonly DomainLayer[] = [ { id: "provider-execution-bindings", name: "Live provider execution bindings", - maturity: "planned", - where: "src/bot/kalshi-client.ts · src/partner/fantasy-ultra/adapter.ts", - notes: "Explicit order translation, balance/liquidity snapshot loader, and reconciliation poller are not wired", + maturity: "partial", + where: "src/partner/execution/kalshi*.ts · src/bot/kalshi-client.ts", + notes: "Kalshi V2 mapper + persisted-book snapshot loader built; authorized runtime route composition and reconciliation poller remain", }, ], }, diff --git a/tests/partner/domain.test.ts b/tests/partner/domain.test.ts index 3651d84..c1797e6 100644 --- a/tests/partner/domain.test.ts +++ b/tests/partner/domain.test.ts @@ -19,7 +19,8 @@ describe("partner domain architecture", () => { const report = buildDomainStatusReport(); expect(report.totals.components).toBeGreaterThan(10); expect(report.totals.built).toBeGreaterThan(0); - expect(report.totals.planned).toBeGreaterThan(0); + expect(report.totals.partial).toBeGreaterThan(0); + expect(report.totals.planned).toBe(0); expect(report.orchestration.missingForBotLoop.length).toBeGreaterThan(0); expect(PARTNER_NAMING.outIdExample).toBe("out-SPEN-1"); expect(formatDomainStatusText(report)).toContain("partner domain"); From 686ce638039b84bfffcb79861682c68d8c00dd3b Mon Sep 17 00:00:00 2001 From: nolarose Date: Thu, 6 Aug 2026 03:44:45 -0500 Subject: [PATCH 6/7] feat(partner): wire authorized Kalshi order route Validation: 1131 tests pass; the sole full-suite failure is tests/partner/toml-config.test.ts because Bun 1.3.14 lacks Bun.TOML.stringify required by the repository's >=1.4.0-canary.1 runtime policy. Focused authorization/execution/HTTP tests (86), typecheck, Bun native guard, glossary, partner validators, TOML config validation, and root branded-ID check pass. --- .env.example | 10 + src/bot/kalshi-client.ts | 14 +- src/institutions/error-codes.ts | 7 + src/lib/config.ts | 2 + src/partner/domain.ts | 6 +- src/partner/execution/domain.ts | 10 + src/partner/execution/executor.ts | 10 + src/partner/execution/index.ts | 1 + src/partner/execution/kalshi-live.ts | 375 ++++++++++++++++++ src/partner/execution/kalshi-snapshot.ts | 5 + src/partner/execution/kalshi.ts | 38 +- src/partner/execution/reservation.ts | 9 +- src/partner/execution/sql.ts | 9 + src/research/hq-app/app.js | 42 +- src/research/hq-view.ts | 42 +- src/research/serve.ts | 108 ++++- tests/partner/execution/executor.test.ts | 2 + tests/partner/execution/kalshi-live.test.ts | 353 +++++++++++++++++ .../partner/execution/kalshi-snapshot.test.ts | 2 + tests/partner/execution/kalshi.test.ts | 11 +- tests/partner/execution/reservation.test.ts | 33 +- tests/research/trading-order.test.ts | 295 ++++++++++++++ 22 files changed, 1354 insertions(+), 30 deletions(-) create mode 100644 src/partner/execution/kalshi-live.ts create mode 100644 tests/partner/execution/kalshi-live.test.ts create mode 100644 tests/research/trading-order.test.ts diff --git a/.env.example b/.env.example index e27954a..36af7a9 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,12 @@ # — OR inline the PEM directly — # KALSHI_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----" +# Authorized partner execution resolves credentials by out, then partner, then +# the global KALSHI_ fallback. Example for out-SPORTS-1: +# KALSHI_SPORTS_1_API_KEY_ID=your_out_scoped_key_id +# KALSHI_SPORTS_1_PRIVATE_KEY_PATH=/path/to/out-scoped-private-key.pem +# Partner-wide fallback: KALSHI_SPORTS_API_KEY_ID / KALSHI_SPORTS_PRIVATE_KEY_PATH + # The Odds API key for Pinnacle consensus feed # Get from: https://the-odds-api.com/ # ODDS_API_KEY=your_odds_api_key_here @@ -35,6 +41,10 @@ # Prod additionally requires: # KALSHI_PROD_ARMED=1 +# Independent fail-closed breaker for POST /api/trading/order live execution. +# Leave unset for dry-run-only HQ behavior. +# KALSHI_AUTHORIZED_EXECUTION_ENABLED=1 + # === RESEARCH PIPELINE (optional) === # Popularity gate overrides diff --git a/src/bot/kalshi-client.ts b/src/bot/kalshi-client.ts index ab4fb76..489b675 100644 --- a/src/bot/kalshi-client.ts +++ b/src/bot/kalshi-client.ts @@ -300,7 +300,7 @@ export function createKalshiClient(options: KalshiClientOptions = {}): KalshiCli let defaultClient: KalshiClient | null = null; -function getDefaultClient(): KalshiClient { +export function getDefaultKalshiClient(): KalshiClient { defaultClient ??= createKalshiClient(); return defaultClient; } @@ -323,25 +323,25 @@ export async function placeOrder(request: KalshiOrderRequest): Promise { - return getDefaultClient().cancelOrder(orderId); + return getDefaultKalshiClient().cancelOrder(orderId); } export async function getOrders(ticker?: string): Promise[]> { - return getDefaultClient().getOrders(ticker); + return getDefaultKalshiClient().getOrders(ticker); } export async function getFills(ticker?: string): Promise[]> { - return getDefaultClient().getFills(ticker); + return getDefaultKalshiClient().getFills(ticker); } export async function getPositions(): Promise[]> { - return getDefaultClient().getPositions(); + return getDefaultKalshiClient().getPositions(); } export async function getBalance(): Promise<{ balanceCents: number | null }> { - return getDefaultClient().getBalance(); + return getDefaultKalshiClient().getBalance(); } diff --git a/src/institutions/error-codes.ts b/src/institutions/error-codes.ts index 46b4275..305017d 100644 --- a/src/institutions/error-codes.ts +++ b/src/institutions/error-codes.ts @@ -23,6 +23,13 @@ export const ERROR_CODES = { E_PRICE_RANGE: { http: 400, message: "priceCents must be an integer 1–99", detail: "Binary contract prices live in (0, 100) cents." }, E_ORDER_ID_REQUIRED: { http: 400, message: "orderId is required", detail: "Cancel target — the exchange order_id, not client_order_id." }, E_BODY_INVALID: { http: 400, message: "invalid JSON body", detail: "POST body must parse as JSON." }, + E_AUTH_CONTEXT_REQUIRED: { http: 400, message: "live authorization context is required", detail: "Live orders require canonical partnerCode, outId, skin, outcome, stakeMinorUnits, and compliance fields." }, + E_IDEMPOTENCY_REQUIRED: { http: 400, message: "idempotency key is required", detail: "Supply an Idempotency-Key header or matching idempotencyKey body field for every live order." }, + E_ACCOUNT_INACTIVE: { http: 403, message: "partner execution account is not active", detail: "The partner, out, or requested skin is missing, mismatched, or inactive." }, + E_AUTHORIZATION_REQUIRED: { http: 403, message: "live-trade authorization denied", detail: "No active matching grant passed policy, balance, liquidity, session, and risk checks." }, + E_EXECUTION_REJECTED: { http: 409, message: "provider rejected the order", detail: "The rejection is conclusive and reserved exposure has been released." }, + E_PROVIDER_NOT_IMPLEMENTED: { http: 501, message: "provider live execution is not implemented", detail: "Kalshi is the only provider wired through authorized execution; Fantasy402 remains disabled." }, + E_EXECUTION_UNKNOWN: { http: 202, message: "provider outcome requires reconciliation", detail: "The provider outcome is ambiguous; exposure remains reserved until reconciliation." }, // ── Upstream / transport (E2xx) ── E_UPSTREAM: { http: 502, message: "Kalshi API request failed", detail: "Upstream returned an error or timed out; see `upstream` for its code." }, diff --git a/src/lib/config.ts b/src/lib/config.ts index 1eba386..a27c9ff 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -48,6 +48,8 @@ declare module "bun" { KALSHI_ENV?: string; /** Must be "1" to enable live trading when KALSHI_ALPHA_LIVE is set */ KALSHI_PROD_ARMED?: string; + /** Independent fail-closed breaker for authorized partner order execution */ + KALSHI_AUTHORIZED_EXECUTION_ENABLED?: string; /** Alpha live trading flag (prefer KALSHI_ALPHA_LIVE) */ ALPHA_LIVE?: string; /** Alpha live trading flag */ diff --git a/src/partner/domain.ts b/src/partner/domain.ts index 6b89790..0802419 100644 --- a/src/partner/domain.ts +++ b/src/partner/domain.ts @@ -188,8 +188,8 @@ export const PARTNER_DOMAIN_LAYERS: readonly DomainLayer[] = [ id: "provider-execution-bindings", name: "Live provider execution bindings", maturity: "partial", - where: "src/partner/execution/kalshi*.ts · src/bot/kalshi-client.ts", - notes: "Kalshi V2 mapper + persisted-book snapshot loader built; authorized runtime route composition and reconciliation poller remain", + where: "src/partner/execution/kalshi*.ts · src/bot/kalshi-client.ts · src/research/serve.ts", + notes: "Kalshi V2 mapper, out-scoped client, persisted-book snapshot loader, and authorized HTTP route built; reconciliation poller remains", }, ], }, @@ -348,7 +348,7 @@ export function buildDomainStatusReport( "partners.telegram_chat_id + topic preferences", "Telegram /capacity /add command router", "partner_ledger + split → report pipeline", - "provider binding into executeAuthorizedBet + reconciliation poller", + "Kalshi unknown-outcome reconciliation poller", ], }, }; diff --git a/src/partner/execution/domain.ts b/src/partner/execution/domain.ts index 1620d2d..9195588 100644 --- a/src/partner/execution/domain.ts +++ b/src/partner/execution/domain.ts @@ -8,12 +8,14 @@ import type { } from "../authorization/domain.ts"; declare const marketIdBrand: unique symbol; +declare const marketSelectionBrand: unique symbol; declare const ticketIdBrand: unique symbol; declare const reservationIdBrand: unique symbol; declare const executionKeyBrand: unique symbol; declare const placementOwnerBrand: unique symbol; export type MarketId = string & { readonly [marketIdBrand]: true }; +export type MarketSelection = string & { readonly [marketSelectionBrand]: true }; export type TicketId = string & { readonly [ticketIdBrand]: true }; export type ExposureReservationId = number & { readonly [reservationIdBrand]: true }; export type ExecutionIdempotencyKey = string & { readonly [executionKeyBrand]: true }; @@ -35,6 +37,7 @@ export interface BetRequest { outId: OutId; skin: SkinId; marketId: MarketId; + selection: MarketSelection; idempotencyKey: ExecutionIdempotencyKey; requestedStake: number; decimalOdds: number; @@ -96,6 +99,7 @@ export interface ExposureReservation { requestedStake: number; effectiveStake: number; marketId: MarketId; + selection: MarketSelection; decimalOdds: number; status: ExposureReservationStatus; reservationExpiresAtMs: number; @@ -126,6 +130,8 @@ export type AuthorizedBetResult = ticketId: TicketId; effectiveStake: number; reservationId: ExposureReservationId; + /** Sanitized provider placement summary, including immediate fill state when available. */ + providerResponse?: unknown; } | { success: false; @@ -139,6 +145,10 @@ export function asMarketId(value: string): MarketId { return brandBoundedString(value, "market ID", 256); } +export function asMarketSelection(value: string): MarketSelection { + return brandBoundedString(value, "market selection", 128); +} + export function asTicketId(value: string): TicketId { return brandBoundedString(value, "ticket ID", 256); } diff --git a/src/partner/execution/executor.ts b/src/partner/execution/executor.ts index 441f12d..ba50377 100644 --- a/src/partner/execution/executor.ts +++ b/src/partner/execution/executor.ts @@ -14,6 +14,7 @@ import { getActiveLiveTradeAuthorization } from "../authorization/sql.ts"; import { asExecutionIdempotencyKey, asMarketId, + asMarketSelection, asPlacementOwner, type AuthorizedBetResult, type BetRequest, @@ -354,6 +355,9 @@ export async function executeAuthorizedBet( ticketId: providerResult.ticketId, effectiveStake: reservation.effectiveStake, reservationId: reservation.id, + ...(providerResult.responseSummary === undefined + ? {} + : { providerResponse: providerResult.responseSummary }), }; } @@ -370,6 +374,9 @@ function replayResult(reservation: ExposureReservation): AuthorizedBetResult { ticketId: reservation.ticketId, effectiveStake: reservation.effectiveStake, reservationId: reservation.id, + ...(reservation.providerResponse === null + ? {} + : { providerResponse: reservation.providerResponse }), }; } if (reservation.status === "unknown") { @@ -417,6 +424,7 @@ function reservationMatchesRequest( reservation.outId === request.outId && reservation.skin === request.skin && reservation.marketId === request.marketId && + reservation.selection === request.selection && reservation.requestedStake === request.requestedStake && reservation.decimalOdds === request.decimalOdds ); @@ -453,6 +461,7 @@ function enqueueExecutionReceipt( `${headline}\n` + `Out: ${Bun.escapeHTML(reservation.outId)}\n` + `Market: ${Bun.escapeHTML(reservation.marketId)}\n` + + `Selection: ${Bun.escapeHTML(reservation.selection)}\n` + `Stake: ${reservation.effectiveStake} minor units\n` + `Odds: ${reservation.decimalOdds}\n` + detail, @@ -469,6 +478,7 @@ function validateRequest(request: BetRequest): string | null { asOutId(request.outId); asSkinId(request.skin); asMarketId(request.marketId); + asMarketSelection(request.selection); asExecutionIdempotencyKey(request.idempotencyKey); if (!Number.isSafeInteger(request.requestedStake) || request.requestedStake <= 0) { throw new TypeError("requested stake must be a positive safe integer in minor units"); diff --git a/src/partner/execution/index.ts b/src/partner/execution/index.ts index 0f71ce9..0f3099b 100644 --- a/src/partner/execution/index.ts +++ b/src/partner/execution/index.ts @@ -1,6 +1,7 @@ export * from "./domain.ts"; export * from "./executor.ts"; export * from "./kalshi.ts"; +export * from "./kalshi-live.ts"; export * from "./kalshi-snapshot.ts"; export * from "./maintenance.ts"; export * from "./reservation.ts"; diff --git a/src/partner/execution/kalshi-live.ts b/src/partner/execution/kalshi-live.ts new file mode 100644 index 0000000..88de8e7 --- /dev/null +++ b/src/partner/execution/kalshi-live.ts @@ -0,0 +1,375 @@ +import type { Database } from "bun:sqlite"; +import { loadKalshiCredentials } from "../../bot/kalshi-auth.ts"; +import { createKalshiClient, type KalshiClient } from "../../bot/kalshi-client.ts"; +import { + asOutId, + asPartnerCode, + asSkinId, + policyFromAuthorization, +} from "../authorization/domain.ts"; +import { getBettingAccountById, type BettingAccountRow } from "../registry.ts"; +import { parseOutMeta, resolveOutSkins } from "../skins.ts"; +import { envPrefixFallbackChain } from "../toml-config.ts"; +import { + asExecutionIdempotencyKey, + asMarketId, + asMarketSelection, + type AuthorizedBetResult, + type BetRequest, +} from "./domain.ts"; +import { executeAuthorizedBet } from "./executor.ts"; +import { + createKalshiBuyOrderMapper, + createKalshiExecutionPlacer, + type KalshiExecutionOrder, + type KalshiOrderResponseSummary, +} from "./kalshi.ts"; +import { createKalshiExecutionSnapshotLoader } from "./kalshi-snapshot.ts"; + +export interface KalshiLiveOrderCommand { + partnerCode: BetRequest["partnerCode"]; + outId: BetRequest["outId"]; + skin: BetRequest["skin"]; + ticker: BetRequest["marketId"]; + outcome: KalshiExecutionOrder["side"]; + requestedStake: number; + priceCents: number; + idempotencyKey: BetRequest["idempotencyKey"]; +} + +export type KalshiLiveCommandParseResult = + | { ok: true; command: KalshiLiveOrderCommand } + | { ok: false; code: "INVALID_REQUEST" | "IDEMPOTENCY_REQUIRED"; reason: string }; + +export type KalshiLiveExecutionResult = + | { + ok: true; + result: Extract; + order: KalshiOrderResponseSummary | null; + } + | { + ok: false; + code: + | "ACCOUNT_NOT_FOUND" + | "ACCOUNT_INACTIVE" + | "PARTNER_INACTIVE" + | "PARTNER_MISMATCH" + | "SKIN_INACTIVE" + | "PROVIDER_NOT_IMPLEMENTED" + | "PROVIDER_SESSION_UNAVAILABLE" + | "CURRENCY_UNSUPPORTED" + | "EXECUTION_DENIED"; + reason: string; + execution?: Extract; + }; + +export interface KalshiLiveExecutionDependencies { + client?: Pick; + resolveClient?: ( + account: BettingAccountRow, + ) => + | Promise> + | Pick; + isRiskHealthy: () => Promise | boolean; + now?: () => number; + maxBookAgeMs?: number; +} + +/** Parse the live-only HTTP wire shape into branded, integer-safe execution input. */ +export function parseKalshiLiveOrderCommand( + wire: unknown, + idempotencyHeader?: string | null, +): KalshiLiveCommandParseResult { + try { + if (!isRecord(wire)) throw new TypeError("request body must be a JSON object"); + const partnerCodeRaw = requiredString(wire.partnerCode, "partnerCode").toUpperCase(); + if (!/^[A-Z]{3,6}$/.test(partnerCodeRaw)) { + throw new TypeError("partnerCode must contain 3–6 uppercase ASCII letters"); + } + const outIdRaw = requiredString(wire.outId, "outId"); + if (!new RegExp(`^out-${partnerCodeRaw}-[1-9][0-9]*$`).test(outIdRaw)) { + throw new TypeError("outId must be canonical and belong to partnerCode"); + } + const skinRaw = requiredString(wire.skin, "skin"); + const tickerRaw = requiredString(wire.ticker, "ticker"); + const outcome = wire.outcome === "yes" || wire.outcome === "no" ? wire.outcome : null; + if (outcome === null) throw new TypeError("outcome must be 'yes' or 'no'"); + const requestedStake = positiveSafeInteger(wire.stakeMinorUnits, "stakeMinorUnits"); + const priceCents = positiveSafeInteger(wire.priceCents, "priceCents"); + if (priceCents > 99) throw new TypeError("priceCents must be between 1 and 99"); + if (wire.postOnly === true) { + throw new TypeError( + "authorized live orders consume executable top-of-book liquidity and require postOnly=false", + ); + } + + const bodyKey = optionalString(wire.idempotencyKey); + const headerKey = optionalString(idempotencyHeader); + if (bodyKey && headerKey && bodyKey !== headerKey) { + throw new TypeError("body and Idempotency-Key header must match"); + } + const idempotencyKey = bodyKey ?? headerKey; + if (!idempotencyKey) { + return { + ok: false, + code: "IDEMPOTENCY_REQUIRED", + reason: "Live execution requires an explicit Idempotency-Key header or idempotencyKey field", + }; + } + + return { + ok: true, + command: { + partnerCode: asPartnerCode(partnerCodeRaw), + outId: asOutId(outIdRaw), + skin: asSkinId(skinRaw), + ticker: asMarketId(tickerRaw), + outcome, + requestedStake, + priceCents, + idempotencyKey: asExecutionIdempotencyKey(idempotencyKey), + }, + }; + } catch (error) { + return { + ok: false, + code: "INVALID_REQUEST", + reason: error instanceof Error ? error.message : "invalid live order request", + }; + } +} + +/** Resolve the out/skin, gather live state, and dispatch only through executeAuthorizedBet(). */ +export async function executeKalshiLiveOrder( + db: Database, + command: KalshiLiveOrderCommand, + dependencies: KalshiLiveExecutionDependencies, +): Promise { + const account = getBettingAccountById(db, command.outId); + if (account === null) { + return { ok: false, code: "ACCOUNT_NOT_FOUND", reason: "Execution out was not found" }; + } + if (account.status !== "active") { + return { ok: false, code: "ACCOUNT_INACTIVE", reason: "Execution out is not active" }; + } + const partner = db + .query("SELECT active FROM partners WHERE id = $partnerId") + .get({ $partnerId: account.partnerId }) as { active: number } | null; + if (partner?.active !== 1) { + return { ok: false, code: "PARTNER_INACTIVE", reason: "Partner is not active" }; + } + const accountPartnerCode = resolveAccountPartnerCode(account.id, account.partnerId, account.metaJson); + if (accountPartnerCode !== command.partnerCode) { + return { + ok: false, + code: "PARTNER_MISMATCH", + reason: "Execution out does not belong to partnerCode", + }; + } + if (account.provider.toLowerCase() !== "kalshi") { + return { + ok: false, + code: "PROVIDER_NOT_IMPLEMENTED", + reason: `Authorized execution is not implemented for provider ${account.provider}`, + }; + } + if (account.currency.toUpperCase() !== "USD") { + return { + ok: false, + code: "CURRENCY_UNSUPPORTED", + reason: "Kalshi execution currently requires a USD out", + }; + } + const skin = resolveOutSkins(account).find((candidate) => candidate.name === command.skin); + if (!skin) { + return { ok: false, code: "SKIN_INACTIVE", reason: "Requested skin is missing or inactive" }; + } + if (!(await dependencies.isRiskHealthy())) { + return { + ok: false, + code: "EXECUTION_DENIED", + reason: "Global authorized-execution circuit breaker is not healthy", + }; + } + + const sitePerBetMax = usdMajorToMinorUnits(skin.perBetMax, "skin per-bet maximum"); + let client: Pick | undefined; + try { + client = dependencies.resolveClient + ? await dependencies.resolveClient(account) + : dependencies.client; + } catch (error) { + return { + ok: false, + code: "PROVIDER_SESSION_UNAVAILABLE", + reason: error instanceof Error ? error.message : "Kalshi credentials are unavailable", + }; + } + if (!client) { + return { + ok: false, + code: "PROVIDER_SESSION_UNAVAILABLE", + reason: "Kalshi live execution client resolver is not configured", + }; + } + let balancePromise: ReturnType | null = null; + const loadBalance = () => { + balancePromise ??= client.getBalance(); + return balancePromise; + }; + const now = dependencies.now ?? Date.now; + const request: BetRequest = { + partnerCode: command.partnerCode, + outId: command.outId, + skin: command.skin, + marketId: command.ticker, + selection: asMarketSelection(command.outcome), + idempotencyKey: command.idempotencyKey, + requestedStake: command.requestedStake, + decimalOdds: 100 / command.priceCents, + }; + const loadSnapshot = createKalshiExecutionSnapshotLoader({ + db, + side: command.outcome, + now, + maxAgeMs: dependencies.maxBookAgeMs, + loadCurrentPolicy: (authorization) => { + if (authorization.provider.toLowerCase() !== "kalshi") { + throw new Error("Active authorization is not bound to Kalshi"); + } + if (authorization.currency !== "USD") { + throw new Error("Active authorization currency does not match the Kalshi out"); + } + return policyFromAuthorization(authorization); + }, + loadSitePerBetMax: () => sitePerBetMax, + loadAvailableBalance: async () => { + const balance = await loadBalance(); + if (balance.balanceCents === null) throw new Error("Kalshi balance is unavailable"); + return balance.balanceCents; + }, + isProviderSessionValid: async () => (await loadBalance()).balanceCents !== null, + isRiskHealthy: dependencies.isRiskHealthy, + }); + const result = await executeAuthorizedBet(db, request, { + now, + loadSnapshot, + placeBet: createKalshiExecutionPlacer( + client, + createKalshiBuyOrderMapper(command.outcome), + ), + }); + if (!result.success) { + return { + ok: false, + code: "EXECUTION_DENIED", + reason: result.reason, + execution: result, + }; + } + return { + ok: true, + result, + order: isKalshiOrderResponseSummary(result.providerResponse) + ? result.providerResponse + : null, + }; +} + +/** Resolve out-scoped Kalshi credentials with out → partner → KALSHI_ fallback. */ +export function createKalshiAccountClientResolver( + envMap: Record = Bun.env as Record, +): (account: BettingAccountRow) => KalshiClient { + const clients = new Map(); + return (account) => { + const prefix = account.envPrefix?.trim() || canonicalKalshiOutPrefix(account.id); + const chain = envPrefixFallbackChain(prefix, "kalshi"); + const scoped = { + KALSHI_API_KEY_ID: firstEnv(chain, envMap, "API_KEY_ID"), + KALSHI_ACCESS_KEY: firstEnv(chain, envMap, "ACCESS_KEY"), + KALSHI_PRIVATE_KEY_PATH: firstEnv(chain, envMap, "PRIVATE_KEY_PATH"), + KALSHI_PRIVATE_KEY: firstEnv(chain, envMap, "PRIVATE_KEY"), + }; + const environment = envMap.KALSHI_ENV === "prod" ? "prod" : "demo"; + const fingerprint = new Bun.CryptoHasher("sha256") + .update(JSON.stringify({ ...scoped, environment })) + .digest("hex"); + const cached = clients.get(account.id); + if (cached?.fingerprint === fingerprint) return cached.client; + const client = createKalshiClient({ + credentials: loadKalshiCredentials(scoped), + env: environment, + }); + clients.set(account.id, { fingerprint, client }); + return client; + }; +} + +export function isKalshiOrderResponseSummary(value: unknown): value is KalshiOrderResponseSummary { + return ( + isRecord(value) && + typeof value.orderId === "string" && + typeof value.clientOrderId === "string" && + typeof value.ticker === "string" && + (value.outcome === "yes" || value.outcome === "no") && + Number.isFinite(value.fillCount) && + Number.isFinite(value.remainingCount) && + (value.state === "resting" || + value.state === "partially_filled" || + value.state === "filled" || + value.state === "not_filled") + ); +} + +function resolveAccountPartnerCode(outId: string, partnerId: string, metaJson: string): string { + const metaCode = parseOutMeta(metaJson).partnerCode; + if (typeof metaCode === "string" && metaCode.trim()) return metaCode.trim().toUpperCase(); + const outMatch = /^out-([A-Z]{3,6})-[1-9][0-9]*$/i.exec(outId); + if (outMatch) return outMatch[1]!.toUpperCase(); + return partnerId.replace(/^partner-/i, "").toUpperCase(); +} + +function canonicalKalshiOutPrefix(outId: string): string { + const match = /^out-([A-Z]{3,6})-([1-9][0-9]*)$/i.exec(outId); + return match ? `KALSHI_${match[1]!.toUpperCase()}_${match[2]}_` : "KALSHI_"; +} + +function firstEnv( + chain: ReturnType, + envMap: Record, + suffix: string, +): string | undefined { + for (const step of chain) { + const value = envMap[`${step.prefix}${suffix}`]?.trim(); + if (value) return value; + } + return undefined; +} + +function usdMajorToMinorUnits(value: number, label: string): number { + if (!Number.isFinite(value) || value < 0) throw new TypeError(`${label} must be non-negative`); + const minorUnits = Math.round(value * 100); + if (!Number.isSafeInteger(minorUnits)) throw new RangeError(`${label} exceeds safe integer range`); + return minorUnits; +} + +function positiveSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new TypeError(`${label} must be a positive safe integer`); + } + return value as number; +} + +function requiredString(value: unknown, label: string): string { + const parsed = optionalString(value); + if (!parsed) throw new TypeError(`${label} is required`); + return parsed; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/partner/execution/kalshi-snapshot.ts b/src/partner/execution/kalshi-snapshot.ts index 30dcba6..b1a8442 100644 --- a/src/partner/execution/kalshi-snapshot.ts +++ b/src/partner/execution/kalshi-snapshot.ts @@ -128,6 +128,11 @@ export function createKalshiExecutionSnapshotLoader( typeof dependencies.side === "function" ? dependencies.side(authorization, request) : dependencies.side; + if (request.selection.toLowerCase() !== side) { + throw new Error( + `Execution selection ${request.selection} does not match Kalshi ${side.toUpperCase()} snapshot side`, + ); + } const nowMs = dependencies.now?.() ?? Date.now(); const quote = loadKalshiMarketExecutionQuote(dependencies.db, { ticker: request.marketId, diff --git a/src/partner/execution/kalshi.ts b/src/partner/execution/kalshi.ts index 0c869ce..cb83294 100644 --- a/src/partner/execution/kalshi.ts +++ b/src/partner/execution/kalshi.ts @@ -14,6 +14,28 @@ export type KalshiExecutionOrderMapper = ( input: ProviderPlacementInput, ) => KalshiExecutionOrder; +export type KalshiPlacementState = + | "resting" + | "partially_filled" + | "filled" + | "not_filled"; + +export interface KalshiOrderResponseSummary { + environment: KalshiClient["environment"]; + orderId: string; + clientOrderId: string; + ticker: string; + outcome: KalshiExecutionOrder["side"]; + count: number; + priceCents: number; + state: KalshiPlacementState; + fillCount: number; + remainingCount: number; + averageFillPriceCents: number | null; + averageFeePaidCents: number | null; + processedAtMs: number | null; +} + export class KalshiOrderMappingError extends Error { constructor(message: string) { super(message); @@ -56,13 +78,13 @@ export function createKalshiExecutionPlacer( return { accepted: false as const, reason: "Kalshi processed the order without a fill or resting quantity", - responseSummary: summarizeKalshiOrderResult(client.environment, result), + responseSummary: summarizeKalshiOrderResult(client.environment, result, order), }; } return { accepted: true as const, ticketId: asTicketId(result.orderId), - responseSummary: summarizeKalshiOrderResult(client.environment, result), + responseSummary: summarizeKalshiOrderResult(client.environment, result, order), }; }; } @@ -73,6 +95,11 @@ export function createKalshiBuyOrderMapper( options: { postOnly?: boolean } = {}, ): KalshiExecutionOrderMapper { return ({ request, effectiveStake }) => { + if (request.selection.toLowerCase() !== side) { + throw new KalshiOrderMappingError( + `Execution selection ${request.selection} does not match Kalshi ${side.toUpperCase()} mapper`, + ); + } const priceCents = decimalOddsToKalshiPriceCents(request.decimalOdds); const count = Math.floor(effectiveStake / priceCents); if (count < 1) { @@ -109,7 +136,8 @@ export function decimalOddsToKalshiPriceCents(decimalOdds: number): number { function summarizeKalshiOrderResult( environment: KalshiClient["environment"], result: Awaited>, -) { + order: KalshiExecutionOrder, +): KalshiOrderResponseSummary { const state = result.fillCount > 0 && result.remainingCount > 0 ? "partially_filled" @@ -122,6 +150,10 @@ function summarizeKalshiOrderResult( environment, orderId: result.orderId, clientOrderId: result.clientOrderId, + ticker: order.ticker, + outcome: order.side, + count: order.count, + priceCents: order.priceCents, state, fillCount: result.fillCount, remainingCount: result.remainingCount, diff --git a/src/partner/execution/reservation.ts b/src/partner/execution/reservation.ts index 3f9a2ec..6f24516 100644 --- a/src/partner/execution/reservation.ts +++ b/src/partner/execution/reservation.ts @@ -14,6 +14,7 @@ import { asExecutionIdempotencyKey, asExposureReservationId, asMarketId, + asMarketSelection, asPlacementOwner, asTicketId, type BetRequest, @@ -36,6 +37,7 @@ type ReservationRow = { requested_stake: number; effective_stake: number; market_id: string; // brand-ok — SQLite wire value; parsed by mapReservation + selection: string; // brand-ok — SQLite wire value; parsed by mapReservation decimal_odds: number; status: ExposureReservationStatus; reservation_expires_at_ms: number; @@ -85,11 +87,11 @@ export function createPendingReservation( .query( `INSERT INTO exposure_reservations ( idempotency_key, partner_code, out_id, skin, provider, authorization_id, - requested_stake, effective_stake, market_id, decimal_odds, + requested_stake, effective_stake, market_id, selection, decimal_odds, status, reservation_expires_at_ms, created_at_ms, updated_at_ms ) VALUES ( $idempotencyKey, $partnerCode, $outId, $skin, $provider, $authorizationId, - $requestedStake, $effectiveStake, $marketId, $decimalOdds, + $requestedStake, $effectiveStake, $marketId, $selection, $decimalOdds, 'pending', $expiresAtMs, $nowMs, $nowMs ) ON CONFLICT(idempotency_key) DO NOTHING @@ -105,6 +107,7 @@ export function createPendingReservation( $requestedStake: input.request.requestedStake, $effectiveStake: input.effectiveStake, $marketId: input.request.marketId, + $selection: input.request.selection, $decimalOdds: input.request.decimalOdds, $expiresAtMs: input.expiresAtMs, $nowMs: input.nowMs, @@ -119,6 +122,7 @@ export function createPendingReservation( existing.skin !== input.request.skin || existing.authorizationId !== input.authorization.id || existing.marketId !== input.request.marketId || + existing.selection !== input.request.selection || existing.requestedStake !== input.request.requestedStake || existing.decimalOdds !== input.request.decimalOdds ) { @@ -478,6 +482,7 @@ function mapReservation(row: ReservationRow): ExposureReservation { requestedStake: row.requested_stake, effectiveStake: row.effective_stake, marketId: asMarketId(row.market_id), + selection: asMarketSelection(row.selection), decimalOdds: row.decimal_odds, status: row.status, reservationExpiresAtMs: row.reservation_expires_at_ms, diff --git a/src/partner/execution/sql.ts b/src/partner/execution/sql.ts index 8b8d8f5..12886eb 100644 --- a/src/partner/execution/sql.ts +++ b/src/partner/execution/sql.ts @@ -48,6 +48,15 @@ export const EXECUTION_MIGRATIONS = [ ON exposure_reservations (partner_code, out_id, skin, created_at_ms, status); `, }, + { + id: "002_exposure_reservation_selection", + sql: ` + ALTER TABLE exposure_reservations + ADD COLUMN selection TEXT NOT NULL DEFAULT 'legacy-unknown'; + CREATE INDEX IF NOT EXISTS idx_exposure_reservations_market_selection + ON exposure_reservations (market_id, selection, status); + `, + }, ] as const; type MigrationRow = { migrationId: string }; // brand-ok — internal migration wire value diff --git a/src/research/hq-app/app.js b/src/research/hq-app/app.js index 92c90a0..9f53ae7 100644 --- a/src/research/hq-app/app.js +++ b/src/research/hq-app/app.js @@ -560,11 +560,18 @@ function renderTrading(hq) { '
' + '

Order entry ' + badge("warn", "dry-run default") + "

" + '
' + + '' + + '' + + '' + '' + '' + '' + '' + '' + + '' + + '' + + '' + + '' + '' + '' + "
" + @@ -708,17 +715,49 @@ async function submitOrder(ev) { const out = $("#order-result"); const live = form.live.checked; if (live && !confirm("Place a LIVE order with real funds?")) return; + if (live) { + const missing = ["partnerCode", "outId", "skin", "sportId", "nodeId"] + .filter((name) => !form[name].value.trim()); + if (missing.length) { + out.innerHTML = 'live order missing: ' + esc(missing.join(", ")) + ""; + return; + } + if (form.postOnly.checked) { + out.innerHTML = 'authorized live orders require post-only to be unchecked'; + return; + } + } + const idempotencyKey = form.idempotencyKey.value.trim() || crypto.randomUUID(); + if (live) form.idempotencyKey.value = idempotencyKey; btn.disabled = true; out.innerHTML = 'submitting…'; try { const res = await fetch("/api/trading/order", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(live ? { + "Idempotency-Key": idempotencyKey, + "x-state-code": form.stateCode.value, + "x-node-id": form.nodeId.value.trim(), + } : {}), + }, body: JSON.stringify({ + partnerCode: form.partnerCode.value.trim(), + outId: form.outId.value.trim(), + skin: form.skin.value.trim(), ticker: form.ticker.value.trim(), side: form.side.value, + outcome: form.side.value, count: Number(form.count.value), priceCents: Number(form.priceCents.value), + stakeMinorUnits: Number(form.count.value) * Number(form.priceCents.value), + idempotencyKey, + wagerAmount: Number(form.count.value) * Number(form.priceCents.value) / 100, + betType: "straight", + sportId: form.sportId.value.trim(), + marketId: form.ticker.value.trim(), + stateCode: form.stateCode.value, postOnly: form.postOnly.checked, dryRun: !live, }), @@ -727,6 +766,7 @@ async function submitOrder(ev) { if (data.ok) { out.innerHTML = badge("ok", (data.dryRun ? "dry-run " : "LIVE ") + "accepted") + ' ' + esc(data.orderId) + ""; + if (live) form.idempotencyKey.value = ""; setTimeout(refresh, 1_500); } else { out.innerHTML = badge("bad", "rejected") + ' ' + esc(data.error) + ""; diff --git a/src/research/hq-view.ts b/src/research/hq-view.ts index 297fa09..58ec4ca 100644 --- a/src/research/hq-view.ts +++ b/src/research/hq-view.ts @@ -203,11 +203,18 @@ function renderTrading(hq) { '
' + '

Order entry ' + badge("warn", "dry-run default") + "

" + '
' + + '' + + '' + + '' + '' + '' + '' + '' + '' + + '' + + '' + + '' + + '' + '' + '' + "
" + @@ -351,17 +358,49 @@ async function submitOrder(ev) { const out = $("#order-result"); const live = form.live.checked; if (live && !confirm("Place a LIVE order with real funds?")) return; + if (live) { + const missing = ["partnerCode", "outId", "skin", "sportId", "nodeId"] + .filter((name) => !form[name].value.trim()); + if (missing.length) { + out.innerHTML = 'live order missing: ' + esc(missing.join(", ")) + ""; + return; + } + if (form.postOnly.checked) { + out.innerHTML = 'authorized live orders require post-only to be unchecked'; + return; + } + } + const idempotencyKey = form.idempotencyKey.value.trim() || crypto.randomUUID(); + if (live) form.idempotencyKey.value = idempotencyKey; btn.disabled = true; out.innerHTML = 'submitting…'; try { const res = await fetch("/api/trading/order", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(live ? { + "Idempotency-Key": idempotencyKey, + "x-state-code": form.stateCode.value, + "x-node-id": form.nodeId.value.trim(), + } : {}), + }, body: JSON.stringify({ + partnerCode: form.partnerCode.value.trim(), + outId: form.outId.value.trim(), + skin: form.skin.value.trim(), ticker: form.ticker.value.trim(), side: form.side.value, + outcome: form.side.value, count: Number(form.count.value), priceCents: Number(form.priceCents.value), + stakeMinorUnits: Number(form.count.value) * Number(form.priceCents.value), + idempotencyKey, + wagerAmount: Number(form.count.value) * Number(form.priceCents.value) / 100, + betType: "straight", + sportId: form.sportId.value.trim(), + marketId: form.ticker.value.trim(), + stateCode: form.stateCode.value, postOnly: form.postOnly.checked, dryRun: !live, }), @@ -370,6 +409,7 @@ async function submitOrder(ev) { if (data.ok) { out.innerHTML = badge("ok", (data.dryRun ? "dry-run " : "LIVE ") + "accepted") + ' ' + esc(data.orderId) + ""; + if (live) form.idempotencyKey.value = ""; setTimeout(refresh, 1_500); } else { out.innerHTML = badge("bad", "rejected") + ' ' + esc(data.error) + ""; diff --git a/src/research/serve.ts b/src/research/serve.ts index 1f2eb07..3690f47 100644 --- a/src/research/serve.ts +++ b/src/research/serve.ts @@ -22,6 +22,8 @@ import { type DeskLiquidityFlags, } from "../institutions/event-store/match-liquidity.ts"; import { Database } from "bun:sqlite"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { partnerDetailHandler } from "../regulatory/routes/ops/partners"; import { requireStateCompliance, type ComplianceContext } from "../regulatory/middleware/state-compliance"; import { createRateLimiter } from "../regulatory/middleware/rate-limit"; @@ -51,7 +53,16 @@ import { buildGlossaryApiPayload } from "../institutions/glossary.ts"; import { readPlayerProfiles } from "./player-profiles.ts"; import { getPlayerDetail } from "./tennis-hq-data.ts"; import { readOpponentProfiles } from "./player-opponent-profiles.ts"; -import { placeOrder, cancelOrder } from "../bot/kalshi-client.ts"; +import { + placeOrder, + cancelOrder, + type KalshiClient, +} from "../bot/kalshi-client.ts"; +import { + createKalshiAccountClientResolver, + executeKalshiLiveOrder, + parseKalshiLiveOrderCommand, +} from "../partner/execution/kalshi-live.ts"; import { codedError, httpStatusFor, type ErrorCode } from "../institutions/error-codes.ts"; import { designAgent } from "../agent/design-agent.ts"; import { fetchKalshiBookSnapshot, midFromBookSnapshot } from "../bot/kalshi-market-data.ts"; @@ -62,8 +73,6 @@ const regDb = new Database(REG_DB_PATH); // Bootstrap schema if in-memory if (REG_DB_PATH === ":memory:") { - const { readFileSync } = await import("fs"); - const { join } = await import("path"); const migration011 = readFileSync( join(import.meta.dir, "../regulatory/db/migrations/011_state_regulation.sql"), "utf-8", @@ -93,9 +102,15 @@ orchestrator.register(new AdminAgent(regDb)); const complianceGate = requireStateCompliance(regDb); const rateLimiter = createRateLimiter({ windowMs: 60_000, max: 100 }); const stateValidator = createStateValidator({ allowed: ["MA", "NJ"] }); +const resolveKalshiAccountClient = createKalshiAccountClientResolver(); export type ServeOptions = { port?: number; + trading?: { + db?: Database; + client?: Pick; + isRiskHealthy?: () => Promise | boolean; + }; }; export type RouteRequest

> = { @@ -248,7 +263,10 @@ function badOrder(code: ErrorCode, upstream?: string): Response { return json(codedError(code, upstream), httpStatusFor(code)); } -async function handleTradingOrder(req: Request): Promise { +export async function handleTradingOrder( + req: Request, + runtime: ServeOptions["trading"] = {}, +): Promise { let body: Record; try { body = (await req.json()) as Record; @@ -256,6 +274,83 @@ async function handleTradingOrder(req: Request): Promise { return badOrder("E_BODY_INVALID"); } + const dryRun = body.dryRun !== false; + if (!dryRun) { + if (!(req as Request & { compliance?: ComplianceContext }).compliance) { + return badOrder("E_AUTH_CONTEXT_REQUIRED", "live order did not pass compliance middleware"); + } + const parsed = parseKalshiLiveOrderCommand(body, req.headers.get("Idempotency-Key")); + if (!parsed.ok) { + return badOrder( + parsed.code === "IDEMPOTENCY_REQUIRED" + ? "E_IDEMPOTENCY_REQUIRED" + : "E_AUTH_CONTEXT_REQUIRED", + parsed.reason, + ); + } + const result = await executeKalshiLiveOrder( + runtime.db ?? openEventStore({ dbPath: DEFAULT_EVENT_STORE_DB }), + parsed.command, + { + ...(runtime.client + ? { client: runtime.client } + : { resolveClient: resolveKalshiAccountClient }), + isRiskHealthy: + runtime.isRiskHealthy ?? + (() => Bun.env.KALSHI_AUTHORIZED_EXECUTION_ENABLED === "1"), + }, + ); + if (!result.ok) { + if (result.code === "PROVIDER_NOT_IMPLEMENTED") { + return badOrder("E_PROVIDER_NOT_IMPLEMENTED", result.reason); + } + if (result.code === "PROVIDER_SESSION_UNAVAILABLE") { + return badOrder( + /missing kalshi_(?:api_key_id|access_key|private_key)/i.test(result.reason) + ? "E_NO_CREDS" + : "E_UPSTREAM", + result.reason, + ); + } + if ( + result.code === "ACCOUNT_NOT_FOUND" || + result.code === "ACCOUNT_INACTIVE" || + result.code === "PARTNER_INACTIVE" || + result.code === "PARTNER_MISMATCH" || + result.code === "SKIN_INACTIVE" || + result.code === "CURRENCY_UNSUPPORTED" + ) { + return badOrder("E_ACCOUNT_INACTIVE", result.reason); + } + if (result.execution?.code === "PROVIDER_OUTCOME_UNKNOWN") { + return badOrder("E_EXECUTION_UNKNOWN", result.reason); + } + if (result.execution?.code === "PROVIDER_REJECTED") { + return badOrder("E_EXECUTION_REJECTED", result.reason); + } + if (result.execution?.code === "SNAPSHOT_UNAVAILABLE") { + return badOrder("E_UPSTREAM", result.reason); + } + return badOrder("E_AUTHORIZATION_REQUIRED", result.reason); + } + resetTradingCache(); + return json({ + ok: true, + dryRun: false, + orderId: result.result.ticketId, + reservationId: result.result.reservationId, + effectiveStakeMinorUnits: result.result.effectiveStake, + status: result.order?.state ?? "confirmed", + fillCount: result.order?.fillCount ?? null, + remainingCount: result.order?.remainingCount ?? null, + ticker: parsed.command.ticker, + outcome: parsed.command.outcome, + partnerCode: parsed.command.partnerCode, + outId: parsed.command.outId, + skin: parsed.command.skin, + }); + } + const ticker = typeof body.ticker === "string" ? body.ticker.trim() : ""; if (!ticker) return badOrder("E_TICKER_REQUIRED"); const side = body.side === "no" ? "no" : body.side === "yes" ? "yes" : null; @@ -268,9 +363,6 @@ async function handleTradingOrder(req: Request): Promise { if (!Number.isInteger(priceCents) || priceCents < 1 || priceCents > 99) { return badOrder("E_PRICE_RANGE"); } - // Safety rail: anything other than explicit `false` stays dry-run. - const dryRun = body.dryRun !== false; - try { const result = await placeOrder({ ticker, @@ -1062,7 +1154,7 @@ export function createResearchServer(options: ServeOptions = {}) { // HQ order entry — same middleware stack as /place-bet; dry-run unless explicit dryRun:false if (url.pathname === "/api/trading/order" && req.method === "POST") { - return rateLimiter(req, () => stateValidator(req, () => complianceGate(req, () => handleTradingOrder(req)))); + return rateLimiter(req, () => stateValidator(req, () => complianceGate(req, () => handleTradingOrder(req, options.trading)))); } // HQ order cancel diff --git a/tests/partner/execution/executor.test.ts b/tests/partner/execution/executor.test.ts index 6fa017c..9e05ba0 100644 --- a/tests/partner/execution/executor.test.ts +++ b/tests/partner/execution/executor.test.ts @@ -20,6 +20,7 @@ import { import { asExecutionIdempotencyKey, asMarketId, + asMarketSelection, asTicketId, type BetRequest, type ExecutionDependencies, @@ -87,6 +88,7 @@ function request(key: string, requestedStake = 700): BetRequest { outId: asOutId("out-SPORTS-1"), skin: asSkinId("main"), marketId: asMarketId("market-1"), + selection: asMarketSelection("yes"), idempotencyKey: asExecutionIdempotencyKey(key), requestedStake, decimalOdds: 2, diff --git a/tests/partner/execution/kalshi-live.test.ts b/tests/partner/execution/kalshi-live.test.ts new file mode 100644 index 0000000..2276df1 --- /dev/null +++ b/tests/partner/execution/kalshi-live.test.ts @@ -0,0 +1,353 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import type { KalshiClient } from "../../../src/bot/kalshi-client.ts"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramUserId, + type AuthorizationPolicy, +} from "../../../src/partner/authorization/domain.ts"; +import { + approveAuthorizationRequest, + createAuthorizationRequest, +} from "../../../src/partner/authorization/service.ts"; +import { + executeKalshiLiveOrder, + createKalshiAccountClientResolver, + parseKalshiLiveOrderCommand, + type KalshiLiveOrderCommand, +} from "../../../src/partner/execution/kalshi-live.ts"; +import { migrateExecutionSchema } from "../../../src/partner/execution/sql.ts"; +import { + asExecutionIdempotencyKey, + asMarketId, +} from "../../../src/partner/execution/domain.ts"; +import { + ensurePartnerRegistrySchema, + upsertBettingAccount, + upsertPartner, +} from "../../../src/partner/registry.ts"; + +const NOW_MS = 1_700_000_000_000; + +describe("Kalshi live execution orchestration", () => { + test("parses canonical identity and requires explicit idempotency", () => { + const wire = { + partnerCode: "sports", + outId: "out-SPORTS-1", + skin: "main", + ticker: "KXTEST", + outcome: "yes", + stakeMinorUnits: 125, + priceCents: 40, + }; + expect(parseKalshiLiveOrderCommand(wire)).toMatchObject({ + ok: false, + code: "IDEMPOTENCY_REQUIRED", + }); + expect(parseKalshiLiveOrderCommand(wire, "request-1")).toMatchObject({ + ok: true, + command: { partnerCode: "SPORTS", idempotencyKey: "request-1" }, + }); + expect(parseKalshiLiveOrderCommand({ ...wire, outId: "out-OTHER-1" }, "request-2")) + .toMatchObject({ ok: false, code: "INVALID_REQUEST" }); + }); + + test("resolves the active skin, binds the live balance/book, quantizes, and audits outcome", async () => { + const db = setup(); + const orders: Array> = []; + const client = mockClient(orders); + const result = await executeKalshiLiveOrder(db, command(), { + client, + now: () => NOW_MS, + maxBookAgeMs: 1_000, + isRiskHealthy: () => true, + }); + expect(result).toMatchObject({ + ok: true, + result: { success: true, effectiveStake: 120 }, + order: { + ticker: "KXTEST", + outcome: "yes", + count: 3, + priceCents: 40, + state: "partially_filled", + fillCount: 1, + remainingCount: 2, + }, + }); + expect(orders).toHaveLength(1); + expect(orders[0]).toMatchObject({ side: "yes", count: 3, priceCents: 40, dryRun: false }); + expect( + db.query( + `SELECT partner_code, out_id, skin, provider, market_id, selection, + requested_stake, effective_stake, status + FROM exposure_reservations`, + ).get(), + ).toEqual({ + partner_code: "SPORTS", + out_id: "out-SPORTS-1", + skin: "main", + provider: "kalshi", + market_id: "KXTEST", + selection: "yes", + requested_stake: 125, + effective_stake: 120, + status: "confirmed", + }); + db.close(); + }); + + test("fails closed for inactive/mismatched account state and non-Kalshi providers", async () => { + for (const variant of ["inactive", "partner", "provider"] as const) { + const db = setup(variant); + const result = await executeKalshiLiveOrder(db, command(), { + client: mockClient([]), + now: () => NOW_MS, + isRiskHealthy: () => true, + }); + expect(result.ok).toBeFalse(); + if (!result.ok) { + expect(result.code).toBe( + variant === "inactive" + ? "ACCOUNT_INACTIVE" + : variant === "partner" + ? "PARTNER_INACTIVE" + : "PROVIDER_NOT_IMPLEMENTED", + ); + } + db.close(); + } + }); + + test("fails closed when risk, balance, or quote binding is unavailable", async () => { + const cases = [ + { risk: false, balance: 10_000, priceCents: 40 }, + { risk: true, balance: null, priceCents: 40 }, + { risk: true, balance: 10_000, priceCents: 41 }, + ] as const; + for (const item of cases) { + const db = setup(); + const result = await executeKalshiLiveOrder( + db, + command({ priceCents: item.priceCents }), + { + client: mockClient([], item.balance), + now: () => NOW_MS, + maxBookAgeMs: 1_000, + isRiskHealthy: () => item.risk, + }, + ); + expect(result).toMatchObject({ ok: false, code: "EXECUTION_DENIED" }); + expect(db.query("SELECT count(*) AS count FROM exposure_reservations").get()).toEqual({ + count: 0, + }); + db.close(); + } + }); + + test("resolves and caches out-scoped credentials without falling through to another out", () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const pem = privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const resolve = createKalshiAccountClientResolver({ + KALSHI_SPORTS_1_API_KEY_ID: "out-key", + KALSHI_SPORTS_1_PRIVATE_KEY: pem, + KALSHI_ENV: "demo", + }); + const account = { + id: "out-SPORTS-1", + partnerId: "partner-sports", + provider: "kalshi" as const, + url: "", + status: "active" as const, + envPrefix: "KALSHI_SPORTS_1_", + maxStake: 5, + maxWin: 20, + currency: "USD", + skin: null, + metaJson: "{}", + }; + const first = resolve(account); + expect(resolve(account)).toBe(first); + expect(first.environment).toBe("demo"); + expect(() => resolve({ ...account, id: "out-OTHER-1", envPrefix: "KALSHI_OTHER_1_" })) + .toThrow(/Missing KALSHI_API_KEY_ID/); + }); + + test("rebuilds an out-scoped client after credential rotation", () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const pem = privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const env = { + KALSHI_SPORTS_1_API_KEY_ID: "out-key-v1", + KALSHI_SPORTS_1_PRIVATE_KEY: pem, + KALSHI_ENV: "demo", + }; + const resolve = createKalshiAccountClientResolver(env); + const account = { + id: "out-SPORTS-1", + partnerId: "partner-sports", + provider: "kalshi" as const, + url: "", + status: "active" as const, + envPrefix: "KALSHI_SPORTS_1_", + maxStake: 5, + maxWin: 20, + currency: "USD", + skin: null, + metaJson: "{}", + }; + const first = resolve(account); + env.KALSHI_SPORTS_1_API_KEY_ID = "out-key-v2"; + expect(resolve(account)).not.toBe(first); + }); + + test("returns an explicit session failure when scoped credentials cannot resolve", async () => { + const db = setup(); + const result = await executeKalshiLiveOrder(db, command(), { + resolveClient: () => { + throw new Error("Missing KALSHI_API_KEY_ID (or KALSHI_ACCESS_KEY)"); + }, + isRiskHealthy: () => true, + }); + expect(result).toEqual({ + ok: false, + code: "PROVIDER_SESSION_UNAVAILABLE", + reason: "Missing KALSHI_API_KEY_ID (or KALSHI_ACCESS_KEY)", + }); + db.close(); + }); +}); + +function setup(variant?: "inactive" | "partner" | "provider"): Database { + const db = new Database(":memory:"); + ensurePartnerRegistrySchema(db); + migrateExecutionSchema(db, NOW_MS); + db.exec(` + CREATE TABLE book_ticks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticker TEXT, + ts INTEGER NOT NULL, + recv_ts INTEGER, + levels_json TEXT NOT NULL, + source TEXT NOT NULL + ); + `); + upsertPartner(db, { + id: "partner-sports", + name: "Sports Partner", + active: variant !== "partner", + profitSplit: null, + commissionRate: null, + notes: null, + }, NOW_MS); + upsertBettingAccount(db, { + id: "out-SPORTS-1", + partnerId: "partner-sports", + provider: variant === "provider" ? "fantasy402" : "kalshi", + url: "", + status: variant === "inactive" ? "inactive" : "active", + envPrefix: "KALSHI_SPORTS_1_", + maxStake: 5, + maxWin: 20, + currency: "USD", + skin: null, + metaJson: JSON.stringify({ + partnerCode: "SPORTS", + skins: [{ name: "main", perBetMax: 5, maxWin: 20, active: true }], + }), + }, NOW_MS); + const policy: AuthorizationPolicy = { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("kalshi"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 500, + maxWin: 2_000, + maxWinBasis: "profit", + dailyLimit: 5_000, + exposureLimit: 2_000, + currency: asCurrencyCode("USD"), + validFromMs: NOW_MS - 1_000, + expiresAtMs: NOW_MS + 60_000, + }; + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id, created_at_ms + ) VALUES ('SPORTS', 'out-SPORTS-1', '789', $nowMs)`, + ).run({ $nowMs: NOW_MS }); + const request = createAuthorizationRequest(db, { + policy, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: null, + telegramMessageId: asTelegramMessageId("100"), + nowMs: NOW_MS, + }); + if (!request.ok) throw new Error(request.reason); + const approved = approveAuthorizationRequest(db, { + requestId: request.request.id, + currentPolicy: policy, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: null, + telegramMessageId: asTelegramMessageId("101"), + approvingUserId: asTelegramUserId("789"), + nowMs: NOW_MS, + }); + if (!approved.ok) throw new Error(approved.reason); + db.query( + `INSERT INTO book_ticks (ticker, ts, recv_ts, levels_json, source) + VALUES ('KXTEST', $ts, $ts, $book, 'kalshi-ws')`, + ).run({ + $ts: NOW_MS - 100, + $book: JSON.stringify({ + ts: NOW_MS - 100, + seq: 1, + bids: [{ priceCents: 35, size: 10 }], + asks: [{ priceCents: 40, size: 10 }], + }), + }); + return db; +} + +function command(overrides: Partial = {}): KalshiLiveOrderCommand { + return { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + skin: asSkinId("main"), + ticker: asMarketId("KXTEST"), + outcome: "yes", + requestedStake: 125, + priceCents: 40, + idempotencyKey: asExecutionIdempotencyKey("live-order-1"), + ...overrides, + }; +} + +function mockClient( + calls: Array>, + balanceCents: number | null = 10_000, +): Pick { + return { + environment: "demo", + getBalance: async () => ({ balanceCents }), + placeOrder: async (order) => { + calls.push(order as unknown as Record); + return { + orderId: "order-1", + clientOrderId: order.clientOrderId!, + fillCount: 1, + remainingCount: 2, + averageFillPriceCents: 40, + averageFeePaidCents: 1, + processedAtMs: NOW_MS, + dryRun: false, + }; + }, + }; +} diff --git a/tests/partner/execution/kalshi-snapshot.test.ts b/tests/partner/execution/kalshi-snapshot.test.ts index ddbe391..99ceda0 100644 --- a/tests/partner/execution/kalshi-snapshot.test.ts +++ b/tests/partner/execution/kalshi-snapshot.test.ts @@ -12,6 +12,7 @@ import { import { asExecutionIdempotencyKey, asMarketId, + asMarketSelection, } from "../../../src/partner/execution/domain.ts"; import { createKalshiExecutionSnapshotLoader, @@ -125,6 +126,7 @@ describe("Kalshi execution snapshot loader", () => { outId: currentPolicy.outId, skin: currentPolicy.skin, marketId: asMarketId("KXTEST"), + selection: asMarketSelection("yes"), idempotencyKey: asExecutionIdempotencyKey("quote-bind"), requestedStake: 100, decimalOdds: 100 / 65, diff --git a/tests/partner/execution/kalshi.test.ts b/tests/partner/execution/kalshi.test.ts index 6632e7f..2d8c871 100644 --- a/tests/partner/execution/kalshi.test.ts +++ b/tests/partner/execution/kalshi.test.ts @@ -17,6 +17,7 @@ import { import { asExecutionIdempotencyKey, asMarketId, + asMarketSelection, } from "../../../src/partner/execution/domain.ts"; import { createKalshiExecutionPlacer, @@ -91,6 +92,7 @@ describe("Kalshi authorized execution adapter", () => { outId: currentPolicy.outId, skin: currentPolicy.skin, marketId: asMarketId("KXTEST"), + selection: asMarketSelection("yes"), idempotencyKey: asExecutionIdempotencyKey("bet-1"), requestedStake: 2, decimalOdds: 2, @@ -111,7 +113,7 @@ describe("Kalshi authorized execution adapter", () => { test("maps minor-unit risk to contract count and the request's quoted price", () => { expect(decimalOddsToKalshiPriceCents(2.5)).toBe(40); const mapper = createKalshiBuyOrderMapper("no"); - const input = executionInput({ effectiveStake: 125, decimalOdds: 2.5 }); + const input = executionInput({ effectiveStake: 125, decimalOdds: 2.5, selection: "no" }); expect(mapper(input)).toEqual({ ticker: "KXTEST", side: "no", @@ -119,7 +121,7 @@ describe("Kalshi authorized execution adapter", () => { priceCents: 40, postOnly: false, }); - expect(() => mapper(executionInput({ effectiveStake: 39, decimalOdds: 2.5 }))).toThrow( + expect(() => mapper(executionInput({ effectiveStake: 39, decimalOdds: 2.5, selection: "no" }))).toThrow( /below the 40-cent cost/, ); }); @@ -164,7 +166,9 @@ describe("Kalshi authorized execution adapter", () => { }); }); -function executionInput(overrides: { effectiveStake?: number; decimalOdds?: number } = {}) { +function executionInput( + overrides: { effectiveStake?: number; decimalOdds?: number; selection?: "yes" | "no" } = {}, +) { const currentPolicy = { partnerCode: asPartnerCode("SPORTS"), outId: asOutId("out-SPORTS-1"), @@ -199,6 +203,7 @@ function executionInput(overrides: { effectiveStake?: number; decimalOdds?: numb outId: currentPolicy.outId, skin: currentPolicy.skin, marketId: asMarketId("KXTEST"), + selection: asMarketSelection(overrides.selection ?? "yes"), idempotencyKey: asExecutionIdempotencyKey("bet-helper"), requestedStake: overrides.effectiveStake ?? 100, decimalOdds: overrides.decimalOdds ?? 2, diff --git a/tests/partner/execution/reservation.test.ts b/tests/partner/execution/reservation.test.ts index 90601b0..04c9320 100644 --- a/tests/partner/execution/reservation.test.ts +++ b/tests/partner/execution/reservation.test.ts @@ -20,6 +20,7 @@ import { import { asExecutionIdempotencyKey, asMarketId, + asMarketSelection, asPlacementOwner, asTicketId, } from "../../../src/partner/execution/domain.ts"; @@ -36,7 +37,10 @@ import { releaseExpiredReservations, settleConfirmedReservation, } from "../../../src/partner/execution/reservation.ts"; -import { migrateExecutionSchema } from "../../../src/partner/execution/sql.ts"; +import { + EXECUTION_MIGRATIONS, + migrateExecutionSchema, +} from "../../../src/partner/execution/sql.ts"; const NOW_MS = 1_700_000_000_000; @@ -60,7 +64,10 @@ function policy(): AuthorizationPolicy { function setup(): { db: Database; authorization: ApprovedAuthorization } { const db = new Database(":memory:"); - expect(migrateExecutionSchema(db, NOW_MS)).toEqual(["001_exposure_reservations"]); + expect(migrateExecutionSchema(db, NOW_MS)).toEqual([ + "001_exposure_reservations", + "002_exposure_reservation_selection", + ]); expect(migrateExecutionSchema(db, NOW_MS + 1)).toEqual([]); const p = policy(); db.query( @@ -95,6 +102,7 @@ function request(key = "bet-1", stake = 1_000) { outId: asOutId("out-SPORTS-1"), skin: asSkinId("main"), marketId: asMarketId("market-1"), + selection: asMarketSelection("yes"), idempotencyKey: asExecutionIdempotencyKey(key), requestedStake: stake, decimalOdds: 2, @@ -114,6 +122,27 @@ function pending(db: Database, authorization: ApprovedAuthorization, key = "bet- } describe("execution exposure reservations", () => { + test("upgrades existing reservations with an auditable selection column", () => { + const db = new Database(":memory:"); + db.exec(EXECUTION_MIGRATIONS[0].sql); + db.exec(` + CREATE TABLE _partner_execution_migrations ( + id TEXT PRIMARY KEY, + applied_at_ms INTEGER NOT NULL + ); + INSERT INTO _partner_execution_migrations (id, applied_at_ms) + VALUES ('001_exposure_reservations', 1); + `); + expect(migrateExecutionSchema(db, NOW_MS)).toEqual([ + "002_exposure_reservation_selection", + ]); + const columns = db.query("PRAGMA table_info(exposure_reservations)").all() as Array<{ + name: string; + }>; + expect(columns.some((column) => column.name === "selection")).toBeTrue(); + db.close(); + }); + test("migrates prerequisites and creates an idempotent pending reservation", () => { const { db, authorization } = setup(); const first = pending(db, authorization); diff --git a/tests/research/trading-order.test.ts b/tests/research/trading-order.test.ts new file mode 100644 index 0000000..f105ba0 --- /dev/null +++ b/tests/research/trading-order.test.ts @@ -0,0 +1,295 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import type { KalshiClient } from "../../src/bot/kalshi-client.ts"; +import { + asCurrencyCode, + asOutId, + asPartnerCode, + asProviderId, + asSkinId, + asTelegramChatId, + asTelegramMessageId, + asTelegramUserId, + type AuthorizationPolicy, +} from "../../src/partner/authorization/domain.ts"; +import { + approveAuthorizationRequest, + createAuthorizationRequest, +} from "../../src/partner/authorization/service.ts"; +import { migrateExecutionSchema } from "../../src/partner/execution/sql.ts"; +import { + ensurePartnerRegistrySchema, + upsertBettingAccount, + upsertPartner, +} from "../../src/partner/registry.ts"; +import { handleTradingOrder } from "../../src/research/serve.ts"; + +describe("HQ trading-order authorization boundary", () => { + test("preserves legacy dry-run behavior without authorization context", async () => { + const response = await handleTradingOrder(request({ + ticker: "KXTEST", + side: "yes", + count: 2, + priceCents: 40, + dryRun: true, + })); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + dryRun: true, + ticker: "KXTEST", + side: "yes", + count: 2, + priceCents: 40, + }); + }); + + test("live execution requires proof that compliance middleware ran", async () => { + const response = await handleTradingOrder(request(liveBody())); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + ok: false, + code: "E_AUTH_CONTEXT_REQUIRED", + }); + }); + + test("Fantasy402 is explicitly 501 and never reaches provider placement", async () => { + const db = fantasyDatabase(); + let providerCalls = 0; + const req = request(liveBody(), { "Idempotency-Key": "live-http-1" }); + (req as Request & { compliance?: unknown }).compliance = { + stateCode: "MA", + userId: "operator-1", + playId: "play-1", + parsedBody: {}, + }; + const response = await handleTradingOrder(req, { + db, + client: { + environment: "demo", + getBalance: async () => { + providerCalls++; + return { balanceCents: 1_000 }; + }, + placeOrder: async () => { + providerCalls++; + throw new Error("must not place"); + }, + } satisfies Pick, + isRiskHealthy: () => true, + }); + expect(response.status).toBe(501); + expect(await response.json()).toMatchObject({ + ok: false, + code: "E_PROVIDER_NOT_IMPLEMENTED", + }); + expect(providerCalls).toBe(0); + db.close(); + }); + + test("live route binds authorization, balance, book, reservation, and response", async () => { + const nowMs = Date.now(); + const db = authorizedDatabase(nowMs); + let providerCalls = 0; + const req = request(liveBody(), { "Idempotency-Key": "live-http-1" }); + attachCompliance(req); + const response = await handleTradingOrder(req, { + db, + client: { + environment: "demo", + getBalance: async () => ({ balanceCents: 10_000 }), + placeOrder: async (order) => { + providerCalls++; + return { + orderId: "order-http-1", + clientOrderId: order.clientOrderId!, + fillCount: 2, + remainingCount: 0, + averageFillPriceCents: 40, + averageFeePaidCents: 1, + processedAtMs: nowMs, + dryRun: false, + }; + }, + }, + isRiskHealthy: () => true, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + dryRun: false, + orderId: "order-http-1", + effectiveStakeMinorUnits: 80, + status: "filled", + fillCount: 2, + remainingCount: 0, + partnerCode: "SPORTS", + outId: "out-SPORTS-1", + skin: "main", + }); + expect(providerCalls).toBe(1); + expect( + db.query( + "SELECT market_id, selection, effective_stake, status FROM exposure_reservations", + ).get(), + ).toEqual({ + market_id: "KXTEST", + selection: "yes", + effective_stake: 80, + status: "confirmed", + }); + db.close(); + }); +}); + +function request(body: unknown, headers: Record = {}): Request { + return new Request("http://localhost/api/trading/order", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +function liveBody(): Record { + return { + partnerCode: "SPORTS", + outId: "out-SPORTS-1", + skin: "main", + ticker: "KXTEST", + outcome: "yes", + stakeMinorUnits: 80, + priceCents: 40, + idempotencyKey: "live-http-1", + dryRun: false, + }; +} + +function attachCompliance(req: Request): void { + (req as Request & { compliance?: unknown }).compliance = { + stateCode: "MA", + userId: "operator-1", + playId: "play-1", + parsedBody: {}, + }; +} + +function authorizedDatabase(nowMs: number): Database { + const db = new Database(":memory:"); + ensurePartnerRegistrySchema(db); + migrateExecutionSchema(db, nowMs); + db.exec(` + CREATE TABLE book_ticks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticker TEXT, + ts INTEGER NOT NULL, + recv_ts INTEGER, + levels_json TEXT NOT NULL, + source TEXT NOT NULL + ); + `); + upsertPartner(db, { + id: "partner-sports", + name: "Sports Partner", + active: true, + profitSplit: null, + commissionRate: null, + notes: null, + }, nowMs); + upsertBettingAccount(db, { + id: "out-SPORTS-1", + partnerId: "partner-sports", + provider: "kalshi", + url: "", + status: "active", + envPrefix: "KALSHI_SPORTS_1_", + maxStake: 5, + maxWin: 20, + currency: "USD", + skin: null, + metaJson: JSON.stringify({ + partnerCode: "SPORTS", + skins: [{ name: "main", perBetMax: 5, maxWin: 20, active: true }], + }), + }, nowMs); + const policy: AuthorizationPolicy = { + partnerCode: asPartnerCode("SPORTS"), + outId: asOutId("out-SPORTS-1"), + provider: asProviderId("kalshi"), + skin: asSkinId("main"), + scope: "live_trade", + maxStake: 500, + maxWin: 2_000, + maxWinBasis: "profit", + dailyLimit: 5_000, + exposureLimit: 2_000, + currency: asCurrencyCode("USD"), + validFromMs: nowMs - 1_000, + expiresAtMs: nowMs + 60_000, + }; + db.query( + `INSERT INTO account_authorization_approvers ( + partner_code, out_id, telegram_user_id, created_at_ms + ) VALUES ('SPORTS', 'out-SPORTS-1', '789', $nowMs)`, + ).run({ $nowMs: nowMs }); + const authRequest = createAuthorizationRequest(db, { + policy, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: null, + telegramMessageId: asTelegramMessageId("100"), + nowMs, + }); + if (!authRequest.ok) throw new Error(authRequest.reason); + const approved = approveAuthorizationRequest(db, { + requestId: authRequest.request.id, + currentPolicy: policy, + telegramChatId: asTelegramChatId("-123"), + telegramTopicId: null, + telegramMessageId: asTelegramMessageId("101"), + approvingUserId: asTelegramUserId("789"), + nowMs, + }); + if (!approved.ok) throw new Error(approved.reason); + db.query( + `INSERT INTO book_ticks (ticker, ts, recv_ts, levels_json, source) + VALUES ('KXTEST', $ts, $ts, $book, 'kalshi-ws')`, + ).run({ + $ts: nowMs - 100, + $book: JSON.stringify({ + ts: nowMs - 100, + seq: 1, + bids: [{ priceCents: 35, size: 10 }], + asks: [{ priceCents: 40, size: 10 }], + }), + }); + return db; +} + +function fantasyDatabase(): Database { + const db = new Database(":memory:"); + ensurePartnerRegistrySchema(db); + upsertPartner(db, { + id: "partner-sports", + name: "Sports Partner", + active: true, + profitSplit: null, + commissionRate: null, + notes: null, + }); + upsertBettingAccount(db, { + id: "out-SPORTS-1", + partnerId: "partner-sports", + provider: "fantasy402", + url: "", + status: "active", + envPrefix: "FANTASY402_SPORTS_1_", + maxStake: 5, + maxWin: 20, + currency: "USD", + skin: null, + metaJson: JSON.stringify({ + partnerCode: "SPORTS", + skins: [{ name: "main", perBetMax: 5, maxWin: 20, active: true }], + }), + }); + return db; +} From 842ac27c06cc7750db244a1cda0feb4f0c540948 Mon Sep 17 00:00:00 2001 From: nolarose Date: Thu, 6 Aug 2026 03:57:08 -0500 Subject: [PATCH 7/7] chore(ops): align local execution authority --- .bun-version | 1 + .github/pull_request_template.md | 19 ++++ .github/workflows/check.yml | 11 +-- AGENTS.md | 36 +++++++ README.md | 13 +-- bunfig.toml | 3 +- docs/AUTHORIZED_EXECUTION.md | 96 +++++++++++++++++++ docs/ENV_NAMING.md | 20 +++- package.json | 5 +- src/partner/toml-stringify.ts | 156 +++++++++++++++++++++++++++++-- 10 files changed, 335 insertions(+), 25 deletions(-) create mode 100644 .bun-version create mode 100644 .github/pull_request_template.md create mode 100644 AGENTS.md create mode 100644 docs/AUTHORIZED_EXECUTION.md diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000..085c0f2 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.14 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8cde736 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Outcome + + + +## What changed + + + +## Safety and compatibility + + + +## Validation + + + +## Follow-up + + diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 07614f5..84eeaea 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,10 +1,9 @@ -# Same gate as tools/pre-commit.sh / bun run check -name: check +# Manual diagnostic only. Local `bun run bun:ci` is merge authority because +# hosted runners are billing-blocked for this repository. +name: check (manual diagnostic) on: - push: - branches: [main] - pull_request: + workflow_dispatch: jobs: check: @@ -13,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: - bun-version: "canary" + bun-version: "1.3.14" - name: Install run: bun install --frozen-lockfile - name: Check diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38e8e86 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Kalshi-bot Local Authority + +This standalone repository inherits `/Users/nolarose/Projects/AGENTS.md` and +the global DX context. This file narrows their application; it does not weaken +runtime safety. + +## Commands + +- Bun 1.3.14 stable is the supported local and production baseline. +- Use `bun run bun:ci` as local merge proof. `bun run check` is the same owned + gate beneath it. +- GitHub Actions is a manual diagnostic only while hosted runners are + billing-blocked. A missing hosted check is not merge authority. +- Use focused `bun test ` while developing. Do not translate Node/Jest + worker flags into Bun flags. + +## Authorized execution + +- Live HTTP orders must enter through `handleTradingOrder` and + `executeKalshiLiveOrder`, then reach the provider only through + `executeAuthorizedBet`. +- Never bypass compliance, an active SQLite authorization grant, policy-hash + verification, executable-book freshness, balance/liquidity caps, exposure + reservation, provider idempotency, or the global risk breaker. +- `KALSHI_AUTHORIZED_EXECUTION_ENABLED=1` opens only the partner-route breaker. + Production additionally requires `KALSHI_ENV=prod` and + `KALSHI_PROD_ARMED=1`. +- Fantasy402 live execution remains unavailable until its provider-side + idempotency contract is proven. + +## Repository hygiene + +- Preserve unrelated dirty and untracked files. Stage with explicit paths. +- Runtime policy, agent instructions, skills, hooks, and documentation cannot + enable live execution; only the runtime gates and verified database state can. +- See `docs/AUTHORIZED_EXECUTION.md` for the operational work card. diff --git a/README.md b/README.md index 696558d..27d0dc1 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ Standalone Bun project for discovering and ranking public [Kalshi](https://kalshi.com) trading bots on GitHub. -**Zero runtime npm dependencies** — Bun + authenticated [`gh`](https://cli.github.com/) CLI only. +**Bun-native runtime** with a deliberately small dependency surface. ## Prerequisites -- [Bun](https://bun.sh) >= 1.3.13 ([`URLPattern`](https://bun.com/blog/bun-v1.3.4#urlpattern-api), [`Bun.cron`](https://bun.com/docs/runtime/cron), [SHA3-256](https://bun.com/blog/bun-v1.3.13#sha3-support-in-webcrypto-and-node-crypto)) +- [Bun](https://bun.sh) >= 1.3.14 ([`URLPattern`](https://bun.com/blog/bun-v1.3.4#urlpattern-api), [`Bun.cron`](https://bun.com/docs/runtime/cron), [SHA3-256](https://bun.com/blog/bun-v1.3.13#sha3-support-in-webcrypto-and-node-crypto)) - GitHub CLI on PATH (`gh auth login`) -- No `bun install` required — zero npm deps; see [`docs/BUN_NATIVE.md` — Package manager](docs/BUN_NATIVE.md#package-manager) +- `bun install --frozen-lockfile` - Optional secrets via [Proton Pass CLI](https://protonpass.github.io/pass-cli/) — see [`docs/PROTONPASS.md`](docs/PROTONPASS.md) +- Authorized execution operators: [`docs/AUTHORIZED_EXECUTION.md`](docs/AUTHORIZED_EXECUTION.md) ## Quick start @@ -23,7 +24,7 @@ bun run agent status # latest run from cache.db bun run agent patterns # pattern extract from cached run bun run agent blueprint # architecture blueprint from cache bun run report:term # ANSI latest.md in terminal -bun test && bun run typecheck # posttest restores committed artifacts from fixtures +bun run bun:ci # guard + typecheck + tests + artifact restore ``` ### Commit flow @@ -31,7 +32,7 @@ bun test && bun run typecheck # posttest restores committed artifacts Tests can overwrite `latest.md` or audit JSONL — **`posttest` restores from fixtures** automatically. Before committing: ```bash -bun run check # typecheck + test + artifact restore +bun run bun:ci # local merge authority bun run hooks:install # once: install pre-commit gate git add … && git commit # pre-commit runs check + deletion guard ``` @@ -103,7 +104,7 @@ Niche dimensions (`sports-nba`, `tracking`, …) may discover candidates but pro | Restore artifacts | `bun run artifacts:restore` — fixtures → reports + audit JSONL | | Pre-commit gate | `bun run hooks:install` then `git commit` runs `bun run check` | | Types | `bun run typecheck` | -| Full check | `bun run check` — typecheck + test | +| Full check | `bun run bun:ci` — guard + typecheck + test; local merge authority | ## Cache, diff, and artifacts diff --git a/bunfig.toml b/bunfig.toml index a2cab22..b50c0a4 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -20,7 +20,8 @@ depth = 3 # Override any key at runtime via env vars: # KALSHI__REGULATORY__DATABASE_PATH=/tmp/test.db bun run script.ts # -# Native API reference: +# Native API reference. `src/partner/toml-stringify.ts` owns the stable-runtime +# fallback until the selected stable Bun ships stringify: # Bun.TOML.parse(string) -> object # Bun.TOML.stringify(object) -> string # @see https://bun.com/docs/api/toml diff --git a/docs/AUTHORIZED_EXECUTION.md b/docs/AUTHORIZED_EXECUTION.md new file mode 100644 index 0000000..0b2fdf5 --- /dev/null +++ b/docs/AUTHORIZED_EXECUTION.md @@ -0,0 +1,96 @@ +# Authorized Partner Execution + +Status: implemented, default off. This is the operational work card for the +authorization, Telegram approval, exposure reservation, Kalshi mapping, and +live HTTP orchestration layers. + +## Authority boundary + +Live provider placement follows one path: + +```text +HTTP compliance + → canonical partnerCode / outId / skin request + → active SQLite authorization + immutable policy hash + → fresh executable Kalshi book + live portfolio balance + → integer stake caps + transactional exposure reservation + → idempotent Kalshi V2 placement + → confirmed, rejected, or unknown reservation + durable receipt +``` + +Skills, agent instructions, documentation, hooks, CI, and dashboard controls do +not grant trading permission. They can only inspect, test, or document this +path. Runtime permission comes from verified database state and the explicit +environment gates below. + +## Runtime gates + +| Gate | Expected behavior | +|------|-------------------| +| `KALSHI_AUTHORIZED_EXECUTION_ENABLED=1` | Opens the partner HTTP execution breaker; unset is dry-run/fail-closed | +| `KALSHI_ENV=demo` | Default provider host; does not require production arming | +| `KALSHI_ENV=prod` | Selects the production provider host | +| `KALSHI_PROD_ARMED=1` | Required in addition to `KALSHI_ENV=prod` | +| active authorization grant | Must match partner, out, skin, provider, currency, scope, validity, and policy hash | +| risk health | Must remain healthy before and during snapshot evaluation | + +`KALSHI_ALPHA_LIVE` belongs to alpha programs and does not enable this route. + +## Request contract + +Live `POST /api/trading/order` requests require: + +- `partnerCode`, canonical `outId`, active `skin`, `ticker`, and `outcome` +- integer `stakeMinorUnits` and `priceCents` +- an explicit, stable `Idempotency-Key` +- compliance fields and middleware context: state, node, sport, market, wager, + and bet type + +The route rejects post-only requests because the authorization snapshot binds +the order to immediately executable top-of-book liquidity. + +## Credentials and provider state + +Kalshi credentials resolve in this order: + +1. out: `KALSHI_SPORTS_1_*` +2. partner: `KALSHI_SPORTS_*` +3. global fallback: `KALSHI_*` + +The client cache fingerprints credential inputs and rebuilds automatically +after key rotation. Kalshi uses signed RSA requests rather than a refresh-token +flow; the live `/portfolio/balance` call proves the current credentials and +supplies available balance to the gate. + +## Expected fail-closed outcomes + +- Missing/mismatched partner, out, skin, provider, currency, or grant: denied. +- Stale/missing/crossed book, quote mismatch, unavailable balance, or unhealthy + risk state: denied before reservation or provider placement. +- Known provider rejection: reservation failed and exposure released. +- Ambiguous provider outcome: reservation remains unknown and exposure remains + held for reconciliation. +- Fantasy402: HTTP 501 with no provider call. + +Credit lines and dedicated partner wallets are not modeled in the current +registry, so the gate does not invent either. Available capacity is the live +Kalshi balance constrained by authorization, skin, daily, exposure, max-win, +and executable-liquidity limits. + +## Operator proof + +```bash +bun test tests/partner/authorization tests/partner/execution tests/research/trading-order.test.ts +bun run bun:ci +``` + +`Bun.TOML.stringify` is optional on the stable runtime: the governed +`src/partner/toml-stringify.ts` boundary uses the native API when present and a +tested compatibility serializer otherwise. + +## Remaining work + +- Build the Kalshi unknown-outcome reconciliation poller. +- Keep Fantasy402 unwired until provider-side idempotency is proven. +- Add credit-line or dedicated-wallet accounting only when an owned domain + contract and ledger source exist. diff --git a/docs/ENV_NAMING.md b/docs/ENV_NAMING.md index a771282..750c2bb 100644 --- a/docs/ENV_NAMING.md +++ b/docs/ENV_NAMING.md @@ -12,7 +12,7 @@ Every env var MUST have a service prefix. Examples: | Pattern | Example | |---------|---------| -| `KALSHI_*` | `KALSHI_API_KEY_ID`, `KALSHI_PROD_ARMED` | +| `KALSHI_*` | `KALSHI_API_KEY_ID`, `KALSHI_PROD_ARMED`, `KALSHI_AUTHORIZED_EXECUTION_ENABLED` | | `TELEGRAM_*` | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALERT_CHAT_ID` | | `TENNIS_*` | `TENNIS_LIVE_INTERVAL_MS`, `TENNIS_WS_RECORDER_CRON_SCHEDULE` | | `RESEARCH_*` | `RESEARCH_DIMENSION`, `RESEARCH_CRON_SCHEDULE` | @@ -32,10 +32,10 @@ Every env var MUST have a service prefix. Examples: | `_SECONDS` | Duration in seconds | `TENNIS_WS_RECORDER_WS_SECONDS` | | `_SCHEDULE` | Cron expression | `RESEARCH_CRON_SCHEDULE` | | `_TITLE` | Cron job title | `RESEARCH_CRON_TITLE` | -| `_LIVE` | Boolean toggle (live mode) | `ALPHA_LIVE` | +| `_LIVE` | Boolean toggle (live mode) | `KALSHI_ALPHA_LIVE` | | `_ARMED` | Safety gate (must be "1") | `KALSHI_PROD_ARMED` | | `_WAIT` | Blocking flag | `GITHUB_RATE_LIMIT_WAIT` | -| `_ENABLED` | Boolean toggle | `RESEARCH_EXPORT_AUDIT` | +| `_ENABLED` | Boolean toggle | `KALSHI_AUTHORIZED_EXECUTION_ENABLED` | ## Cron pairs @@ -67,6 +67,20 @@ When renaming, add a backward-compat read wrapper: const hubUrl = Bun.env.OPS_DASHBOARD_URL ?? Bun.env.SERVE_URL; ``` +## Live execution gates + +The flags are independent and conjunctive; none is an alias for another: + +| Variable | Owns | +|----------|------| +| `KALSHI_ENV=prod` | Selects the production Kalshi API host | +| `KALSHI_PROD_ARMED=1` | Permits construction of a production Kalshi client | +| `KALSHI_AUTHORIZED_EXECUTION_ENABLED=1` | Opens the authorized partner HTTP execution breaker | +| `KALSHI_ALPHA_LIVE=1` | Alpha-program execution only; it does not open the partner route | + +All authorization, compliance, balance, liquidity, session, exposure, and risk +checks still run after the environment gates. Missing or false flags fail closed. + ## Rename log | Old name | New name | Date | diff --git a/package.json b/package.json index 78cae76..e32985d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kalshi-bot-research", "version": "0.2.0", - "packageManager": "bun@1.4.0-canary.1", + "packageManager": "bun@1.3.14", "private": true, "description": "Discover and rank public Kalshi trading bots on GitHub (Bun + gh CLI)", "type": "module", @@ -33,6 +33,7 @@ "artifacts:restore": "bun tools/restore-committed-artifacts.ts", "guard": "bun scripts/audit-bun-native.ts", "check": "bun run guard && bun run typecheck && bun run test", + "bun:ci": "bun run check", "rate-limit:status": "bun tools/github-rate-budget.ts", "miss-taxonomy:status": "bun tools/miss-taxonomy-status.ts", "hooks:install": "cp tools/pre-commit.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit", @@ -157,7 +158,7 @@ "test:liquidity": "bun test --parallel tests/institutions/match-liquidity.test.ts tests/institutions/match-liquidity-ground.test.ts tests/institutions/match-liquidity-backfill.test.ts tests/institutions/match-liquidity-pipeline.test.ts" }, "engines": { - "bun": ">=1.4.0-canary.1" + "bun": ">=1.3.14" }, "dependencies": { "drizzle-orm": "^0.45.2", diff --git a/src/partner/toml-stringify.ts b/src/partner/toml-stringify.ts index e22e453..7416efa 100644 --- a/src/partner/toml-stringify.ts +++ b/src/partner/toml-stringify.ts @@ -1,15 +1,157 @@ /** - * Bun.TOML.stringify — types lag helper (bun-types 1.3.x vs runtime 1.4). + * TOML serialization through Bun's native API with a stable-runtime fallback. * - * Runtime + docs expose stringify; the pinned bun-types@1.3.14 TOML - * namespace only declares parse. Mirror of the main monorepo helper - * (~/Projects/lib/toml-stringify.ts). + * Project R is pinned to Bun 1.3.14, while `Bun.TOML.stringify` arrived on the + * Bun 1.4 channel. Keep the compatibility decision at this one boundary so + * callers and tests have the same behavior on stable and newer runtimes. + * + * This mirrors the governed helper at `~/Projects/lib/toml-stringify.ts`. * * @see https://bun.com/docs/runtime/toml#bun-toml-stringify */ -import { TOML } from 'bun'; +import { TOML } from "bun"; + +type TomlScalar = string | number | bigint | boolean | Date; +type TomlValue = TomlScalar | TomlValue[] | TomlTable; +interface TomlTable { + [key: string]: TomlValue | undefined; +} + +function isTomlTable(value: TomlValue): value is TomlTable { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof Date) + ); +} + +function parseTomlValue( + value: unknown, + path: string, + ancestors: ReadonlySet, +): TomlValue | undefined { + if (value === undefined) return undefined; + if (value === null) throw new TypeError(`TOML cannot represent null (key '${path}')`); + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "bigint" || + typeof value === "boolean" + ) { + return value; + } + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) throw new TypeError(`Invalid Date at '${path}'`); + return value; + } + if (typeof value !== "object") { + throw new TypeError(`Unsupported TOML value at '${path}': ${typeof value}`); + } + if (ancestors.has(value)) throw new TypeError(`Circular TOML value at '${path}'`); + + const nextAncestors = new Set(ancestors).add(value); + if (Array.isArray(value)) { + return value.map((item, index) => { + const parsed = parseTomlValue(item, `${path}[${index}]`, nextAncestors); + if (parsed === undefined) { + throw new TypeError(`TOML arrays cannot contain undefined at '${path}[${index}]'`); + } + return parsed; + }); + } + + const table: TomlTable = {}; + for (const key of Object.keys(value)) { + table[key] = parseTomlValue( + Reflect.get(value, key), + path ? `${path}.${key}` : key, + nextAncestors, + ); + } + return table; +} + +function formatTomlKey(key: string): string { + return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key); +} + +function formatTomlPath(path: readonly string[]): string { + return path.map(formatTomlKey).join("."); +} + +function formatTomlScalar(value: TomlScalar): string { + if (typeof value === "string") return JSON.stringify(value); + if (value instanceof Date) return value.toISOString(); + if (typeof value === "number" && !Number.isFinite(value)) { + if (Number.isNaN(value)) return "nan"; + return value < 0 ? "-inf" : "inf"; + } + return String(value); +} + +function isArrayOfTables(value: TomlValue): value is TomlTable[] { + return Array.isArray(value) && value.length > 0 && value.every(isTomlTable); +} + +function formatTomlArray(values: TomlValue[], path: readonly string[]): string { + return `[${values + .map((value, index) => { + if (isTomlTable(value)) { + throw new TypeError( + `TOML inline arrays cannot contain tables at '${formatTomlPath(path)}[${index}]'`, + ); + } + return Array.isArray(value) ? formatTomlArray(value, path) : formatTomlScalar(value); + }) + .join(", ")}]`; +} + +function collectTomlBlocks( + table: TomlTable, + path: readonly string[], + arrayTable: boolean, +): string[] { + const lines: string[] = []; + if (path.length > 0) { + const name = formatTomlPath(path); + lines.push(arrayTable ? `[[${name}]]` : `[${name}]`); + } + + for (const [key, value] of Object.entries(table)) { + if (value === undefined || isTomlTable(value) || isArrayOfTables(value)) continue; + lines.push( + `${formatTomlKey(key)} = ${ + Array.isArray(value) + ? formatTomlArray(value, [...path, key]) + : formatTomlScalar(value) + }`, + ); + } + + const blocks = lines.length > 0 ? [lines.join("\n")] : []; + for (const [key, value] of Object.entries(table)) { + if (value === undefined) continue; + if (isTomlTable(value)) { + blocks.push(...collectTomlBlocks(value, [...path, key], false)); + } else if (isArrayOfTables(value)) { + for (const item of value) blocks.push(...collectTomlBlocks(item, [...path, key], true)); + } + } + return blocks; +} + +function fallbackTomlStringify(value: TValue): string { + const parsed = parseTomlValue(value, "", new Set()); + if (parsed === undefined || !isTomlTable(parsed)) { + throw new TypeError("TOML root must be an object"); + } + return `${collectTomlBlocks(parsed, [], false).join("\n\n")}\n`; +} -/** types lag bun-types@1.3.x — https://bun.com/docs/runtime/toml#bun-toml-stringify */ +/** Serialize an object as TOML on both Bun 1.3.14 and Bun 1.4+. */ export function tomlStringify(value: TValue): string { - return (TOML as typeof TOML & { stringify: (v: TInput) => string }).stringify(value); + const nativeStringify = Reflect.get(TOML, "stringify"); + if (typeof nativeStringify === "function") return nativeStringify.call(TOML, value); + return fallbackTomlStringify(value); }