diff --git a/packages/stellar/README.md b/packages/stellar/README.md index f10a952c..0c4ad356 100644 --- a/packages/stellar/README.md +++ b/packages/stellar/README.md @@ -43,7 +43,7 @@ import { ## Deterministic Contract Address Derivation -`deriveContractAddress(deployerPublicKey, salt, wasmHash)` computes the Soroban +`deriveContractAddress(deployerPublicKey, salt, wasmHash, networkPassphrase)` computes the Soroban contract address that will be assigned when a contract is deployed with the given parameters, without submitting any transaction. @@ -64,19 +64,80 @@ guaranteed to match the address assigned at deployment time. | `deployerPublicKey`| `string` | `G…` Stellar public key of the deploying account | | `salt` | `Buffer \| string` | 32-byte deployment salt (Buffer or hex string) | | `wasmHash` | `Buffer \| string` | 32-byte SHA-256 hash of the WASM binary | +| `networkPassphrase`| `string` | Network passphrase (e.g., `Networks.PUBLIC`, `Networks.TESTNET_FUTURE`, etc.) | ### Example ```ts import { deriveContractAddress, verifyContractAddress } from '@craft/stellar'; - -const previewAddress = deriveContractAddress(deployerKey, salt, wasmHash); +import { Networks } from 'stellar-sdk'; + +// Current signature requires networkPassphrase to prevent cross-network address collisions +const previewAddress = deriveContractAddress( + deployerKey, + salt, + wasmHash, + Networks.PUBLIC_NETWORK_PASSPHRASE, +); console.log('Pre-deployment address:', previewAddress); // After deployment, verify the address matches -const isMatch = verifyContractAddress(deployerKey, salt, wasmHash, deployedAddress); +const isMatch = verifyContractAddress( + deployerKey, + salt, + wasmHash, + deployedAddress, + Networks.PUBLIC_NETWORK_PASSPHRASE, +); +``` + +### Breaking Changes (v1.x) + +**Version 1.x introduced a breaking change**: both `deriveContractAddress` and `verifyContractAddress` +now require a `networkPassphrase` parameter as their final argument. + +#### Migration Guide + +**Before (pre-v1.x):** +```ts +const address = deriveContractAddress(deployerKey, salt, wasmHash); +const isValid = verifyContractAddress(deployerKey, salt, wasmHash, deployedAddress); ``` +**After (v1.x+):** +```ts +import { Networks } from 'stellar-sdk'; + +const address = deriveContractAddress( + deployerKey, + salt, + wasmHash, + Networks.PUBLIC_NETWORK_PASSPHRASE, +); +const isValid = verifyContractAddress( + deployerKey, + salt, + wasmHash, + deployedAddress, + Networks.PUBLIC_NETWORK_PASSPHRASE, +); +``` + +#### Rationale + +The `networkPassphrase` parameter is required to **prevent cross-network address collisions**. +Contract addresses are deterministic based on the deployer, salt, WASM hash, and **network**. +Without including the network passphrase in the derivation, the same parameters could produce +identical addresses on different networks (Mainnet, Testnet, etc.), leading to confusion and +potential security issues. + +#### Available Network Passphrases + +- `Networks.PUBLIC_NETWORK_PASSPHRASE` – Stellar public network +- `Networks.TESTNET_NETWORK_PASSPHRASE` – Stellar test network +- `Networks.FUTURENET_NETWORK_PASSPHRASE` – Stellar future network +- Custom passphrase string (for private networks) + --- ## Type-Safe Contract Invocation Wrapper diff --git a/packages/stellar/src/multi-party-issuance.test.ts b/packages/stellar/src/multi-party-issuance.test.ts index 4b32d785..a87f0149 100644 --- a/packages/stellar/src/multi-party-issuance.test.ts +++ b/packages/stellar/src/multi-party-issuance.test.ts @@ -465,3 +465,133 @@ describe('regression #1101 – reject co-signer signatures against a different t expect(result.ok).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Session expiry boundary conditions +// --------------------------------------------------------------------------- + +describe('Session expiry at exact boundary', () => { + it('addCoSignerSignature allows signature one moment before expiry', () => { + const baseTxXdr = buildBaseTxXdr(); + const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr); + + // Use a time one millisecond before expiry + const timeBefore = session.expiresAt - 1; + + const result = addCoSignerSignature( + session.id, + SIGNER_A.publicKey(), + signTxXdr(baseTxXdr, SIGNER_A), + NETWORK, + timeBefore, + ); + + expect(result.ok).toBe(true); + }); + + it('addCoSignerSignature allows signature at exact expiry moment', () => { + const baseTxXdr = buildBaseTxXdr(); + const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr); + + // Use the exact expiry time + // At exactly expiresAt, isSessionExpired returns false (because it checks now > expiresAt) + const exactExpiry = session.expiresAt; + + const result = addCoSignerSignature( + session.id, + SIGNER_A.publicKey(), + signTxXdr(baseTxXdr, SIGNER_A), + NETWORK, + exactExpiry, + ); + + expect(result.ok).toBe(true); + }); + + it('addCoSignerSignature rejects signature after expiry', () => { + const baseTxXdr = buildBaseTxXdr(); + const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr); + + // Use a time one millisecond after expiry + const timeAfter = session.expiresAt + 1; + + const result = addCoSignerSignature( + session.id, + SIGNER_A.publicKey(), + signTxXdr(baseTxXdr, SIGNER_A), + NETWORK, + timeAfter, + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/expired/i); + }); + + it('expireTimedOutSessions does not expire session one moment before boundary', () => { + const baseTxXdr = buildBaseTxXdr(); + const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr); + + // Use a time one millisecond before expiry + const timeBefore = session.expiresAt - 1; + const expiredCount = expireTimedOutSessions(timeBefore); + + expect(expiredCount).toBe(0); + + // Verify session is still in pending state + const updated = getIssuanceSession(session.id); + expect(updated?.state).toBe('pending'); + }); + + it('expireTimedOutSessions expires session at exact boundary', () => { + const baseTxXdr = buildBaseTxXdr(); + const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr); + + // Use the exact expiry time + const exactExpiry = session.expiresAt; + const expiredCount = expireTimedOutSessions(exactExpiry); + + expect(expiredCount).toBe(0); // not expired at exactly the boundary + + // Verify session is still in pending state at boundary + const updated = getIssuanceSession(session.id); + expect(updated?.state).toBe('pending'); + }); + + it('expireTimedOutSessions expires session after boundary', () => { + const baseTxXdr = buildBaseTxXdr(); + const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr); + + // Use a time one millisecond after expiry + const timeAfter = session.expiresAt + 1; + const expiredCount = expireTimedOutSessions(timeAfter); + + expect(expiredCount).toBe(1); + + // Verify session transitioned to expired state + const updated = getIssuanceSession(session.id); + expect(updated?.state).toBe('expired'); + }); + + it('addCoSignerSignature and expireTimedOutSessions agree at boundary', () => { + const baseTxXdr = buildBaseTxXdr(); + const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr); + const exactExpiry = session.expiresAt; + + // At the exact boundary (now === expiresAt): + // - addCoSignerSignature should accept (isSessionExpired checks now > expiresAt, not >=) + const sigResult = addCoSignerSignature( + session.id, + SIGNER_A.publicKey(), + signTxXdr(baseTxXdr, SIGNER_A), + NETWORK, + exactExpiry, + ); + + // - expireTimedOutSessions should NOT expire (isSessionExpired also checks now > expiresAt) + const expiredCount = expireTimedOutSessions(exactExpiry); + + // Both should agree: the session is NOT expired at exactly expiresAt + expect(sigResult.ok).toBe(true); + expect(expiredCount).toBe(0); + }); +}); diff --git a/packages/stellar/src/multi-party-issuance.ts b/packages/stellar/src/multi-party-issuance.ts index eb190306..3ac7595d 100644 --- a/packages/stellar/src/multi-party-issuance.ts +++ b/packages/stellar/src/multi-party-issuance.ts @@ -140,12 +140,14 @@ export function createIssuanceSession(config: MultiPartyConfig, baseTxXdr: strin * @param signerPublicKey - Public key of the co-signer submitting the signature * @param signedTxXdr - Transaction signed by this co-signer * @param networkPassphrase - Stellar network passphrase for XDR parsing + * @param _now - Override current timestamp (for testing) */ export function addCoSignerSignature( sessionId: string, signerPublicKey: string, signedTxXdr: string, networkPassphrase: string, + _now: number = Date.now(), ): AddSignatureResult { const session = sessionStore.get(sessionId); if (!session) { @@ -153,7 +155,7 @@ export function addCoSignerSignature( } // Expire sessions that exceeded the timeout - if (isSessionExpired(session, Date.now())) { + if (isSessionExpired(session, _now)) { session.state = 'expired'; return { ok: false, error: 'Session has expired' }; } diff --git a/packages/stellar/src/soroban-budget-monitor.test.ts b/packages/stellar/src/soroban-budget-monitor.test.ts index bdc764f4..abd493da 100644 --- a/packages/stellar/src/soroban-budget-monitor.test.ts +++ b/packages/stellar/src/soroban-budget-monitor.test.ts @@ -437,4 +437,111 @@ describe('trackContractBudget – precomputedSimulation (#1108)', () => { expect(metrics[0].contractId).toBe(CONTRACT_ID); expect(metrics[0].method).toBe('myMethod'); }); +}); + +describe('Circular buffer metrics storage', () => { + it('preserves insertion order when buffer is below capacity', async () => { + const mockSimulate = vi.fn() + .mockResolvedValueOnce(makeSimulation('1000000', '512000')) + .mockResolvedValueOnce(makeSimulation('2000000', '1024000')) + .mockResolvedValueOnce(makeSimulation('3000000', '2048000')); + + await trackContractBudget(CONTRACT_ID, 'method1', [], SOURCE_KEY, {}, mockSimulate); + await trackContractBudget(CONTRACT_ID, 'method2', [], SOURCE_KEY, {}, mockSimulate); + await trackContractBudget(CONTRACT_ID, 'method3', [], SOURCE_KEY, {}, mockSimulate); + + const metrics = getBudgetMetrics(); + expect(metrics).toHaveLength(3); + expect(metrics[0].method).toBe('method1'); + expect(metrics[1].method).toBe('method2'); + expect(metrics[2].method).toBe('method3'); + }); + + it('returns empty array when no metrics recorded', () => { + clearBudgetMetrics(); + const metrics = getBudgetMetrics(); + expect(metrics).toEqual([]); + }); + + it('evicts oldest entry when buffer reaches capacity', async () => { + const mockSimulate = vi.fn().mockImplementation((_, method) => + Promise.resolve(makeSimulation('1000000', '512000')), + ); + + // Push MAX_STORED_METRICS + 1 entries + for (let i = 0; i < 1001; i++) { + await trackContractBudget(CONTRACT_ID, `method${i}`, [], SOURCE_KEY, {}, mockSimulate); + } + + const metrics = getBudgetMetrics(); + expect(metrics).toHaveLength(1000); + // First entry should be method1 (method0 was evicted) + expect(metrics[0].method).toBe('method1'); + // Last entry should be method1000 + expect(metrics[999].method).toBe('method1000'); + }); + + it('maintains correct ordering (newest last) after circular wrap', async () => { + const mockSimulate = vi.fn().mockImplementation((_, method) => + Promise.resolve(makeSimulation('1000000', '512000')), + ); + + // Fill buffer beyond capacity to force wrap-around + for (let i = 0; i < 1005; i++) { + await trackContractBudget(CONTRACT_ID, `m${i}`, [], SOURCE_KEY, {}, mockSimulate); + } + + const metrics = getBudgetMetrics(); + // Should have exactly 1000 entries (capacity) + expect(metrics).toHaveLength(1000); + // First should be m5 (since m0-m4 were evicted in circular wrap) + expect(metrics[0].method).toBe('m5'); + // Last should be m1004 + expect(metrics[999].method).toBe('m1004'); + + // Verify strict order + for (let i = 0; i < metrics.length; i++) { + const expectedNum = 5 + i; + expect(metrics[i].method).toBe(`m${expectedNum}`); + } + }); + + it('clears all metrics and resets circular buffer state', async () => { + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('1000000', '512000')); + + await trackContractBudget(CONTRACT_ID, 'method1', [], SOURCE_KEY, {}, mockSimulate); + expect(getBudgetMetrics()).toHaveLength(1); + + clearBudgetMetrics(); + expect(getBudgetMetrics()).toHaveLength(0); + + // Verify buffer is properly reset by adding new metrics + await trackContractBudget(CONTRACT_ID, 'method2', [], SOURCE_KEY, {}, mockSimulate); + const metrics = getBudgetMetrics(); + expect(metrics).toHaveLength(1); + expect(metrics[0].method).toBe('method2'); + }); + + it('handles sequential fills and clears correctly', async () => { + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('1000000', '512000')); + + // Fill 10 entries + for (let i = 0; i < 10; i++) { + await trackContractBudget(CONTRACT_ID, `a${i}`, [], SOURCE_KEY, {}, mockSimulate); + } + expect(getBudgetMetrics()).toHaveLength(10); + + // Clear + clearBudgetMetrics(); + expect(getBudgetMetrics()).toHaveLength(0); + + // Fill 5 more entries (should start from index 0 again) + for (let i = 0; i < 5; i++) { + await trackContractBudget(CONTRACT_ID, `b${i}`, [], SOURCE_KEY, {}, mockSimulate); + } + const metrics = getBudgetMetrics(); + expect(metrics).toHaveLength(5); + expect(metrics[0].method).toBe('b0'); + expect(metrics[4].method).toBe('b4'); + }); }); \ No newline at end of file diff --git a/packages/stellar/src/soroban-budget-monitor.ts b/packages/stellar/src/soroban-budget-monitor.ts index ce3b6367..64fa7f4b 100644 --- a/packages/stellar/src/soroban-budget-monitor.ts +++ b/packages/stellar/src/soroban-budget-monitor.ts @@ -157,7 +157,12 @@ export function emitBudgetMetrics(metric: BudgetMetric): void { // ── Module-level state (ring-buffer + handlers) ─────────────────────────────── const MAX_STORED_METRICS = 1_000; -const metricsStore: BudgetMetric[] = []; + +// Circular buffer implementation: O(1) push instead of O(n) with shift() +const metricsBuffer = new Array(MAX_STORED_METRICS); +let metricsWriteIndex = 0; +let metricsCount = 0; + const alertHandlers: BudgetAlertHandler[] = []; // ── Public API ──────────────────────────────────────────────────────────────── @@ -187,14 +192,33 @@ export function onBudgetAlert(handler: BudgetAlertHandler): () => void { * Call in test teardown to ensure isolation between test cases. */ export function clearBudgetMetrics(): void { - metricsStore.length = 0; + metricsWriteIndex = 0; + metricsCount = 0; } /** * Return a read-only snapshot of all recorded budget metrics (newest last). + * Reconstructs the circular buffer in insertion order. */ export function getBudgetMetrics(): readonly BudgetMetric[] { - return metricsStore; + if (metricsCount === 0) return []; + + const result: BudgetMetric[] = []; + + if (metricsCount < MAX_STORED_METRICS) { + // Buffer not yet full: read from index 0 to writeIndex + for (let i = 0; i < metricsCount; i++) { + result.push(metricsBuffer[i]!); + } + } else { + // Buffer is full: read from writeIndex (oldest) to writeIndex-1 (newest) + for (let i = 0; i < MAX_STORED_METRICS; i++) { + const index = (metricsWriteIndex + i) % MAX_STORED_METRICS; + result.push(metricsBuffer[index]!); + } + } + + return result; } /** @@ -299,10 +323,14 @@ function extractBudgetUsage( } function pushMetric(metric: BudgetMetric): void { - if (metricsStore.length >= MAX_STORED_METRICS) { - metricsStore.shift(); + // Circular buffer: write to current index and advance + metricsBuffer[metricsWriteIndex] = metric; + metricsWriteIndex = (metricsWriteIndex + 1) % MAX_STORED_METRICS; + + // Track actual count (up to MAX_STORED_METRICS) + if (metricsCount < MAX_STORED_METRICS) { + metricsCount++; } - metricsStore.push(metric); // Emit to analytics sink immediately (#788) emitBudgetMetrics(metric); diff --git a/packages/stellar/src/soroban-event-relay.test.ts b/packages/stellar/src/soroban-event-relay.test.ts index 0aa4425b..ba850927 100644 --- a/packages/stellar/src/soroban-event-relay.test.ts +++ b/packages/stellar/src/soroban-event-relay.test.ts @@ -535,4 +535,82 @@ describe('SorobanEventRelay – dead-letter buffer size cap (#1107)', () => { // Exactly one entry, well within the cap. expect(relay.deadLetterBuffer).toHaveLength(1); }); +}); + +describe('SorobanEventRelay – dead-letter buffer under ACK failures', () => { + it('does not enter DLB when event is acknowledged on fourth delivery attempt', async () => { + vi.useFakeTimers(); + + const ws = makeMockWs(); + const events = [makeMockEvent(CONTRACT_A, 'transfer', 100)]; + const client = makeMockClient(events, 100); + + const relay = new SorobanEventRelay(ws, client, { + ackTimeoutMs: 1_000, + maxDeliveryAttempts: 5, // Allow up to 5 attempts + }); + + relay.subscribe({ contractId: CONTRACT_A }); + // Flush promises from initial poll + for (let i = 0; i < 10; i++) await Promise.resolve(); + + // Simulate 3 failed ACKs (attempts 1, 2, 3) + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(1_001); + for (let j = 0; j < 5; j++) await Promise.resolve(); + } + + // Event should still be in staging buffer after 3 failures + expect(relay.deadLetterBuffer).toHaveLength(0); + + // Now, before the 4th timeout fires, acknowledge the event + const stagedEventId = Array.from((relay as any).stagingBuffer.keys())[0]; + relay.acknowledgeEvent(stagedEventId); + + // Advance timer for the 4th attempt (would have triggered without ACK) + vi.advanceTimersByTime(1_001); + for (let i = 0; i < 5; i++) await Promise.resolve(); + + // Event should never reach dead-letter buffer because it was acknowledged + expect(relay.deadLetterBuffer).toHaveLength(0); + // Event should be cleared from staging buffer + expect((relay as any).stagingBuffer.size).toBe(0); + }); + + it('clears staged events on unsubscribe without adding to DLB', async () => { + vi.useFakeTimers(); + + const ws = makeMockWs(); + const events = [ + makeMockEvent(CONTRACT_A, 'transfer', 100), + makeMockEvent(CONTRACT_A, 'transfer', 101), + ]; + const client = makeMockClient(events, 101); + + const relay = new SorobanEventRelay(ws, client, { + ackTimeoutMs: 1_000, + maxDeliveryAttempts: 2, + }); + + relay.subscribe({ contractId: CONTRACT_A }); + // Flush promises from initial poll + for (let i = 0; i < 10; i++) await Promise.resolve(); + + // Verify events are in staging buffer + expect((relay as any).stagingBuffer.size).toBeGreaterThan(0); + expect(relay.deadLetterBuffer).toHaveLength(0); + + // Unsubscribe before any ACK timeouts fire + relay.unsubscribe({ contractId: CONTRACT_A }); + + // Staged events should be cleared + expect((relay as any).stagingBuffer.size).toBe(0); + + // Advance time well beyond ACK timeout + vi.advanceTimersByTime(5_000); + for (let i = 0; i < 5; i++) await Promise.resolve(); + + // Dead-letter buffer should remain empty; events were not moved there + expect(relay.deadLetterBuffer).toHaveLength(0); + }); }); \ No newline at end of file