diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4dcea58 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing to StellarStream + +Thank you for your interest in contributing to StellarStream! This guide will help you get started with our development process. + +## Development Setup + +1. Clone the repository +2. Install dependencies: `npm run install:all` +3. Run the development environment: `npm run dev` + +## Testing + +### Backend Tests +Run `npm run test` in the `backend/` directory. + +### Contract Tests +Run `cargo test` in the `contracts/` directory. + +#### Snapshot Testing +We use `insta` for snapshot testing of contract events. +Snapshot files are located in `contracts/test_snapshots/`. + +**To update snapshots:** +If you change event structures and need to update the snapshots, run: +```bash +cargo insta review +``` +This will allow you to interactively review and accept changes to the snapshots. + +## Pull Request Process + +1. Create a feature branch from `main`. +2. Ensure all tests pass. +3. Update documentation if necessary. +4. Submit a PR and wait for review. diff --git a/backend/src/config/validateEnv.ts b/backend/src/config/validateEnv.ts index 019b7b7..8f440ef 100644 --- a/backend/src/config/validateEnv.ts +++ b/backend/src/config/validateEnv.ts @@ -44,7 +44,7 @@ const envSchema = z.object({ DB_PATH: z.string().optional().default("backend/data/streams.db"), WEBHOOK_DESTINATION_URL: z.string().optional(), WEBHOOK_SIGNING_SECRET: z.string().optional(), - JWT_SECRET: z.string().optional().default("default_local_dev_secret_key"), + JWT_SECRET: z.string().optional(), SERVER_SIGNING_KEY: z.string().optional(), DOMAIN: z.string().optional().default("localhost"), SOROBAN_DISABLED: z.string().optional(), diff --git a/backend/src/index.ts b/backend/src/index.ts index e9b4ed3..a8b8d2e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -34,7 +34,9 @@ import { listStreams, listStreamsByRecipient, listStreamsBySender, + pauseStream, refreshStreamStatuses, + resumeStream, StreamStatus, syncStreams, updateStreamStartAt, @@ -677,6 +679,80 @@ app.patch( }, ); +app.patch( + "/api/streams/:id/pause", + authMiddleware, + async (req: Request, res: Response) => { + const parsedId = parseStreamId(req.params.id); + if (!parsedId.ok) { + sendValidationError(req, res, parsedId.issues); + return; + } + + const stream = getStream(parsedId.value); + if (!stream) { + sendApiError(req, res, 404, "Stream not found.", { code: "NOT_FOUND" }); + return; + } + + const user = (req as any).user; + if (stream.sender !== user.accountId) { + sendApiError(req, res, 403, "Only the sender can pause this stream.", { + code: "FORBIDDEN", + }); + return; + } + + try { + const pausedStream = await pauseStream(parsedId.value); + res.json({ data: { ...pausedStream, progress: calculateProgress(pausedStream!) } }); + } catch (error: any) { + console.error("Failed to pause stream:", error); + const normalizedError = normalizeUnknownApiError(error, "Failed to pause stream."); + sendApiError(req, res, normalizedError.statusCode, normalizedError.message, { + code: normalizedError.code ?? "INTERNAL_ERROR", + }); + } + }, +); + +app.patch( + "/api/streams/:id/resume", + authMiddleware, + async (req: Request, res: Response) => { + const parsedId = parseStreamId(req.params.id); + if (!parsedId.ok) { + sendValidationError(req, res, parsedId.issues); + return; + } + + const stream = getStream(parsedId.value); + if (!stream) { + sendApiError(req, res, 404, "Stream not found.", { code: "NOT_FOUND" }); + return; + } + + const user = (req as any).user; + if (stream.sender !== user.accountId) { + sendApiError(req, res, 403, "Only the sender can resume this stream.", { + code: "FORBIDDEN", + }); + return; + } + + try { + const resumedStream = await resumeStream(parsedId.value); + res.json({ data: { ...resumedStream, progress: calculateProgress(resumedStream!) } }); + } catch (error: any) { + console.error("Failed to resume stream:", error); + const normalizedError = normalizeUnknownApiError(error, "Failed to resume stream."); + sendApiError(req, res, normalizedError.statusCode, normalizedError.message, { + code: normalizedError.code ?? "INTERNAL_ERROR", + }); + } + }, +); + app.get("/api/streams/:id/history", (req: Request, res: Response) => { const parsedId = parseStreamId(req.params.id); if (!parsedId.ok) { diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index 4d1d360..01ebb75 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -5,6 +5,7 @@ import { WebAuth, } from "@stellar/stellar-sdk"; import jwt from "jsonwebtoken"; +import crypto from "crypto"; import { Request, Response, NextFunction } from "express"; import { sendApiError } from "../apiErrors"; @@ -16,8 +17,22 @@ const SERVER_SIGNING_KEY = const DOMAIN = (process.env.DOMAIN || "localhost").trim(); const NETWORK_PASSPHRASE = process.env.NETWORK_PASSPHRASE || Networks.TESTNET; +let jwtSecret = process.env.JWT_SECRET; + +if (!jwtSecret) { + if (process.env.NODE_ENV === "production") { + throw new Error("JWT_SECRET must be set in production"); + } + + jwtSecret = crypto.randomBytes(32).toString("hex"); + + console.warn( + "JWT_SECRET not set — using ephemeral secret. All tokens will be invalidated on restart.", + ); +} + function getJwtSecret() { - return process.env.JWT_SECRET || "default_local_dev_secret_key"; + return jwtSecret as string; } export interface AuthUser { diff --git a/backend/src/services/db.ts b/backend/src/services/db.ts index 9412e17..7eb3e96 100644 --- a/backend/src/services/db.ts +++ b/backend/src/services/db.ts @@ -41,7 +41,9 @@ function migrate(): void { canceled_at INTEGER, completed_at INTEGER, refunded_amount REAL, - archived_at INTEGER + archived_at INTEGER, + paused_at INTEGER, + paused_duration INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS stream_archive ( @@ -56,7 +58,9 @@ function migrate(): void { canceled_at INTEGER, completed_at INTEGER, refunded_amount REAL, - archived_at INTEGER NOT NULL + archived_at INTEGER NOT NULL, + paused_at INTEGER, + paused_duration INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS stream_events ( diff --git a/backend/src/services/streamStore.ts b/backend/src/services/streamStore.ts index 570c28b..ee1824c 100644 --- a/backend/src/services/streamStore.ts +++ b/backend/src/services/streamStore.ts @@ -39,6 +39,8 @@ export interface StreamRecord { canceledAt?: number; completedAt?: number; refundedAmount?: number; + pausedAt?: number; + pausedDuration: number; } export interface StreamProgress { @@ -63,6 +65,8 @@ interface StreamRow { completed_at: number | null; refunded_amount: number | null; archived_at: number | null; + paused_at: number | null; + paused_duration: number; } function rowToRecord(row: StreamRow): StreamRecord { @@ -78,6 +82,8 @@ function rowToRecord(row: StreamRow): StreamRecord { canceledAt: row.canceled_at ?? undefined, completedAt: row.completed_at ?? undefined, refundedAmount: row.refunded_amount ?? undefined, + pausedAt: row.paused_at ?? undefined, + pausedDuration: row.paused_duration, }; } @@ -85,8 +91,8 @@ function upsertStream(record: StreamRecord): void { const db = getDb(); db.prepare( ` - INSERT INTO streams (id, sender, recipient, asset_code, total_amount, duration_seconds, start_at, created_at, canceled_at, completed_at, refunded_amount, archived_at) - VALUES (@id, @sender, @recipient, @assetCode, @totalAmount, @durationSeconds, @startAt, @createdAt, @canceledAt, @completedAt, @refundedAmount, @archivedAt) + INSERT INTO streams (id, sender, recipient, asset_code, total_amount, duration_seconds, start_at, created_at, canceled_at, completed_at, refunded_amount, archived_at, paused_at, paused_duration) + VALUES (@id, @sender, @recipient, @assetCode, @totalAmount, @durationSeconds, @startAt, @createdAt, @canceledAt, @completedAt, @refundedAmount, @archivedAt, @pausedAt, @pausedDuration) ON CONFLICT(id) DO UPDATE SET sender = excluded.sender, recipient = excluded.recipient, @@ -98,7 +104,9 @@ function upsertStream(record: StreamRecord): void { canceled_at = excluded.canceled_at, completed_at = excluded.completed_at, refunded_amount = excluded.refunded_amount, - archived_at = excluded.archived_at + archived_at = excluded.archived_at, + paused_at = excluded.paused_at, + paused_duration = excluded.paused_duration `, ).run({ id: record.id, @@ -113,6 +121,8 @@ function upsertStream(record: StreamRecord): void { completedAt: record.completedAt ?? null, refundedAmount: record.refundedAmount ?? null, archivedAt: null, + pausedAt: record.pausedAt ?? null, + pausedDuration: record.pausedDuration ?? 0, }); } @@ -354,6 +364,9 @@ function computeStatus(stream: StreamRecord, at: number): StreamStatus { if (at >= stream.startAt + stream.durationSeconds) { return "completed"; } + if (stream.pausedAt !== undefined) { + return "active"; // Or could be a "paused" status if we want to add it + } return "active"; } @@ -362,11 +375,19 @@ export function calculateProgress( at = nowInSeconds(), ): StreamProgress { const streamEnd = stream.startAt + stream.durationSeconds; + + // Calculate paused duration including current pause if active + let pausedDuration = stream.pausedDuration; + if (stream.pausedAt !== undefined) { + pausedDuration += Math.max(0, at - stream.pausedAt); + } + const effectiveEnd = stream.canceledAt !== undefined - ? Math.min(stream.canceledAt, streamEnd) - : streamEnd; - const elapsed = Math.max(0, Math.min(at, effectiveEnd) - stream.startAt); + ? Math.min(stream.canceledAt, streamEnd + pausedDuration) + : streamEnd + pausedDuration; + + const elapsed = Math.max(0, Math.min(at, effectiveEnd) - stream.startAt - pausedDuration); const ratio = Math.min(1, elapsed / stream.durationSeconds); const vestedAmount = stream.totalAmount * ratio; @@ -623,6 +644,104 @@ export async function createStream(input: StreamInput): Promise { return stream; } +export async function pauseStream(id: string): Promise { + const stream = getStream(id); + if (!stream || stream.pausedAt !== undefined || stream.canceledAt !== undefined || stream.completedAt !== undefined) { + return stream; + } + + const sorobanContext = getSorobanContext(); + if (sorobanContext && rpcServer && serverKeypair) { + const sourceAccount = await rpcServer.getAccount(serverKeypair.publicKey()); + const tx = sorobanContext.contract.call( + "pause_stream", + nativeToScVal(parseInt(id), { type: "u64" }), + ); + + const built = await rpcServer.prepareTransaction( + new TransactionBuilder(sourceAccount, { + fee: "1000", + networkPassphrase: process.env.NETWORK_PASSPHRASE || Networks.TESTNET, + }) + .addOperation(tx) + .setTimeout(30) + .build(), + ); + + built.sign(serverKeypair); + const sendRes = await retryWithBackoff(() => rpcServer!.sendTransaction(built)); + if (sendRes.status === "PENDING") { + let txResult; + let attempts = 0; + while (attempts < 10) { + txResult = await retryWithBackoff(() => rpcServer!.getTransaction(sendRes.hash)); + if (txResult.status !== "NOT_FOUND") break; + await new Promise((r) => setTimeout(r, 1000)); + attempts++; + } + } + } + + const now = nowInSeconds(); + const db = getDb(); + db.prepare("UPDATE streams SET paused_at = ? WHERE id = ?").run(now, id); + + invalidateCache(`stream:${id}`); + return getStream(id); +} + +export async function resumeStream(id: string): Promise { + const stream = getStream(id); + if (!stream || stream.pausedAt === undefined) { + return stream; + } + + const sorobanContext = getSorobanContext(); + if (sorobanContext && rpcServer && serverKeypair) { + const sourceAccount = await rpcServer.getAccount(serverKeypair.publicKey()); + const tx = sorobanContext.contract.call( + "resume_stream", + nativeToScVal(parseInt(id), { type: "u64" }), + ); + + const built = await rpcServer.prepareTransaction( + new TransactionBuilder(sourceAccount, { + fee: "1000", + networkPassphrase: process.env.NETWORK_PASSPHRASE || Networks.TESTNET, + }) + .addOperation(tx) + .setTimeout(30) + .build(), + ); + + built.sign(serverKeypair); + const sendRes = await retryWithBackoff(() => rpcServer!.sendTransaction(built)); + if (sendRes.status === "PENDING") { + let txResult; + let attempts = 0; + while (attempts < 10) { + txResult = await retryWithBackoff(() => rpcServer!.getTransaction(sendRes.hash)); + if (txResult.status !== "NOT_FOUND") break; + await new Promise((r) => setTimeout(r, 1000)); + attempts++; + } + } + } + + const now = nowInSeconds(); + const additionalPausedDuration = Math.max(0, now - stream.pausedAt); + const newTotalPausedDuration = stream.pausedDuration + additionalPausedDuration; + + const db = getDb(); + db.prepare("UPDATE streams SET paused_at = NULL, paused_duration = ? WHERE id = ?").run( + newTotalPausedDuration, + id, + ); + + invalidateCache(`stream:${id}`); + return getStream(id); +} + export function refreshStreamStatuses(): number { const db = getDb(); const now = nowInSeconds(); diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 3322ab3..4e2ded6 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arbitrary" version = "1.3.2" @@ -86,6 +92,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -141,6 +153,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -419,12 +442,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -437,6 +476,12 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" @@ -465,6 +510,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "generic-array" version = "0.14.9" @@ -489,6 +540,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -512,12 +576,27 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -566,6 +645,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -601,6 +686,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + [[package]] name = "itertools" version = "0.11.0" @@ -647,6 +745,12 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" @@ -659,6 +763,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -820,6 +930,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -847,7 +963,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -895,6 +1011,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1055,6 +1184,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "smallvec" version = "1.15.1" @@ -1089,7 +1224,7 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-xdr", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1114,7 +1249,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.17", "hex-literal", "hmac", "k256", @@ -1132,7 +1267,7 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-strkey", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1215,7 +1350,7 @@ dependencies = [ "base64 0.13.1", "stellar-xdr", "thiserror", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1273,6 +1408,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" name = "stellar-stream" version = "0.1.0" dependencies = [ + "insta", "soroban-sdk", ] @@ -1326,6 +1462,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1389,6 +1538,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "version_check" version = "0.9.5" @@ -1401,6 +1556,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1446,6 +1619,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser 0.244.0", +] + [[package]] name = "wasmi_arena" version = "0.4.1" @@ -1474,6 +1669,18 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wasmparser-nostd" version = "0.100.2" @@ -1542,6 +1749,109 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "zerocopy" version = "0.8.39" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index b56c341..fb470ab 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -11,6 +11,7 @@ soroban-sdk = "21.0.0" [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } +insta = { version = "1.34.0", features = ["yaml"] } [profile.release] opt-level = "z" diff --git a/contracts/src/test.rs b/contracts/src/test.rs index 4b7b549..1a522f0 100644 --- a/contracts/src/test.rs +++ b/contracts/src/test.rs @@ -3,8 +3,9 @@ extern crate std; use super::*; use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, - token, Address, Env, IntoVal, + token, Address, Env, IntoVal, symbol_short, }; +use insta::assert_debug_snapshot as assert_snapshot; fn create_token(env: &Env, admin: &Address) -> Address { let token_contract_id = env.register_stellar_asset_contract_v2(admin.clone()); @@ -459,6 +460,74 @@ fn test_event_emissions() { ); } +#[test] +fn test_stream_created_snapshot() { + let env = Env::default(); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = Address::generate(&env); + + let event = StreamCreated { + stream_id: 1, + sender: sender.clone(), + recipient: recipient.clone(), + token: token.clone(), + total_amount: 1000, + start_time: 100, + end_time: 200, + }; + + assert_snapshot!("stream_created_event", event); +} + +#[test] +fn test_claimable_at_start_time() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &1000, &2000); + assert_eq!(client.claimable(&stream_id, &1000), 0); +} + +#[test] +fn test_claimable_at_end_time() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &1000, &2000); + assert_eq!(client.claimable(&stream_id, &2000), 1000); +} + +#[test] +fn test_claimable_after_end_time() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, StellarStreamContract); + let client = StellarStreamContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token = create_token(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token); + token_admin.mint(&sender, &1000); + let stream_id = client.create_stream(&sender, &recipient, &token, &1000, &1000, &2000); + assert_eq!(client.claimable(&stream_id, &2100), 1000); +} + // -----------------------------------------------------------------