diff --git a/src/streams.ts b/src/streams.ts index 2c8dffb..bff77f8 100644 --- a/src/streams.ts +++ b/src/streams.ts @@ -1056,11 +1056,12 @@ export class StreamsModule { // Parsing -function parseStreamInfo(id: bigint, address: string, val: xdr.ScVal): StreamInfo { +export function parseStreamInfo(id: bigint, address: string, val: xdr.ScVal): StreamInfo { const entries = val.map() ?? []; const m: Record = {}; for (const e of entries) { - const k = e.key().sym()?.toString('utf8') ?? e.key().str()?.toString('utf8') ?? ''; + const keyVal = e.key(); + const k = keyVal.switch().name === 'scvSymbol' ? keyVal.sym().toString('utf8') : keyVal.switch().name === 'scvString' ? keyVal.str().toString('utf8') : ''; m[k] = e.val(); } // `paused`, `cancelled` and `clawback_enabled` are NOT fields on the diff --git a/src/tests/parse-stream-info.test.ts b/src/tests/parse-stream-info.test.ts index 2cf5f25..1fe63cb 100644 --- a/src/tests/parse-stream-info.test.ts +++ b/src/tests/parse-stream-info.test.ts @@ -1,18 +1,21 @@ /** - * Unit tests for parseStreamInfo (exercised via StreamsModule.get()). + * Unit tests for parseStreamInfo (exercised directly and via StreamsModule.get()). * - * parseStreamInfo is private; we drive it through get() by controlling the + * parseStreamInfo is exercised directly and driven through get() by controlling the * simulated ScVal map returned for the 'info' contract call. * - * Regression coverage for #521: stream.paused must be true when the contract - * returns scvBool(true) for the 'paused' key — not silently collapsed to false - * by a missing key-presence guard. + * Regression coverage for #521 and contract schema verification for #595: + * The on-chain Soroban contract (contracts/stream/src/storage.rs) stores + * StreamInfo with flags packed into `flags: u32` rather than standalone + * boolean fields. These tests verify the parser strictly adheres to the + * contract schema and round-trips correctly without model drift. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Address, Keypair, StrKey, xdr } from '@stellar/stellar-sdk'; +import type { StreamInfo } from '../types/index.js'; -// ── Mocks ───────────────────────────────────────────────────────────────────── +// ── Mocks ─────────────────────────────────────────────────────────────────── const { mockStreamAddress, mockSimulate } = vi.hoisted(() => ({ mockStreamAddress: vi.fn(), @@ -48,7 +51,7 @@ vi.mock('@stellar/stellar-sdk', async () => { }; }); -// ── Helpers ─────────────────────────────────────────────────────────────────── +// ── Helpers ───────────────────────────────────────────────────────────────── const FACTORY_ADDR = StrKey.encodeContract(Buffer.alloc(32, 1)); const STREAM_ADDR = StrKey.encodeContract(Buffer.alloc(32, 2)); @@ -117,7 +120,7 @@ beforeEach(() => { mockSimulate.mockReset(); }); -// ── Tests ───────────────────────────────────────────────────────────────────── +// ── Tests ─────────────────────────────────────────────────────────────────── describe('parseStreamInfo — flags (#521)', () => { it('sets paused=false when the paused bit is clear', async () => { @@ -182,9 +185,7 @@ describe('parseStreamInfo — flags (#521)', () => { }); it('derives multiple flags from a combined bitmask', async () => { - mockSimulate.mockResolvedValue(simSuccess(baseStreamMap({ - flags: u32Scv(FLAG_PAUSED | FLAG_CLAWBACK_ENABLED), - }))); + mockSimulate.mockResolvedValue(simSuccess(baseStreamMap({ flags: u32Scv(FLAG_PAUSED | FLAG_CLAWBACK_ENABLED) }))); const { StreamsModule } = await import('../streams.js'); const sdk = new StreamsModule({ network: 'testnet', factoryAddress: FACTORY_ADDR, keypair: Keypair.random() }); @@ -194,3 +195,221 @@ describe('parseStreamInfo — flags (#521)', () => { expect(info.cancelled).toBe(false); }); }); + +describe('parseStreamInfo — contract schema validation and flags: u32 round-trip (#595)', () => { + interface ContractStreamParams { + sender?: string; + recipient?: string; + token?: string; + ratePerSecond?: bigint; + startTime?: bigint; + endTime?: bigint; + withdrawn?: bigint; + flags?: number; + pausedAt?: bigint; + keyType?: 'symbol' | 'string'; + extraFields?: Record; + } + + function buildContractStreamInfoScVal(params: ContractStreamParams = {}): xdr.ScVal { + const entries: Record = { + sender: new Address(params.sender ?? SENDER).toScVal(), + recipient: new Address(params.recipient ?? RECIPIENT).toScVal(), + token: new Address(params.token ?? TOKEN_ADDR).toScVal(), + rate_per_second: i128Scv(params.ratePerSecond ?? 100n), + start_time: u64Scv(params.startTime ?? 1_000_000n), + end_time: u64Scv(params.endTime ?? 1_004_000n), + withdrawn: i128Scv(params.withdrawn ?? 0n), + flags: u32Scv(params.flags ?? 0), + paused_at: u64Scv(params.pausedAt ?? 0n), + ...(params.extraFields ?? {}), + }; + + const keyFn = params.keyType === 'string' + ? (k: string) => xdr.ScVal.scvString(k) + : (k: string) => xdr.ScVal.scvSymbol(k); + + return xdr.ScVal.scvMap( + Object.entries(entries).map( + ([k, v]) => new xdr.ScMapEntry({ key: keyFn(k), val: v }), + ), + ); + } + + it('round-trips the canonical contract StreamInfo ScVal map through parseStreamInfo', async () => { + const { parseStreamInfo } = await import('../streams.js'); + + const canonicalMap = buildContractStreamInfoScVal({ + sender: SENDER, + recipient: RECIPIENT, + token: TOKEN_ADDR, + ratePerSecond: 12_500_000n, + startTime: 1_700_000_000n, + endTime: 1_700_100_000n, + withdrawn: 3_000_000n, + flags: FLAG_CLAWBACK_ENABLED, + pausedAt: 0n, + }); + + const info = parseStreamInfo(42n, STREAM_ADDR, canonicalMap); + + expect(info.id).toBe(42n); + expect(info.address).toBe(STREAM_ADDR); + expect(info.sender).toBe(SENDER); + expect(info.recipient).toBe(RECIPIENT); + expect(info.token).toBe(TOKEN_ADDR); + expect(info.ratePerSecond).toBe(12_500_000n); + expect(info.startTime).toBe(1_700_000_000); + expect(info.endTime).toBe(1_700_100_000); + expect(info.withdrawn).toBe(3_000_000n); + expect(info.paused).toBe(false); + expect(info.cancelled).toBe(false); + expect(info.clawbackEnabled).toBe(true); + expect(info.pausedAt).toBe(0); + }); + + it('prevents model drift: ignores standalone bool fields in favor of contract flags: u32', async () => { + const { parseStreamInfo } = await import('../streams.js'); + + // Standalone boolean keys present with true, but flags = 0 + const driftMap1 = buildContractStreamInfoScVal({ + flags: 0, + extraFields: { + paused: xdr.ScVal.scvBool(true), + cancelled: xdr.ScVal.scvBool(true), + clawback_enabled: xdr.ScVal.scvBool(true), + }, + }); + + const parsed1 = parseStreamInfo(1n, STREAM_ADDR, driftMap1); + expect(parsed1.paused).toBe(false); + expect(parsed1.cancelled).toBe(false); + expect(parsed1.clawbackEnabled).toBe(false); + + // Standalone boolean keys present with false, but flags has bits set + const driftMap2 = buildContractStreamInfoScVal({ + flags: FLAG_PAUSED | FLAG_CANCELLED | FLAG_CLAWBACK_ENABLED, + extraFields: { + paused: xdr.ScVal.scvBool(false), + cancelled: xdr.ScVal.scvBool(false), + clawback_enabled: xdr.ScVal.scvBool(false), + }, + }); + + const parsed2 = parseStreamInfo(1n, STREAM_ADDR, driftMap2); + expect(parsed2.paused).toBe(true); + expect(parsed2.cancelled).toBe(true); + expect(parsed2.clawbackEnabled).toBe(true); + }); + + it('correctly decodes all permutations of contract flags: u32 bitmask', async () => { + const { parseStreamInfo } = await import('../streams.js'); + + const flagTestCases = [ + { flags: 0, paused: false, cancelled: false, clawbackEnabled: false }, + { flags: FLAG_PAUSED, paused: true, cancelled: false, clawbackEnabled: false }, + { flags: FLAG_CLAWBACK_ENABLED, paused: false, cancelled: false, clawbackEnabled: true }, + { flags: FLAG_CANCELLED, paused: false, cancelled: true, clawbackEnabled: false }, + { flags: FLAG_PAUSED | FLAG_CLAWBACK_ENABLED, paused: true, cancelled: false, clawbackEnabled: true }, + { flags: FLAG_PAUSED | FLAG_CANCELLED, paused: true, cancelled: true, clawbackEnabled: false }, + { flags: FLAG_CLAWBACK_ENABLED | FLAG_CANCELLED, paused: false, cancelled: true, clawbackEnabled: true }, + { flags: FLAG_PAUSED | FLAG_CLAWBACK_ENABLED | FLAG_CANCELLED, paused: true, cancelled: true, clawbackEnabled: true }, + // Unrelated higher-order bits do not flip known flags + { flags: 1 << 5, paused: false, cancelled: false, clawbackEnabled: false }, + { flags: (1 << 5) | FLAG_PAUSED, paused: true, cancelled: false, clawbackEnabled: false }, + ]; + + for (const { flags, paused, cancelled, clawbackEnabled } of flagTestCases) { + const map = buildContractStreamInfoScVal({ flags }); + const parsed = parseStreamInfo(10n, STREAM_ADDR, map); + expect(parsed.paused).toBe(paused); + expect(parsed.cancelled).toBe(cancelled); + expect(parsed.clawbackEnabled).toBe(clawbackEnabled); + } + }); + + it('preserves 128-bit integer precision for rate_per_second and withdrawn from contract schema', async () => { + const { parseStreamInfo } = await import('../streams.js'); + + const largeRate = 18_446_744_073_709_551_615_000n; + const largeWithdrawn = (1n << 127n) - 1n; // max signed i128 + const map = buildContractStreamInfoScVal({ + ratePerSecond: largeRate, + withdrawn: largeWithdrawn, + }); + + const parsed = parseStreamInfo(1n, STREAM_ADDR, map); + expect(parsed.ratePerSecond).toBe(largeRate); + expect(parsed.withdrawn).toBe(largeWithdrawn); + }); + + it('supports contract ScVal maps with string keys identically to symbol keys', async () => { + const { parseStreamInfo } = await import('../streams.js'); + + const symMap = buildContractStreamInfoScVal({ + flags: FLAG_PAUSED | FLAG_CLAWBACK_ENABLED, + keyType: 'symbol', + }); + const strMap = buildContractStreamInfoScVal({ + flags: FLAG_PAUSED | FLAG_CLAWBACK_ENABLED, + keyType: 'string', + }); + + const symInfo = parseStreamInfo(5n, STREAM_ADDR, symMap); + const strInfo = parseStreamInfo(5n, STREAM_ADDR, strMap); + + expect(strInfo.paused).toBe(symInfo.paused); + expect(strInfo.cancelled).toBe(symInfo.cancelled); + expect(strInfo.clawbackEnabled).toBe(symInfo.clawbackEnabled); + expect(strInfo.sender).toBe(symInfo.sender); + expect(strInfo.token).toBe(symInfo.token); + }); + + it('serializes cleanly to JSON via .toJSON() without bigint errors', async () => { + const { parseStreamInfo } = await import('../streams.js'); + + const map = buildContractStreamInfoScVal({ + ratePerSecond: 100n, + flags: FLAG_PAUSED, + }); + const info = parseStreamInfo(1n, STREAM_ADDR, map) as StreamInfo & { toJSON(): Record }; + + const json = info.toJSON(); + expect(typeof json).toBe('object'); + expect(json.ratePerSecond).toBe('100'); + expect(json.paused).toBe(true); + expect(json.cancelled).toBe(false); + }); + + it('round-trips contract StreamInfo end-to-end via StreamsModule.get()', async () => { + const contractMap = buildContractStreamInfoScVal({ + sender: SENDER, + recipient: RECIPIENT, + token: TOKEN_ADDR, + ratePerSecond: 50n, + startTime: 1_600_000_000n, + endTime: 1_600_500_000n, + withdrawn: 10n, + flags: FLAG_CANCELLED, + pausedAt: 0n, + }); + + mockSimulate.mockResolvedValue(simSuccess(contractMap)); + const { StreamsModule } = await import('../streams.js'); + const sdk = new StreamsModule({ network: 'testnet', factoryAddress: FACTORY_ADDR, keypair: Keypair.random() }); + + const info = await sdk.get(100n); + expect(info.id).toBe(100n); + expect(info.address).toBe(STREAM_ADDR); + expect(info.sender).toBe(SENDER); + expect(info.recipient).toBe(RECIPIENT); + expect(info.token).toBe(TOKEN_ADDR); + expect(info.ratePerSecond).toBe(50n); + expect(info.startTime).toBe(1_600_000_000); + expect(info.endTime).toBe(1_600_500_000); + expect(info.withdrawn).toBe(10n); + expect(info.cancelled).toBe(true); + expect(info.paused).toBe(false); + expect(info.clawbackEnabled).toBe(false); + }); +});