From 3763dece01c11dbdfa22ef54bb6ae0e722dabf6d Mon Sep 17 00:00:00 2001 From: dhruvja Date: Tue, 18 Aug 2026 17:18:38 +0530 Subject: [PATCH 1/3] fix: assert undelegation via base-layer ownership and surface ER logs on failure --- .../tests/pinocchio-private-counter.test.ts | 28 ++++++++------- scripts/test-locally.sh | 8 +++++ spl-tokens/anchor/tests/spl-tokens.ts | 34 +++++++++++++++---- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts b/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts index e734663..bbec5c5 100644 --- a/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts +++ b/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts @@ -18,7 +18,6 @@ import { MAGIC_PROGRAM_ID, PERMISSION_PROGRAM_ID, getAuthToken, - GetCommitmentSignature, } from "@magicblock-labs/ephemeral-rollups-sdk"; import * as nacl from "tweetnacl"; import path from "path"; @@ -688,22 +687,25 @@ describe( console.log(`(ER) Undelegate txHash: ${txHash}`); expect(txHash).toBeDefined(); - const commitHash = await GetCommitmentSignature( - txHash, - connectionEphemeralRollup, - ); - console.log(`(ER) Commit txHash: ${commitHash}`); - expect(commitHash).toBeDefined(); - - const result = await connectionBaseLayer.confirmTransaction(commitHash); - console.log(`(Base Layer) Commit result: ${result}`); - expect(result.value.err).toBeNull(); - + // Wait for the ER to commit + undelegate the counter back to the base + // layer, i.e. until it is owned by our program again. This polls base-layer + // ownership directly instead of resolving the commit signature from the ER's + // ScheduledCommitSent logs: the local committor can re-send the finalize tx + // after a transient error, and the duplicate then fails on-chain (the + // original already landed) — which surfaces as "Unable to find Commitment + // signature" even though the account did come back. let counter = await connectionBaseLayer.getAccountInfo(counterPda, { commitment: "confirmed", }); + for (let i = 0; i < 60 && !counter?.owner.equals(PROGRAM_ID); i++) { + await new Promise((r) => setTimeout(r, 1000)); + counter = await connectionBaseLayer.getAccountInfo(counterPda, { + commitment: "confirmed", + }); + } + console.log(`(Base Layer) Counter owner: ${counter?.owner.toBase58()}`); expect(counter?.owner.equals(PROGRAM_ID)).toBe(true); - }); + }, 90_000); }, { timeout: 30000 }, ); diff --git a/scripts/test-locally.sh b/scripts/test-locally.sh index fec3116..3ccf8cb 100755 --- a/scripts/test-locally.sh +++ b/scripts/test-locally.sh @@ -265,6 +265,14 @@ run_test() { if [ "$test_failed" = true ]; then # Show full output on failure cat "$test_log" + # Settlement failures (commit / undelegate never landing) only explain + # themselves in the ER's own logs, so surface those alongside the test output. + if grep -qiE "error|warn" "$REPO_ROOT/mb-stack.log" 2>/dev/null; then + echo "" + echo "----- mb-stack.log (WARN/ERROR lines, last 40) -----" + grep -iE "error|warn" "$REPO_ROOT/mb-stack.log" | tail -40 + echo "----- end of mb-stack.log excerpt -----" + fi else # Show only stage completion markers on success with timing local stages_completed="" diff --git a/spl-tokens/anchor/tests/spl-tokens.ts b/spl-tokens/anchor/tests/spl-tokens.ts index 6741646..f821c02 100644 --- a/spl-tokens/anchor/tests/spl-tokens.ts +++ b/spl-tokens/anchor/tests/spl-tokens.ts @@ -20,8 +20,9 @@ import { import { SplTokens } from "../target/types/spl_tokens"; import { delegateSpl, + deriveEphemeralAta, deriveRentPda, - GetCommitmentSignature, + EPHEMERAL_SPL_TOKEN_PROGRAM_ID, transferSpl, undelegateIx, withdrawSpl, @@ -102,6 +103,21 @@ describe("spl-tokens", () => { ); }; + // Poll the base layer until `account` is owned by the ephemeral SPL token + // program again, i.e. the ER's commit + undelegate for it has landed. + const waitForUndelegation = async (account: PublicKey): Promise => { + for (let attempt = 0; attempt < 60; attempt += 1) { + const info = await connection.getAccountInfo(account, "confirmed"); + if (info?.owner.equals(EPHEMERAL_SPL_TOKEN_PROGRAM_ID)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error( + `${account.toBase58()} was not undelegated back to the base layer in time`, + ); + }; + /** * Create a fresh mint and two recipients, each funded with SOL and holding * {@link TOKEN_AMOUNT} SPL tokens. Returns the mint, owners and their ATAs. @@ -288,9 +304,12 @@ describe("spl-tokens", () => { // Undelegate each owner in the ER (one per tx — combined undelegates are flaky // in CI). Withdraw runs on the base layer and requires each ephemeral ATA to be // owned by the SDK program again, which only happens once that owner's - // undelegation has committed back to base — so wait for BOTH commits before + // undelegation has committed back to base — so wait for BOTH before // withdrawing (waiting for one races the other's withdraw → InvalidAccountOwner). - const commits: string[] = []; + // Ownership is polled on the base layer rather than resolved through the ER's + // commit signature: the local committor can re-send the finalize tx after a + // transient error and the duplicate then fails on-chain (the original already + // landed), which surfaces as "Unable to find Commitment signature". for (const owner of [recipientA, recipientB]) { const sgn = await providerEphemeralRollup.sendAndConfirm( new anchor.web3.Transaction().add( @@ -300,12 +319,13 @@ describe("spl-tokens", () => { { commitment: "confirmed", skipPreflight: true }, ); console.log(`Undelegate ${owner.publicKey.toBase58()} signature: ${sgn}`); - commits.push( - await GetCommitmentSignature(sgn, providerEphemeralRollup.connection), - ); } await Promise.all( - commits.map((c) => connection.confirmTransaction(c, "confirmed")), + [recipientA, recipientB].map((owner) => + waitForUndelegation( + deriveEphemeralAta(owner.publicKey, mint.publicKey)[0], + ), + ), ); // Withdraw both balances back to their base-layer ATAs via the SDK helper. From 037b28c9a9f635fd651a3bf0a327e982ee57c3bf Mon Sep 17 00:00:00 2001 From: dhruvja Date: Wed, 19 Aug 2026 10:04:49 +0530 Subject: [PATCH 2/3] fix: poll base-layer ownership before every wait --- spl-tokens/anchor/tests/spl-tokens.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spl-tokens/anchor/tests/spl-tokens.ts b/spl-tokens/anchor/tests/spl-tokens.ts index f821c02..e8c25c2 100644 --- a/spl-tokens/anchor/tests/spl-tokens.ts +++ b/spl-tokens/anchor/tests/spl-tokens.ts @@ -107,11 +107,13 @@ describe("spl-tokens", () => { // program again, i.e. the ER's commit + undelegate for it has landed. const waitForUndelegation = async (account: PublicKey): Promise => { for (let attempt = 0; attempt < 60; attempt += 1) { + if (attempt > 0) { + await sleep(1000); + } const info = await connection.getAccountInfo(account, "confirmed"); if (info?.owner.equals(EPHEMERAL_SPL_TOKEN_PROGRAM_ID)) { return; } - await new Promise((resolve) => setTimeout(resolve, 1000)); } throw new Error( `${account.toBase58()} was not undelegated back to the base layer in time`, From db68f500d5440fccbf1d9f57a2a158c9a3158222 Mon Sep 17 00:00:00 2001 From: dhruvja Date: Wed, 2 Sep 2026 18:03:32 +0530 Subject: [PATCH 3/3] fix: retry transient settlement polls --- .../tests/pinocchio-private-counter.test.ts | 34 ++++++++++++++----- spl-tokens/anchor/tests/spl-tokens.ts | 16 ++++++--- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts b/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts index bbec5c5..5f14269 100644 --- a/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts +++ b/private-counter/pinocchio/tests/pinocchio-private-counter.test.ts @@ -1,4 +1,5 @@ import { + type AccountInfo, Keypair, PublicKey, SystemProgram, @@ -694,17 +695,32 @@ describe( // after a transient error, and the duplicate then fails on-chain (the // original already landed) — which surfaces as "Unable to find Commitment // signature" even though the account did come back. - let counter = await connectionBaseLayer.getAccountInfo(counterPda, { - commitment: "confirmed", - }); - for (let i = 0; i < 60 && !counter?.owner.equals(PROGRAM_ID); i++) { - await new Promise((r) => setTimeout(r, 1000)); - counter = await connectionBaseLayer.getAccountInfo(counterPda, { - commitment: "confirmed", - }); + let counter: AccountInfo | null = null; + let lastError: unknown; + for (let attempt = 0; attempt < 60; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + try { + counter = await connectionBaseLayer.getAccountInfo(counterPda, { + commitment: "confirmed", + }); + if (counter?.owner.equals(PROGRAM_ID)) { + break; + } + lastError = new Error( + `expected ${PROGRAM_ID.toBase58()}, got ${counter?.owner.toBase58() ?? "missing account"}`, + ); + } catch (error) { + lastError = error; + } } console.log(`(Base Layer) Counter owner: ${counter?.owner.toBase58()}`); - expect(counter?.owner.equals(PROGRAM_ID)).toBe(true); + if (!counter?.owner.equals(PROGRAM_ID)) { + throw new Error( + `Counter was not undelegated back to the base layer in time: ${lastError}`, + ); + } }, 90_000); }, { timeout: 30000 }, diff --git a/spl-tokens/anchor/tests/spl-tokens.ts b/spl-tokens/anchor/tests/spl-tokens.ts index e8c25c2..5fea3d5 100644 --- a/spl-tokens/anchor/tests/spl-tokens.ts +++ b/spl-tokens/anchor/tests/spl-tokens.ts @@ -106,17 +106,25 @@ describe("spl-tokens", () => { // Poll the base layer until `account` is owned by the ephemeral SPL token // program again, i.e. the ER's commit + undelegate for it has landed. const waitForUndelegation = async (account: PublicKey): Promise => { + let lastError: unknown; for (let attempt = 0; attempt < 60; attempt += 1) { if (attempt > 0) { await sleep(1000); } - const info = await connection.getAccountInfo(account, "confirmed"); - if (info?.owner.equals(EPHEMERAL_SPL_TOKEN_PROGRAM_ID)) { - return; + try { + const info = await connection.getAccountInfo(account, "confirmed"); + if (info?.owner.equals(EPHEMERAL_SPL_TOKEN_PROGRAM_ID)) { + return; + } + lastError = new Error( + `expected ${EPHEMERAL_SPL_TOKEN_PROGRAM_ID.toBase58()}, got ${info?.owner.toBase58() ?? "missing account"}`, + ); + } catch (error) { + lastError = error; } } throw new Error( - `${account.toBase58()} was not undelegated back to the base layer in time`, + `${account.toBase58()} was not undelegated back to the base layer in time: ${lastError}`, ); };