React SDK reuses by-value WASM objects across poll/refetch iterations ("null pointer passed to rust")
- Vorherrschende Sprache
- TypeScript
- Sterne
- 1
- Forks
- 21
- Ø Merge
- 12 Std. 14 Min.
- Gemergte PRs (30 T.)
- 41
Beschreibung
### Packages versions
@miden-sdk/miden-sdk: 0.15.9
@miden-sdk/react: 0.15.9
Traced against 0xMiden/web-sdk @ main, commit dcfdc6a. All file:line references below are current as of that commit.
### Bug description
A few places in `@miden-sdk/react` keep a `TransactionFilter` or `TransactionId` in a variable and hand it to the client more than once. On the Rust side both are taken by value, so wasm-bindgen moves them into WASM and zeroes the JS wrapper's pointer on the first call. Every use after that fails.
#270 already fixes one instance of this, in `utils/transactions.ts`, by snapshotting the hex and rebuilding the `TransactionId` per poll. That fix is right. The same mistake is in four other places, and the worst of them needs no polling loop at all, so I'm filing this to cover the rest.
One thing the error messages make harder than it should be: which failure you get depends on how the object is passed. Used directly, you get `null pointer passed to rust`. Passed inside the array `TransactionFilter.ids()` takes, you get `array contains a value of the wrong type`, which says nothing about the real cause at all. Both are in the log output field, captured in Chromium against the published 0.15.9.
### Why the objects get consumed
`get_transactions` takes an owned filter (`crates/web-client/src/transactions.rs:12-14`) and `TransactionFilter::ids` takes `Vec` by value (`crates/web-client/src/models/transaction_filter.rs:23`). The shipped glue makes the consequence explicit — `getTransactions` calls `__destroy_into_raw()` on the filter, and `__destroy_into_raw` sets `this.__wbg_ptr = 0`. So every call destroys the wrapper it was handed.
This isn't news to the repo. The JS resource layer already handles it, with a comment saying why, in `crates/web-client/js/resources/transactions.js` (`waitFor()`):
```js
// Recreate filter each iteration — WASM consumes it by value
const filter = wasm.TransactionFilter.ids([
wasm.TransactionId.fromHex(hex),
]);
```
Same warning in `crates/web-client/test/test-helpers.ts:99` ("Save hex before `TransactionFilter.ids()` consumes the WASM object"). The React SDK never picked it up.
Worth flagging up front: #135 reports the `null pointer passed to rust` string too, but from a different cause (auth-callback mismatch for `withNoAuthComponent()` accounts during execution). This one needs no signer and reproduces with a plain wallet account.
### Where it happens
**`useTransactionHistory.ts:134`** — a caller-supplied filter is reused on every refetch:
```ts
function buildFilter(filter, ids, idsHex): FilterBuildResult {
if (filter) {
return { filter };
}
```
`refetch` runs again on every sync (`refreshOnSync` defaults to true at line 56, effect at 89-92) and `options.filter` is memo-stable, so the filter survives one fetch and is dead on the next. This is the one that needs no polling and no transaction — a mounted `useTransactionHistory({ filter: TransactionFilter.uncommitted() })` breaks on the second sync tick.
**`useTransactionHistory.ts:143`** — same shape, with IDs:
```ts
return { filter: TransactionFilter.ids(ids as TransactionId[]) };
```
`ids` comes from a `useMemo` over `options.id` / `options.ids`, so the caller's `TransactionId` objects are consumed on the first fetch and reused dead afterwards. Passing hex strings instead takes the `TransactionFilter.all()` + local-scan branch and is unaffected, but the hook's own JSDoc example points people at `record.id()`, which returns a `TransactionId`.
**`useWaitForCommit.ts:53`** — the filter is rebuilt per iteration, `txId` isn't:
```ts
typeof txId === "string"
? TransactionFilter.all() // fresh each iteration, fine
: TransactionFilter.ids([txId]) // txId gone after iteration 1
```
So a transaction that isn't already committed on the first poll throws on the second instead of continuing to wait, which is the case the hook exists for. This is the same defect #270 fixes in the shared helper, just in a second copy of the loop.
**`useTransaction.ts:158`** — after `waitForTransactionCommit` has consumed `txId`, the summary still reads from it:
```ts
const txSummary = { transactionId: txId.toHex() };
```
Note this one survives #270. That PR rebuilds the id *inside* the helper, which is correct, but the caller's `txId` was already moved out by the first `TransactionFilter.ids([txId])` before the fix's snapshot is taken — so the private-note delivery path of `useTransaction` still can't return successfully. Worth checking against #270's branch; if I've misread the ordering there, this one collapses into it.
**`utils/transactions.ts:30`** — the original instance, already addressed in #270.
### The duplicated helper
#270 fixes the bug in `utils/transactions.ts` but leaves the duplication behind. There are still two `waitForTransactionCommit` implementations: `utils/transactions.ts:18` takes a `TransactionId` and queries with `ids()`, `utils/noteFilters.ts:66` takes a hex string and scans `TransactionFilter.all()`. `useTransaction` uses the first, `useSend` and `useMultiSend` the second.
The `noteFilters.ts` one dodges the bug on purpose. Its comment says it takes hex "because `applyTransaction` may invalidate all child WASM pointers (including `TransactionId`)". But it pays for that with a full scan every poll, on the strength of a TODO that has since gone stale:
```ts
// TODO: Use TransactionFilter.ids([txId]) once TransactionId.fromHex()
// is available in the SDK. ... This is O(n) per poll iteration.
```
`TransactionId.fromHex()` does exist (`crates/web-client/src/models/transaction_id.rs:28`) and `resources/transactions.js` already uses it in both `list()` and `waitFor()`. Once #270 lands, the two helpers do the same thing by the same means and one of them should go.
### Why the tests miss it
The unit tests stub the WASM types as plain objects:
```ts
const txId = { toHex: () => "0xtx" } as never;
```
No pointer to zero, so nothing is ever consumed and every site clears the 95% coverage gate. `__tests__/hooks/useWaitForCommit.test.tsx:99` goes further and asserts `TransactionFilter.ids([txId])` is called, so the buggy shape is currently pinned by a test. #270's author ran into the same wall and noted it there.
### Fix sketch
For the id-based sites, #270's approach generalises directly: take `string | TransactionId` at the boundary, convert to hex once, and rebuild `TransactionId.fromHex(hex)` plus the filter on every call, the way `resources/transactions.js` already does.
`options.filter` is the one that can't be normalized that way, since it's an opaque caller-owned object. The choices are accepting a factory (`filter?: TransactionFilter | (() => TransactionFilter)`) or rebuilding per refetch from a descriptor, and I'd rather hear which shape you'd want than guess.
The part that keeps the whole class from coming back is a move-aware mock in the react-sdk test setup: fakes whose pointer is zeroed when passed to `ids()` or `getTransactions`, throwing on reuse. `useWaitForCommit.test.tsx:99` needs updating alongside it. Without that, the next hook to touch a WASM handle reintroduces this and CI stays green.
Happy to pick up whatever of this you don't want folded into #270. Should that target `main` or `next`?
### How can this be reproduced?
Traced by reading the source rather than captured from a running app, so the steps below are derived from the code paths, not from an executed session. The shortest one is the `useTransactionHistory` filter case, which needs no transaction at all:
```tsx
import { TransactionFilter } from "@miden-sdk/miden-sdk";
import { useTransactionHistory } from "@miden-sdk/react";
function Uncommitted() {
// filter is memo-stable across renders, and refreshOnSync defaults to true
const { records, error } = useTransactionHistory({
filter: TransactionFilter.uncommitted(),
});
return
{error ? String(error) : records.length};
}
```
Mount it under `MidenProvider` and let one sync tick land. The first fetch succeeds; the refetch triggered by `lastSyncTime` passes the already-consumed filter back into `getTransactions`.
The polling case needs a transaction that isn't committed on the first poll:
```tsx
const { waitForCommit } = useWaitForCommit();
// passing the WASM object, not the hex string
await waitForCommit(txResult.id());
```
Iteration 1 consumes `txId` inside `TransactionFilter.ids([txId])`; iteration 2 reuses it.
Note that neither reproduces under the react-sdk unit suite, which aliases `@miden-sdk/miden-sdk` to a hand-written mock (`vitest.config.ts` → `src/__tests__/mocks/miden-sdk-entry.ts`). It needs the real WASM binary, so a Playwright test under `packages/react-sdk/test/` is the right place for a regression test.
### Relevant log output
```shell
```
Beitragsleitfaden
Rechercherichtung
The bug is in @miden-sdk/react hooks that reuse WASM objects (TransactionFilter, TransactionId) across calls, causing null pointer errors. Start by examining useTransactionHistory.ts, useWaitForCommit.ts, and useTransaction.ts to locate the problematic reuses. Understand how wasm-bindgen consumes objects by value. The fix involves converting to hex strings at boundaries and rebuilding objects per call, similar to the fix in #270. Update tests to use move-aware mocks to prevent regression.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Bewertung
- Tech-Stack
- react, rust, typescript, wasm
- Bereich
- backend-api-design, devtools, testing-qa
- Issue-Typ
- Bug
- Schwierigkeit
- 3/5
- Geschätzter Aufwand
- 1-2 Tage
- Aktivitätsstatus
- Ruhig
- Klarheit
- Klar beschrieben
- Anfängerfreundlichkeit
- 45/100