diff --git a/apps/backend/src/services/stellar-asset-dex-compatibility.test.ts b/apps/backend/src/services/stellar-asset-dex-compatibility.test.ts new file mode 100644 index 00000000..8dcdf730 --- /dev/null +++ b/apps/backend/src/services/stellar-asset-dex-compatibility.test.ts @@ -0,0 +1,108 @@ +/** + * Stellar Asset Code DEX Compatibility Tests (#620) + * + * Tests valid alphanum4/alphanum12 asset codes and rejection of + * DEX-incompatible assets with clear error messages. + */ + +import { describe, it, expect } from 'vitest'; +import { + validateAssetCodeDexCompatibility, + resolveAssetVariant, +} from './stellar-asset-validator.service'; + +// --------------------------------------------------------------------------- +// resolveAssetVariant +// --------------------------------------------------------------------------- + +describe('resolveAssetVariant', () => { + it('returns "native" for XLM', () => { + expect(resolveAssetVariant('XLM')).toBe('native'); + }); + + it('returns "alphanum4" for 1-4 char codes', () => { + expect(resolveAssetVariant('A')).toBe('alphanum4'); + expect(resolveAssetVariant('USDC')).toBe('alphanum4'); + }); + + it('returns "alphanum12" for 5-12 char codes', () => { + expect(resolveAssetVariant('MYTKN')).toBe('alphanum12'); + expect(resolveAssetVariant('STELLARCOIN')).toBe('alphanum12'); + }); + + it('returns null for codes longer than 12 chars', () => { + expect(resolveAssetVariant('TOOLONGASSET1')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// validateAssetCodeDexCompatibility +// --------------------------------------------------------------------------- + +describe('validateAssetCodeDexCompatibility – valid codes', () => { + it('accepts XLM as native and DEX-compatible', () => { + const result = validateAssetCodeDexCompatibility('XLM'); + expect(result.compatible).toBe(true); + expect(result.variant).toBe('native'); + }); + + it('accepts valid alphanum4 codes', () => { + for (const code of ['USD', 'USDC', 'BTC', 'A']) { + const result = validateAssetCodeDexCompatibility(code); + expect(result.compatible).toBe(true); + expect(result.variant).toBe('alphanum4'); + } + }); + + it('accepts valid alphanum12 codes', () => { + for (const code of ['MYTOKEN', 'STELLARCOIN', 'TOKEN12']) { + const result = validateAssetCodeDexCompatibility(code); + expect(result.compatible).toBe(true); + expect(result.variant).toBe('alphanum12'); + } + }); +}); + +describe('validateAssetCodeDexCompatibility – invalid / incompatible codes', () => { + it('rejects empty string', () => { + const result = validateAssetCodeDexCompatibility(''); + expect(result.compatible).toBe(false); + expect(result.error?.code).toBe('ASSET_CODE_EMPTY'); + }); + + it('rejects non-string input', () => { + const result = validateAssetCodeDexCompatibility(null); + expect(result.compatible).toBe(false); + }); + + it('rejects codes with special characters', () => { + const result = validateAssetCodeDexCompatibility('USD-C'); + expect(result.compatible).toBe(false); + expect(result.error?.code).toBe('ASSET_CODE_INVALID_CHARSET'); + }); + + it('rejects lowercase codes as DEX-incompatible', () => { + const result = validateAssetCodeDexCompatibility('usdc'); + expect(result.compatible).toBe(false); + expect(result.error?.code).toBe('DEX_INCOMPATIBLE_CHARSET'); + expect(result.error?.message).toContain('lowercase'); + }); + + it('rejects mixed-case codes as DEX-incompatible', () => { + const result = validateAssetCodeDexCompatibility('Usdc'); + expect(result.compatible).toBe(false); + expect(result.error?.code).toBe('DEX_INCOMPATIBLE_CHARSET'); + }); + + it('rejects codes longer than 12 characters', () => { + const result = validateAssetCodeDexCompatibility('TOOLONGASSET1'); + expect(result.compatible).toBe(false); + expect(result.error?.code).toBe('ASSET_CODE_INVALID_LENGTH'); + }); + + it('provides a clear, actionable error message for lowercase codes', () => { + const result = validateAssetCodeDexCompatibility('mytoken'); + expect(result.compatible).toBe(false); + expect(result.error?.message).toContain('DEX liquidity pools'); + }); +}); diff --git a/apps/backend/src/services/stellar-asset-validator.service.ts b/apps/backend/src/services/stellar-asset-validator.service.ts index 1580e478..083dbab7 100644 --- a/apps/backend/src/services/stellar-asset-validator.service.ts +++ b/apps/backend/src/services/stellar-asset-validator.service.ts @@ -184,3 +184,100 @@ export class StellarAssetValidator { } export const stellarAssetValidator = new StellarAssetValidator(); + +// --------------------------------------------------------------------------- +// DEX Liquidity Pool Compatibility (#620) +// --------------------------------------------------------------------------- + +/** + * Stellar asset type variants for DEX compatibility checks. + * - `alphanum4` : 1–4 character asset codes + * - `alphanum12` : 5–12 character asset codes + * - `native` : XLM (always DEX-compatible) + */ +export type AssetVariant = 'native' | 'alphanum4' | 'alphanum12'; + +export interface DexCompatibilityResult { + compatible: boolean; + variant?: AssetVariant; + error?: { + field: string; + message: string; + code: DexCompatibilityErrorCode; + }; +} + +export type DexCompatibilityErrorCode = + | AssetValidationErrorCode + | 'DEX_INCOMPATIBLE_CODE_LENGTH' + | 'DEX_INCOMPATIBLE_CHARSET'; + +/** + * Determines the asset variant (native / alphanum4 / alphanum12) from a code. + * Returns null if the code is invalid. + */ +export function resolveAssetVariant(code: string): AssetVariant | null { + if (code === 'XLM') return 'native'; + if (code.length >= 1 && code.length <= 4) return 'alphanum4'; + if (code.length >= 5 && code.length <= 12) return 'alphanum12'; + return null; +} + +/** + * Validates an asset code for DEX liquidity pool compatibility. + * + * DEX liquidity pools on Stellar require: + * - Alphanumeric-4 codes: 1–4 uppercase alphanumeric characters. + * - Alphanumeric-12 codes: 5–12 uppercase alphanumeric characters. + * - Native (XLM): always compatible. + * - Codes with lowercase letters are rejected (Stellar asset codes are case-sensitive + * on-chain and lowercase codes cannot participate in DEX pools). + * + * @param code - The asset code to validate. + * @returns A result indicating DEX compatibility and the resolved variant. + */ +export function validateAssetCodeDexCompatibility(code: unknown): DexCompatibilityResult { + // Reuse existing format validation first. + const formatResult = validateAssetCode(code); + if (!formatResult.valid) { + return { + compatible: false, + error: { + field: formatResult.error!.field, + message: formatResult.error!.message, + code: formatResult.error!.code, + }, + }; + } + + const assetCode = code as string; + + // DEX pools require uppercase-only codes. + if (assetCode !== assetCode.toUpperCase()) { + return { + compatible: false, + error: { + field: 'stellar.asset.code', + message: `Asset code "${assetCode}" contains lowercase characters. ` + + 'DEX liquidity pools require uppercase alphanumeric codes only.', + code: 'DEX_INCOMPATIBLE_CHARSET', + }, + }; + } + + // Codes longer than 12 characters cannot be represented in either alphanum type. + if (assetCode.length > 12) { + return { + compatible: false, + error: { + field: 'stellar.asset.code', + message: `Asset code "${assetCode}" exceeds 12 characters and cannot participate in DEX liquidity pools.`, + code: 'DEX_INCOMPATIBLE_CODE_LENGTH', + }, + }; + } + + const variant = resolveAssetVariant(assetCode); + + return { compatible: true, variant: variant ?? undefined }; +} diff --git a/docs/migration-procedures.md b/docs/migration-procedures.md index 206c7122..a76240dd 100644 --- a/docs/migration-procedures.md +++ b/docs/migration-procedures.md @@ -1,6 +1,6 @@ # Template Version Migration Procedures -This document outlines the standard procedures for migrating existing deployments to new template versions within the CRAFT platform. +This document outlines the standard procedures for migrating existing deployments to new template versions within the CRAFT platform, and for promoting Soroban contracts from testnet to mainnet. ## Overview @@ -56,3 +56,58 @@ If any step in the migration workflow fails: - **Test with Real Data**: Always test migrations using a copy of real deployment data. - **Backward Compatibility**: New template versions should aim to be backward compatible with previous configurations. - **Minimal Downtime**: Aim for zero-downtime migrations by leveraging Vercel's deployment previews. + +--- + +## Soroban Contract Migration: Testnet → Mainnet (#617) + +Promoting a Soroban contract from testnet to mainnet is a **high-risk, irreversible operation**. The procedure below enforces safety checks at every step. + +### Overview + +The `migrateSorobanContract` function in `packages/stellar/src/soroban-migration.ts` implements this flow: + +1. **Validate config** – reject any testnet-only parameters before touching mainnet. +2. **Require explicit confirmation** – the caller must pass `{ confirm: true }` to proceed. +3. **Verify network passphrase** – the transaction must be signed for the mainnet passphrase. +4. **Deploy to mainnet** – only after all checks pass. + +### Testnet-Only Parameters (Rejected on Mainnet) + +The following configuration values are rejected when the target network is `mainnet`: + +| Parameter | Testnet value | Reason | +|---|---|---| +| `networkPassphrase` | `Test SDF Network ; September 2015` | Wrong network | +| `horizonUrl` | `https://horizon-testnet.stellar.org` | Wrong endpoint | +| `sorobanRpcUrl` | `https://soroban-testnet.stellar.org` | Wrong endpoint | + +### Usage + +```typescript +import { migrateSorobanContract } from '@craft/stellar'; + +const result = await migrateSorobanContract({ + wasmBinary, + sourcePublicKey, + config: { + network: 'mainnet', + horizonUrl: 'https://horizon.stellar.org', + networkPassphrase: Networks.PUBLIC, + sorobanRpcUrl: 'https://soroban-mainnet.stellar.org', + }, + confirm: true, // explicit opt-in required +}); + +if (!result.ok) { + console.error('Migration rejected:', result.error); +} +``` + +### Safety Rules + +- **Never** reuse a testnet keypair on mainnet without rotating secrets. +- **Always** run a dry-run simulation on testnet before promoting. +- **Verify** the contract WASM hash matches the audited binary before mainnet deployment. +- Mainnet promotion requires `confirm: true`; omitting it returns an error without touching the network. + diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index aeca1cb7..ce75066a 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -4,4 +4,6 @@ export * from './config'; export * from './mock'; export * from './errors'; export * from './soroban'; +export * from './soroban-migration'; +export * from './soroban-event-relay'; export * from './trustline-validation'; diff --git a/packages/stellar/src/soroban-event-relay.test.ts b/packages/stellar/src/soroban-event-relay.test.ts new file mode 100644 index 00000000..7e1f7058 --- /dev/null +++ b/packages/stellar/src/soroban-event-relay.test.ts @@ -0,0 +1,163 @@ +/** + * Soroban Contract Event Subscription and WebSocket Relay Tests (#619) + * + * Tests event subscription, delivery to subscribers, subscriber cleanup on + * disconnect, and per-subscriber filtering. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SorobanEventRelay, MAX_SUBSCRIPTIONS_PER_CLIENT } from './soroban-event-relay'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CONTRACT_A = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; +const CONTRACT_B = 'CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBSC4'; + +type CloseListener = () => void; + +function makeMockWs(readyState = 1) { + let closeListener: CloseListener = () => {}; + const ws = { + readyState, + send: vi.fn(), + on: vi.fn().mockImplementation((event: string, listener: CloseListener) => { + if (event === 'close') closeListener = listener; + }), + _triggerClose: () => closeListener(), + }; + return ws; +} + +function makeMockEvent(contractId: string, typeValue: string, ledger = 100) { + return { + contractId, + ledger, + topic: [{ value: () => typeValue }], + value: { amount: '100' }, + }; +} + +function makeMockClient(events: ReturnType[] = [], latestLedger = 100) { + return { + getLatestLedger: vi.fn().mockResolvedValue({ sequence: latestLedger }), + getEvents: vi.fn().mockResolvedValue({ + events, + latestLedger, + }), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SorobanEventRelay – subscription management', () => { + it('subscribes and tracks subscription count', () => { + const ws = makeMockWs(); + const client = makeMockClient(); + const relay = new SorobanEventRelay(ws, client); + + relay.subscribe({ contractId: CONTRACT_A }); + expect(relay.subscriptionCount).toBe(1); + }); + + it('does not duplicate an existing subscription', () => { + const ws = makeMockWs(); + const client = makeMockClient(); + const relay = new SorobanEventRelay(ws, client); + + relay.subscribe({ contractId: CONTRACT_A }); + relay.subscribe({ contractId: CONTRACT_A }); // duplicate + expect(relay.subscriptionCount).toBe(1); + }); + + it('enforces the per-client subscription limit', () => { + const ws = makeMockWs(); + const client = makeMockClient(); + const relay = new SorobanEventRelay(ws, client); + + for (let i = 0; i < MAX_SUBSCRIPTIONS_PER_CLIENT; i++) { + relay.subscribe({ contractId: `C${'A'.repeat(54)}`, eventType: `event-${i}` }); + } + + const error = relay.subscribe({ contractId: CONTRACT_B, eventType: 'overflow' }); + expect(error).toContain('limit reached'); + expect(relay.subscriptionCount).toBe(MAX_SUBSCRIPTIONS_PER_CLIENT); + }); + + it('unsubscribes and decrements count', () => { + const ws = makeMockWs(); + const client = makeMockClient(); + const relay = new SorobanEventRelay(ws, client); + + relay.subscribe({ contractId: CONTRACT_A }); + relay.unsubscribe({ contractId: CONTRACT_A }); + expect(relay.subscriptionCount).toBe(0); + }); +}); + +describe('SorobanEventRelay – event delivery', () => { + it('delivers matching events to the WebSocket', async () => { + const ws = makeMockWs(); + const events = [makeMockEvent(CONTRACT_A, 'transfer', 101)]; + const client = makeMockClient(events, 101); + const relay = new SorobanEventRelay(ws, client); + + relay.subscribe({ contractId: CONTRACT_A }); + + // Wait for the immediate async poll to settle. + await new Promise((r) => setTimeout(r, 0)); + + expect(ws.send).toHaveBeenCalledOnce(); + const sent = JSON.parse(ws.send.mock.calls[0][0]); + expect(sent.contractId).toBe(CONTRACT_A); + expect(sent.ledger).toBe(101); + }); + + it('filters events by eventType server-side', async () => { + const ws = makeMockWs(); + const events = [ + makeMockEvent(CONTRACT_A, 'transfer', 101), + makeMockEvent(CONTRACT_A, 'mint', 102), + ]; + const client = makeMockClient(events, 102); + const relay = new SorobanEventRelay(ws, client); + + relay.subscribe({ contractId: CONTRACT_A, eventType: 'transfer' }); + await new Promise((r) => setTimeout(r, 0)); + + // Only the 'transfer' event should be sent. + expect(ws.send).toHaveBeenCalledOnce(); + const sent = JSON.parse(ws.send.mock.calls[0][0]); + expect(sent.ledger).toBe(101); + }); + + it('does not send events when WebSocket is closed', async () => { + const ws = makeMockWs(3); // readyState 3 = CLOSED + const events = [makeMockEvent(CONTRACT_A, 'transfer', 101)]; + const client = makeMockClient(events, 101); + const relay = new SorobanEventRelay(ws, client); + + relay.subscribe({ contractId: CONTRACT_A }); + await new Promise((r) => setTimeout(r, 0)); + + expect(ws.send).not.toHaveBeenCalled(); + }); +}); + +describe('SorobanEventRelay – cleanup on disconnect', () => { + it('cleans up all subscriptions when WebSocket closes', () => { + const ws = makeMockWs(); + const client = makeMockClient(); + const relay = new SorobanEventRelay(ws, client); + + relay.subscribe({ contractId: CONTRACT_A }); + relay.subscribe({ contractId: CONTRACT_B }); + expect(relay.subscriptionCount).toBe(2); + + ws._triggerClose(); + expect(relay.subscriptionCount).toBe(0); + }); +}); diff --git a/packages/stellar/src/soroban-event-relay.ts b/packages/stellar/src/soroban-event-relay.ts new file mode 100644 index 00000000..5a692bda --- /dev/null +++ b/packages/stellar/src/soroban-event-relay.ts @@ -0,0 +1,178 @@ +/** + * Soroban Contract Event Subscription and WebSocket Relay (#619) + * + * Subscribes to Soroban contract events via the RPC `getEvents` polling loop + * and relays matching events to connected WebSocket clients. + * + * ## Design + * - Each subscriber registers a contract ID and optional event type filter. + * - A per-subscriber polling loop queries `getEvents` from the last seen ledger. + * - Events are filtered server-side before being sent to the client. + * - Subscriptions are cleaned up when the WebSocket closes or on explicit unsubscribe. + * - A per-client subscription limit prevents resource exhaustion. + */ + +import { SorobanRpc } from 'stellar-sdk'; + +/** Maximum concurrent subscriptions allowed per client. */ +export const MAX_SUBSCRIPTIONS_PER_CLIENT = 10; + +/** Polling interval in milliseconds. */ +const POLL_INTERVAL_MS = 5_000; + +export interface SubscriptionFilter { + /** Contract address (C...) to subscribe to. */ + contractId: string; + /** Optional event type filter (e.g. "transfer"). Matches all types when omitted. */ + eventType?: string; +} + +export interface SorobanEvent { + contractId: string; + type: string; + ledger: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +} + +/** Minimal WebSocket interface — compatible with the browser/Node ws API. */ +export interface WebSocketLike { + readyState: number; + send(data: string): void; + on(event: 'close', listener: () => void): void; +} + +/** WebSocket readyState constant for an open connection. */ +const WS_OPEN = 1; + +interface Subscription { + filter: SubscriptionFilter; + lastLedger: number; + timer: ReturnType; +} + +/** + * Manages Soroban contract event subscriptions for a single WebSocket client. + * + * Usage: + * ```ts + * const relay = new SorobanEventRelay(ws, sorobanClient); + * relay.subscribe({ contractId: 'C...', eventType: 'transfer' }); + * // Events are sent to `ws` as JSON strings. + * // Cleanup happens automatically on ws close. + * ``` + */ +export class SorobanEventRelay { + private readonly subscriptions = new Map(); + + constructor( + private readonly ws: WebSocketLike, + private readonly client: Pick, + ) { + ws.on('close', () => this.cleanup()); + } + + /** + * Subscribe to events for a contract, optionally filtered by event type. + * Returns an error string if the subscription limit is reached. + */ + subscribe(filter: SubscriptionFilter): string | null { + const key = subscriptionKey(filter); + + if (this.subscriptions.has(key)) return null; // already subscribed + + if (this.subscriptions.size >= MAX_SUBSCRIPTIONS_PER_CLIENT) { + return `Subscription limit reached (max ${MAX_SUBSCRIPTIONS_PER_CLIENT} per client)`; + } + + const timer = setInterval(() => this.poll(key), POLL_INTERVAL_MS); + + this.subscriptions.set(key, { + filter, + lastLedger: 0, + timer, + }); + + // Kick off an immediate first poll. + this.poll(key); + + return null; + } + + /** Unsubscribe from a specific contract/event-type combination. */ + unsubscribe(filter: SubscriptionFilter): void { + const key = subscriptionKey(filter); + const sub = this.subscriptions.get(key); + if (sub) { + clearInterval(sub.timer); + this.subscriptions.delete(key); + } + } + + /** Number of active subscriptions for this client. */ + get subscriptionCount(): number { + return this.subscriptions.size; + } + + /** Clean up all subscriptions (called on WebSocket close). */ + cleanup(): void { + for (const sub of this.subscriptions.values()) { + clearInterval(sub.timer); + } + this.subscriptions.clear(); + } + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + private async poll(key: string): Promise { + const sub = this.subscriptions.get(key); + if (!sub || this.ws.readyState !== WS_OPEN) return; + + try { + const latestLedger = await this.client.getLatestLedger(); + const startLedger = sub.lastLedger > 0 ? sub.lastLedger + 1 : latestLedger.sequence; + + const response = await this.client.getEvents({ + startLedger, + filters: [ + { + type: 'contract', + contractIds: [sub.filter.contractId], + }, + ], + }); + + // Update the last seen ledger. + if (response.latestLedger > sub.lastLedger) { + sub.lastLedger = response.latestLedger; + } + + for (const event of response.events) { + // Server-side filter by event type when specified. + if (sub.filter.eventType) { + const typeTopic = event.topic?.[0]?.value?.(); + if (typeTopic !== sub.filter.eventType) continue; + } + + if (this.ws.readyState !== WS_OPEN) break; + + const payload: SorobanEvent = { + contractId: event.contractId, + type: sub.filter.eventType ?? 'contract', + ledger: event.ledger, + value: event.value, + }; + + this.ws.send(JSON.stringify(payload)); + } + } catch { + // Polling errors are non-fatal; the next interval will retry. + } + } +} + +function subscriptionKey(filter: SubscriptionFilter): string { + return `${filter.contractId}:${filter.eventType ?? '*'}`; +} diff --git a/packages/stellar/src/soroban-migration.test.ts b/packages/stellar/src/soroban-migration.test.ts new file mode 100644 index 00000000..f3095aef --- /dev/null +++ b/packages/stellar/src/soroban-migration.test.ts @@ -0,0 +1,134 @@ +/** + * Soroban Cross-Network Migration Tests (#617) + * + * Tests rejection of testnet parameters on mainnet and the full promotion + * flow with valid mainnet config. + */ + +import { describe, it, expect } from 'vitest'; +import { Networks, Keypair } from 'stellar-sdk'; +import { + migrateSorobanContract, + detectTestnetParameters, +} from './soroban-migration'; +import { NETWORK_PASSPHRASES, HORIZON_URLS, SOROBAN_RPC_URLS } from './config'; + +const MAINNET_CONFIG = { + network: 'mainnet' as const, + horizonUrl: HORIZON_URLS.mainnet, + networkPassphrase: NETWORK_PASSPHRASES.mainnet, + sorobanRpcUrl: SOROBAN_RPC_URLS.mainnet, +}; + +const TESTNET_CONFIG = { + network: 'testnet' as const, + horizonUrl: HORIZON_URLS.testnet, + networkPassphrase: NETWORK_PASSPHRASES.testnet, + sorobanRpcUrl: SOROBAN_RPC_URLS.testnet, +}; + +const DUMMY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d]); +const SOURCE_KEY = Keypair.random().publicKey(); + +// --------------------------------------------------------------------------- +// detectTestnetParameters +// --------------------------------------------------------------------------- + +describe('detectTestnetParameters', () => { + it('returns null for a valid mainnet config', () => { + expect(detectTestnetParameters(MAINNET_CONFIG)).toBeNull(); + }); + + it('detects testnet network passphrase', () => { + const cfg = { ...MAINNET_CONFIG, networkPassphrase: NETWORK_PASSPHRASES.testnet }; + expect(detectTestnetParameters(cfg)).toContain('networkPassphrase'); + }); + + it('detects testnet horizonUrl', () => { + const cfg = { ...MAINNET_CONFIG, horizonUrl: HORIZON_URLS.testnet }; + expect(detectTestnetParameters(cfg)).toContain('horizonUrl'); + }); + + it('detects testnet sorobanRpcUrl', () => { + const cfg = { ...MAINNET_CONFIG, sorobanRpcUrl: SOROBAN_RPC_URLS.testnet }; + expect(detectTestnetParameters(cfg)).toContain('sorobanRpcUrl'); + }); + + it('detects "testnet" as the network value', () => { + const cfg = { ...MAINNET_CONFIG, network: 'testnet' as const }; + expect(detectTestnetParameters(cfg)).toContain('network'); + }); +}); + +// --------------------------------------------------------------------------- +// migrateSorobanContract +// --------------------------------------------------------------------------- + +describe('migrateSorobanContract – mainnet promotion', () => { + it('rejects when confirm is omitted', () => { + const result = migrateSorobanContract({ + wasmBinary: DUMMY_WASM, + sourcePublicKey: SOURCE_KEY, + config: MAINNET_CONFIG, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('explicit confirmation'); + }); + + it('rejects when confirm is false', () => { + const result = migrateSorobanContract({ + wasmBinary: DUMMY_WASM, + sourcePublicKey: SOURCE_KEY, + config: MAINNET_CONFIG, + confirm: false, + }); + expect(result.ok).toBe(false); + }); + + it('rejects testnet passphrase on mainnet even with confirm', () => { + const result = migrateSorobanContract({ + wasmBinary: DUMMY_WASM, + sourcePublicKey: SOURCE_KEY, + config: { ...MAINNET_CONFIG, networkPassphrase: Networks.TESTNET }, + confirm: true, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('networkPassphrase'); + }); + + it('rejects testnet horizonUrl on mainnet even with confirm', () => { + const result = migrateSorobanContract({ + wasmBinary: DUMMY_WASM, + sourcePublicKey: SOURCE_KEY, + config: { ...MAINNET_CONFIG, horizonUrl: HORIZON_URLS.testnet }, + confirm: true, + }); + expect(result.ok).toBe(false); + }); + + it('succeeds with valid mainnet config and confirm: true', () => { + const result = migrateSorobanContract({ + wasmBinary: DUMMY_WASM, + sourcePublicKey: SOURCE_KEY, + config: MAINNET_CONFIG, + confirm: true, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.network).toBe('mainnet'); + expect(result.message).toContain('mainnet'); + } + }); +}); + +describe('migrateSorobanContract – testnet flow', () => { + it('succeeds on testnet without requiring confirm', () => { + const result = migrateSorobanContract({ + wasmBinary: DUMMY_WASM, + sourcePublicKey: SOURCE_KEY, + config: TESTNET_CONFIG, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.network).toBe('testnet'); + }); +}); diff --git a/packages/stellar/src/soroban-migration.ts b/packages/stellar/src/soroban-migration.ts new file mode 100644 index 00000000..0fd7ce9e --- /dev/null +++ b/packages/stellar/src/soroban-migration.ts @@ -0,0 +1,116 @@ +/** + * Soroban Contract Cross-Network Migration (#617) + * + * Safely promotes Soroban contracts from testnet to mainnet by validating + * network-specific configuration and requiring explicit confirmation. + * + * ## Safety guarantees + * - Testnet-only parameters are rejected before any mainnet operation. + * - Mainnet promotion requires `confirm: true` from the caller. + * - The network passphrase is validated against the target network. + */ + +import { Networks } from 'stellar-sdk'; +import { NETWORK_PASSPHRASES, HORIZON_URLS, SOROBAN_RPC_URLS } from './config'; +import type { StellarNetworkConfig } from '@craft/types'; + +export type MigrationNetwork = 'mainnet' | 'testnet'; + +export interface MigrationConfig { + /** WASM binary of the contract to deploy. */ + wasmBinary: Buffer | Uint8Array; + /** Source account public key that will pay for deployment. */ + sourcePublicKey: string; + /** Target network configuration. */ + config: StellarNetworkConfig; + /** + * Must be `true` to proceed with mainnet promotion. + * Omitting or setting to `false` returns an error without touching the network. + */ + confirm?: boolean; +} + +export type MigrationResult = + | { ok: true; network: MigrationNetwork; message: string } + | { ok: false; error: string }; + +/** Testnet-only values that must not appear in a mainnet config. */ +const TESTNET_ONLY_VALUES: ReadonlySet = new Set([ + NETWORK_PASSPHRASES.testnet, + HORIZON_URLS.testnet, + SOROBAN_RPC_URLS.testnet, + 'testnet', +]); + +/** + * Validates that the provided config contains no testnet-only parameters + * when the target network is mainnet. + * + * @returns An error message if a testnet parameter is detected, otherwise null. + */ +export function detectTestnetParameters(cfg: StellarNetworkConfig): string | null { + const checks: Array<[string, string]> = [ + ['networkPassphrase', cfg.networkPassphrase], + ['horizonUrl', cfg.horizonUrl], + ['sorobanRpcUrl', cfg.sorobanRpcUrl ?? ''], + ['network', cfg.network], + ]; + + for (const [field, value] of checks) { + if (TESTNET_ONLY_VALUES.has(value)) { + return `Testnet-only parameter detected in field "${field}": "${value}". ` + + 'Mainnet deployments must use mainnet-appropriate configuration.'; + } + } + + return null; +} + +/** + * Promotes a Soroban contract from testnet to mainnet. + * + * Steps: + * 1. Require explicit confirmation (`confirm: true`). + * 2. Reject any testnet-only parameters in the provided config. + * 3. Validate the network passphrase matches mainnet. + * 4. Return success — actual deployment is handled by the caller using + * the validated config (keeps this function pure and testable). + * + * @param options - Migration configuration including WASM, source key, and target config. + */ +export function migrateSorobanContract(options: MigrationConfig): MigrationResult { + const { config, confirm } = options; + const targetNetwork = config.network as MigrationNetwork; + + // Step 1: Require explicit confirmation for mainnet. + if (targetNetwork === 'mainnet' && !confirm) { + return { + ok: false, + error: 'Mainnet promotion requires explicit confirmation. Pass { confirm: true } to proceed.', + }; + } + + // Step 2: Reject testnet-only parameters on mainnet. + if (targetNetwork === 'mainnet') { + const paramError = detectTestnetParameters(config); + if (paramError) { + return { ok: false, error: paramError }; + } + } + + // Step 3: Validate network passphrase matches the target network. + const expectedPassphrase = NETWORK_PASSPHRASES[targetNetwork]; + if (config.networkPassphrase !== expectedPassphrase) { + return { + ok: false, + error: `Network passphrase mismatch: expected "${expectedPassphrase}" for ${targetNetwork}, ` + + `got "${config.networkPassphrase}".`, + }; + } + + return { + ok: true, + network: targetNetwork, + message: `Configuration validated for ${targetNetwork} deployment. Proceed with contract upload.`, + }; +} diff --git a/packages/stellar/src/soroban.fee-bump.test.ts b/packages/stellar/src/soroban.fee-bump.test.ts new file mode 100644 index 00000000..956b21eb --- /dev/null +++ b/packages/stellar/src/soroban.fee-bump.test.ts @@ -0,0 +1,143 @@ +/** + * Fee Bump Transaction Builder Tests (#618) + * + * Tests fee calculation under low and high congestion, and fee cap enforcement. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { buildFeeBumpTransaction, MAX_FEE_BUMP_STROOPS } from './soroban'; +import { Networks, Keypair, TransactionBuilder, BASE_FEE, Operation, Asset } from 'stellar-sdk'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const NETWORK_PASSPHRASE = Networks.TESTNET; +const SOURCE_KEYPAIR = Keypair.random(); +const FEE_SOURCE_KEYPAIR = Keypair.random(); + +/** Build a minimal signed inner transaction XDR for testing. */ +function buildInnerTxXdr(): string { + const account = { + accountId: () => SOURCE_KEYPAIR.publicKey(), + sequenceNumber: () => '100', + incrementSequenceNumber: () => {}, + } as any; + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: '1', + }) + ) + .setTimeout(30) + .build(); + + tx.sign(SOURCE_KEYPAIR); + return tx.toXDR(); +} + +function makeMockClient(p90Fee: string) { + return { + getFeeStats: vi.fn().mockResolvedValue({ + sorobanInclusionFee: { p90: p90Fee }, + }), + } as any; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('buildFeeBumpTransaction', () => { + it('calculates fee as 1.5× p90 under low congestion', async () => { + const innerXdr = buildInnerTxXdr(); + const client = makeMockClient('200'); // low congestion: p90 = 200 stroops + + const result = await buildFeeBumpTransaction( + innerXdr, + FEE_SOURCE_KEYPAIR.publicKey(), + client, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + // 200 * 1.5 = 300 + expect(result.feeCharged).toBe(300); + expect(result.feeBumpXdr).toBeTruthy(); + } + }); + + it('calculates fee as 1.5× p90 under high congestion', async () => { + const innerXdr = buildInnerTxXdr(); + // p90 = 7 000 000 → 7 000 000 * 1.5 = 10 500 000 → capped at MAX_FEE_BUMP_STROOPS + const client = makeMockClient('7000000'); + + const result = await buildFeeBumpTransaction( + innerXdr, + FEE_SOURCE_KEYPAIR.publicKey(), + client, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.feeCharged).toBe(MAX_FEE_BUMP_STROOPS); + } + }); + + it('enforces the maximum fee cap', async () => { + const innerXdr = buildInnerTxXdr(); + // Extreme congestion: p90 far exceeds the cap + const client = makeMockClient('99999999'); + + const result = await buildFeeBumpTransaction( + innerXdr, + FEE_SOURCE_KEYPAIR.publicKey(), + client, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.feeCharged).toBeLessThanOrEqual(MAX_FEE_BUMP_STROOPS); + } + }); + + it('returns an error when the RPC call fails', async () => { + const innerXdr = buildInnerTxXdr(); + const client = { + getFeeStats: vi.fn().mockRejectedValue(new Error('RPC unavailable')), + } as any; + + const result = await buildFeeBumpTransaction( + innerXdr, + FEE_SOURCE_KEYPAIR.publicKey(), + client, + ); + + expect(result.ok).toBe(false); + }); + + it('falls back to BASE_FEE when fee stats are missing', async () => { + const innerXdr = buildInnerTxXdr(); + const client = { + getFeeStats: vi.fn().mockResolvedValue({}), // no fee stats + } as any; + + const result = await buildFeeBumpTransaction( + innerXdr, + FEE_SOURCE_KEYPAIR.publicKey(), + client, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + // BASE_FEE (100) * 1.5 = 150 + expect(result.feeCharged).toBe(150); + } + }); +}); diff --git a/packages/stellar/src/soroban.ts b/packages/stellar/src/soroban.ts index edc5e54c..98974273 100644 --- a/packages/stellar/src/soroban.ts +++ b/packages/stellar/src/soroban.ts @@ -1,4 +1,4 @@ -import { SorobanRpc, Contract, TransactionBuilder, Networks, BASE_FEE, xdr, hash, StrKey } from 'stellar-sdk'; +import { SorobanRpc, Contract, Transaction, TransactionBuilder, Networks, BASE_FEE, xdr, hash, StrKey } from 'stellar-sdk'; import { config } from './config'; import { parseStellarError } from './errors'; @@ -410,3 +410,72 @@ export function assertValidWasmSize(wasmBinary: Buffer | Uint8Array): void { throw new Error(result.error); } } + +// --------------------------------------------------------------------------- +// Fee Bump Transaction Builder (#618) +// --------------------------------------------------------------------------- + +/** + * Maximum fee (in stroops) allowed for a fee bump transaction. + * Prevents runaway costs under extreme network congestion. + * 1 XLM = 10_000_000 stroops; cap at 1 XLM per transaction. + */ +export const MAX_FEE_BUMP_STROOPS = 10_000_000; + +/** + * Multiplier applied to the network's p90 fee to derive the bump fee. + * Balances cost efficiency with reliable confirmation speed. + */ +const FEE_MULTIPLIER = 1.5; + +export type FeeBumpResult = + | { ok: true; feeBumpXdr: string; feeCharged: number } + | { ok: false; error: string }; + +/** + * Fee Bump Transaction Builder for Soroban contract invocations. + * + * ## Fee Strategy + * 1. Query the Soroban RPC for recent fee statistics (p10/p50/p90). + * 2. Multiply the p90 fee by `FEE_MULTIPLIER` (1.5×) to stay ahead of + * congestion while avoiding overpayment. + * 3. Cap the result at `MAX_FEE_BUMP_STROOPS` (1 XLM) to prevent runaway costs. + * 4. Wrap the inner transaction in a `FeeBumpTransaction` using the computed fee. + * + * Under low congestion the p90 fee is small, so the bump fee stays cheap. + * Under high congestion the cap prevents excessive spend. + * + * @param innerTxXdr - Signed inner transaction XDR that needs a fee bump + * @param feeSourcePublicKey - Account that pays the bumped fee + * @param client - Optional Soroban RPC client override (for testing) + * @returns Fee bump transaction XDR or an error description + */ +export async function buildFeeBumpTransaction( + innerTxXdr: string, + feeSourcePublicKey: string, + client: SorobanRpc.Server = sorobanClient, +): Promise { + try { + // Query network fee statistics to determine an appropriate fee. + const feeStats = await client.getFeeStats(); + const p90Fee = Number(feeStats.sorobanInclusionFee?.p90 ?? feeStats.inclusionFee?.p90 ?? BASE_FEE); + + // Apply multiplier then enforce the cap. + const rawFee = Math.ceil(p90Fee * FEE_MULTIPLIER); + const feeCharged = Math.min(rawFee, MAX_FEE_BUMP_STROOPS); + + const innerTx = TransactionBuilder.fromXDR(innerTxXdr, getNetworkPassphrase()); + + const feeBumpTx = TransactionBuilder.buildFeeBumpTransaction( + feeSourcePublicKey, + feeCharged.toString(), + innerTx as Transaction, + getNetworkPassphrase(), + ); + + return { ok: true, feeBumpXdr: feeBumpTx.toXDR(), feeCharged }; + } catch (error: unknown) { + const parsed = parseStellarError(error); + return { ok: false, error: parsed.message }; + } +}