-
Notifications
You must be signed in to change notification settings - Fork 169
feat(backend): wire Soroban create_stream contract call with simulati… #650
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -194,11 +194,12 @@ export async function initSoroban() { | |||||||||||||
| process.env.RPC_URL || "https://soroban-testnet.stellar.org:443"; | ||||||||||||||
| rpcServer = new rpc.Server(rpcUrl); | ||||||||||||||
|
|
||||||||||||||
| if (process.env.SERVER_PRIVATE_KEY) { | ||||||||||||||
| serverKeypair = Keypair.fromSecret(process.env.SERVER_PRIVATE_KEY); | ||||||||||||||
| const secretKey = process.env.STELLAR_SECRET_KEY || process.env.SERVER_PRIVATE_KEY; | ||||||||||||||
| if (secretKey) { | ||||||||||||||
| serverKeypair = Keypair.fromSecret(secretKey); | ||||||||||||||
| } else { | ||||||||||||||
| logger.warn( | ||||||||||||||
| "SERVER_PRIVATE_KEY missing. Creating streams on-chain will fail.", | ||||||||||||||
| "SERVER_PRIVATE_KEY / STELLAR_SECRET_KEY missing. Creating streams on-chain will fail.", | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -779,49 +780,73 @@ export async function reconcileMissingStreams(): Promise<number> { | |||||||||||||
|
|
||||||||||||||
| export async function createStream(input: StreamInput): Promise<StreamRecord> { | ||||||||||||||
| const startAt = input.startAt ?? nowInSeconds(); | ||||||||||||||
| const sorobanDisabled = | ||||||||||||||
| process.env.SOROBAN_DISABLED?.toLowerCase() === "true" || | ||||||||||||||
| process.env.SOROBAN_ENABLED?.toLowerCase() === "false"; | ||||||||||||||
|
|
||||||||||||||
| const contractId = process.env.CONTRACT_ID; | ||||||||||||||
| const netPass = | ||||||||||||||
| process.env.NETWORK_PASSPHRASE || "Test SDF Network ; September 2015"; | ||||||||||||||
|
|
||||||||||||||
| if (!contractId || !rpcServer || !serverKeypair) { | ||||||||||||||
| throw new Error("Backend not configured for Soroban."); | ||||||||||||||
| } | ||||||||||||||
| let streamIdStr: string; | ||||||||||||||
|
|
||||||||||||||
| const sourceAccount = await rpcServer.getAccount(serverKeypair.publicKey()); | ||||||||||||||
| const tx = createStreamOperation(contractId, input, startAt); | ||||||||||||||
| if (sorobanDisabled || !contractId || !rpcServer || !serverKeypair) { | ||||||||||||||
| if (!sorobanDisabled && (!contractId || !rpcServer || !serverKeypair)) { | ||||||||||||||
| logger.warn( | ||||||||||||||
| "Soroban configuration incomplete or serverKeypair missing, falling back to local SQLite creation.", | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| // Fallback SQLite-only path (e.g., local dev or SOROBAN_ENABLED=false) | ||||||||||||||
| const db = getDb(); | ||||||||||||||
| const row = db | ||||||||||||||
| .prepare("SELECT MAX(CAST(id AS INTEGER)) as maxId FROM streams") | ||||||||||||||
| .get() as { maxId: number | null } | undefined; | ||||||||||||||
| const nextNumericId = (row?.maxId ?? 0) + 1; | ||||||||||||||
| streamIdStr = nextNumericId.toString(); | ||||||||||||||
| } else { | ||||||||||||||
| // Soroban path: build, simulate, sign, and submit transaction | ||||||||||||||
| const sourceAccount = await rpcServer.getAccount(serverKeypair.publicKey()); | ||||||||||||||
| const op = createStreamOperation(contractId, input, startAt); | ||||||||||||||
|
|
||||||||||||||
| const txToSimulate = new TransactionBuilder(sourceAccount, { | ||||||||||||||
| const built = await rpcServer.prepareTransaction( | ||||||||||||||
| new TransactionBuilder(sourceAccount, { | ||||||||||||||
| fee: "1000", | ||||||||||||||
| networkPassphrase: netPass, | ||||||||||||||
| }) | ||||||||||||||
| .addOperation(tx) | ||||||||||||||
| .addOperation(op) | ||||||||||||||
| .setTimeout(30) | ||||||||||||||
| .build(), | ||||||||||||||
| ); | ||||||||||||||
| .build(); | ||||||||||||||
|
|
||||||||||||||
| built.sign(serverKeypair); | ||||||||||||||
| const simRes = await rpcServer.simulateTransaction(txToSimulate); | ||||||||||||||
| if (!rpc.Api.isSimulationSuccess(simRes)) { | ||||||||||||||
| throw new Error("Soroban RPC simulation failed: " + JSON.stringify(simRes)); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const sendRes = await retryWithBackoff(() => rpcServer!.sendTransaction(built)); | ||||||||||||||
| if (sendRes.status !== "PENDING") { | ||||||||||||||
| throw new Error("Failed to send transaction: " + JSON.stringify(sendRes)); | ||||||||||||||
| } | ||||||||||||||
| const preparedTx = await rpcServer.prepareTransaction(txToSimulate); | ||||||||||||||
| preparedTx.sign(serverKeypair); | ||||||||||||||
|
|
||||||||||||||
| 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 sendRes = await retryWithBackoff(() => rpcServer!.sendTransaction(preparedTx)); | ||||||||||||||
| if (sendRes.status !== "PENDING") { | ||||||||||||||
| throw new Error("Failed to send transaction: " + JSON.stringify(sendRes)); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (txResult?.status !== "SUCCESS" || !txResult.returnValue) { | ||||||||||||||
| throw new Error("Tx failed on chain: " + JSON.stringify(txResult)); | ||||||||||||||
| } | ||||||||||||||
| 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++; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (txResult?.status !== "SUCCESS" || !txResult.returnValue) { | ||||||||||||||
| throw new Error("Tx failed on chain: " + JSON.stringify(txResult)); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+843
to
+845
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Falsy check rejects a valid
🐛 Proposed fix- if (txResult?.status !== "SUCCESS" || !txResult.returnValue) {
+ if (txResult?.status !== "SUCCESS" || txResult.returnValue === undefined || txResult.returnValue === null) {
throw new Error("Tx failed on chain: " + JSON.stringify(txResult));
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| const streamIdVal = scValToNative(txResult.returnValue); | ||||||||||||||
| const streamIdStr = streamIdVal.toString(); | ||||||||||||||
| const streamIdVal = scValToNative(txResult.returnValue); | ||||||||||||||
| streamIdStr = streamIdVal.toString(); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const stream: StreamRecord = { | ||||||||||||||
| id: streamIdStr, | ||||||||||||||
|
|
@@ -860,6 +885,8 @@ export async function createStream(input: StreamInput): Promise<StreamRecord> { | |||||||||||||
| resetStatsCache(); | ||||||||||||||
| resetStreamMetricsCache(); | ||||||||||||||
|
|
||||||||||||||
| // Webhook fires after the transaction commits — a webhook failure | ||||||||||||||
| // must never roll back an already-persisted stream. | ||||||||||||||
| triggerWebhook("created", stream); | ||||||||||||||
| return stream; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
SQLite fallback IDs and Soroban on-chain IDs are independent namespaces — collisions will silently overwrite unrelated streams.
streamIdStrin the fallback branch isMAX(CAST(id AS INTEGER)) + 1computed purely from the localstreamstable, while the Soroban branch derivesstreamIdStrfrom the contract's own on-chain counter (scValToNative(txResult.returnValue), seen as a sequential integer in the mock server'sget_next_stream_id). These two counters are not coordinated. Any time the fallback path is used (RPC outage,SOROBAN_ENABLED=falsetoggled temporarily, etc.) and later the Soroban path resumes, the on-chain contract can hand back an id that already exists locally (or vice versa). SinceupsertStreamdoesINSERT ... ON CONFLICT(id) DO UPDATE, a colliding id doesn't error — it silently overwrites a different, unrelated stream's row with the new stream's data.Additionally, even within the fallback path alone,
SELECT MAX(...)and the laterINSERTare two separate statements not wrapped in one transaction; across multiple app instances/processes sharing the same SQLite file this is a TOCTOU race that can also produce duplicate ids.Consider allocating stream ids from a single authoritative source regardless of path (e.g., a dedicated counter table incremented atomically, or a UUID), and/or storing the on-chain id in a separate column from the local primary key so the two are never conflated.
Also applies to: 839-880
🤖 Prompt for AI Agents