Reporting a UX limitation that affects every private dapp on STRK20, with the evidence I gathered narrowing it down. Not a blocker for us — everything works — but it is the single roughest edge in the flow and I could not resolve it at the dapp layer.
Symptom
A private donation raises two wallet approval requests at the same time. They render near-identically, so it reads as a duplicate transaction. Rejecting one leaves the other open. Users reasonably assume they are being charged twice.
Observed in Ready on mainnet, repeatedly.
What I ruled out
1. Not the dapp calling twice. strk20InvokeTransaction is a single JSON-RPC request. From starknet.js 10.4.0:
function strk20InvokeTransaction(walletWSF, actions) {
return walletWSF.features["starknet:walletApi"].request({
type: "wallet_strk20InvokeTransaction",
params: { actions }
});
}
One call in, two approvals out.
2. Not the pre-flight dry run. strk20PrepareInvoke raises its own prompt, so I removed it from the submit path. Verified absent from the production bundle by grepping deployed chunks for a string only reachable from that branch. Two approvals remained.
3. Not a double-mounted component. One component instance, one button, confirmed in served HTML.
4. Not background wallet chatter. This one was partly mine and worth flagging for other teams: I had a balance refresh (strk20Balances) firing after a successful donation, and any wallet request appears to make the wallet flush queued approvals — which resurfaced a stale prompt carrying the previous amount, seconds after the user had only pressed a UI button. Making every wallet call strictly user-initiated removed those stray prompts. The two-per-action approvals persist regardless.
The constraint
A private action needs at least two STRK20 actions, so with one approval per action, two is the floor.
invoke-only is rejected:
That is Wallet-API-level validation, not a contract revert — so the single-action shape is structurally disallowed rather than merely unsupported by my contract.
Our working shape, for reference (stateful helper that parks funds and returns an empty span):
[
{ type: "withdraw", token, amount, recipient: HELPER },
{ type: "invoke", contract: HELPER, calldata: [token, amount, op, ...] },
]
Possible docs inconsistency
Private DeFi End to End shows the swap as transfer(OPEN) + invoke while stating "The pool withdraws amountIn to your helper". That reads as though the input leg is implicit in calldata. It is not — at least not for a helper that parks funds. I lost time assuming it was, and the escrow page is the one that clarified it, via "Tokens already transferred by the pool via Withdraw."
A sentence on the swap page saying the input leg must be declared explicitly would have saved that.
Why it matters
Since the swap example is also two actions, this is not specific to my contract — every private DeFi flow on STRK20 inherits it. Two near-identical simultaneous approvals is a meaningful trust problem for a consumer-facing app, because the natural reading is a double charge.
No double-spend
Worth stating plainly, since the fear is the real damage. Audited across a long testing session: 6 donations against 6 backer_count increments, and the helper contract balance reconciles exactly against the sum of campaign tallies. Rejected duplicates produce nothing on-chain. The behaviour is confusing, not harmful.
Suggestions
- Collapse the approvals for actions within a single
wallet_strk20InvokeTransaction into one wallet confirmation, since they are already atomic on-chain.
- Failing that, label them distinguishably (e.g. "step 1 of 2 — move funds", "step 2 of 2 — call contract") so users can tell they are halves rather than repeats.
- Clarify on the swap page that the input leg is an explicit
withdraw/transfer action.
Repro
Happy to test a fix or provide more detail.
Reporting a UX limitation that affects every private dapp on STRK20, with the evidence I gathered narrowing it down. Not a blocker for us — everything works — but it is the single roughest edge in the flow and I could not resolve it at the dapp layer.
Symptom
A private donation raises two wallet approval requests at the same time. They render near-identically, so it reads as a duplicate transaction. Rejecting one leaves the other open. Users reasonably assume they are being charged twice.
Observed in Ready on mainnet, repeatedly.
What I ruled out
1. Not the dapp calling twice.
strk20InvokeTransactionis a single JSON-RPC request. From starknet.js 10.4.0:One call in, two approvals out.
2. Not the pre-flight dry run.
strk20PrepareInvokeraises its own prompt, so I removed it from the submit path. Verified absent from the production bundle by grepping deployed chunks for a string only reachable from that branch. Two approvals remained.3. Not a double-mounted component. One component instance, one button, confirmed in served HTML.
4. Not background wallet chatter. This one was partly mine and worth flagging for other teams: I had a balance refresh (
strk20Balances) firing after a successful donation, and any wallet request appears to make the wallet flush queued approvals — which resurfaced a stale prompt carrying the previous amount, seconds after the user had only pressed a UI button. Making every wallet call strictly user-initiated removed those stray prompts. The two-per-action approvals persist regardless.The constraint
A private action needs at least two STRK20 actions, so with one approval per action, two is the floor.
invoke-only is rejected:That is Wallet-API-level validation, not a contract revert — so the single-action shape is structurally disallowed rather than merely unsupported by my contract.
Our working shape, for reference (stateful helper that parks funds and returns an empty span):
Possible docs inconsistency
Private DeFi End to End shows the swap as
transfer(OPEN)+invokewhile stating "The pool withdrawsamountInto your helper". That reads as though the input leg is implicit in calldata. It is not — at least not for a helper that parks funds. I lost time assuming it was, and the escrow page is the one that clarified it, via "Tokens already transferred by the pool via Withdraw."A sentence on the swap page saying the input leg must be declared explicitly would have saved that.
Why it matters
Since the swap example is also two actions, this is not specific to my contract — every private DeFi flow on STRK20 inherits it. Two near-identical simultaneous approvals is a meaningful trust problem for a consumer-facing app, because the natural reading is a double charge.
No double-spend
Worth stating plainly, since the fear is the real damage. Audited across a long testing session: 6 donations against 6
backer_countincrements, and the helper contract balance reconciles exactly against the sum of campaign tallies. Rejected duplicates produce nothing on-chain. The behaviour is confusing, not harmful.Suggestions
wallet_strk20InvokeTransactioninto one wallet confirmation, since they are already atomic on-chain.withdraw/transferaction.Repro
0x06fed63d…d93022c0x0449e60d…620689Happy to test a fix or provide more detail.