The double-click mints twice — idempotency is the app's duty
The chain dedupes nonces, not intents: a user double-clicking Buy produces two valid transactions, and both settle. Payment APIs solved this decades ago with idempotency keys — on-chain apps have to rebuild that discipline themselves.
Build a checkout that issues an intent ID per user action, dedupes on it client-side and server-side, and a control version without it. Double-click both, slow the RPC to widen the race, and count what settles.
Why
Every payment API since Stripe ships idempotency keys because retries and double-clicks are how real users behave. On-chain the problem is worse: the wallet happily signs twice, both transactions carry different nonces so the chain sees two distinct valid payments, and finality means no one un-charges the second one.
The fix cannot live on the chain — the chain has no concept of the user's intent. It lives in the application: one intent, one ID, and every layer (button state, server, submission queue) refuses to act on an ID it has seen. This is the smallest possible example of a larger truth: application-level guarantees have to be built at the application level, even when the settlement layer is perfect.
How it works
One checkout, two builds — with and without intent IDs — and a race you widen on purpose.
PoC
A mint-or-buy button against anvil. Version A: on click, create intent { id, action, params }, disable the button on pending id, have the server (or client queue) refuse a second submission with the same id, and store the mapping id → txHash so a retry returns the existing receipt instead of a new transaction. Version B: none of that. Double-click both under a artificially slow RPC; assert version A settles exactly one transaction and version B settles two.
What it proves
Deduplication by intent is an application responsibility, and it is cheap: one UUID, one lookup table, one disabled button. The chain's own uniqueness (nonces) protects against replay of the same signed bytes — it does nothing against the same human wish signed twice.
체인은 논스를 중복 제거하지 의도는 못 합니다: Buy 를 더블클릭한 사용자는 유효한 트랜잭션 둘을 만들고, 둘 다 체결됩니다. 결제 API 들은 수십 년 전 idempotency key 로 해결한 문제 — 온체인 앱은 그 규율을 직접 다시 만들어야 합니다.
사용자 액션마다 intent ID 를 발급하고 클라이언트·서버 양쪽에서 그것으로 중복 제거하는 체크아웃과, 그것이 없는 대조군을 만듭니다. 둘 다 더블클릭하고, RPC 를 느리게 해 경쟁 구간을 넓힌 뒤, 무엇이 체결되는지 셉니다.
왜
Stripe 이후의 모든 결제 API 가 idempotency key 를 싣는 이유는, 재시도와 더블클릭이 실제 사용자의 행동이기 때문입니다. 온체인에서는 문제가 더 나쁩니다: 지갑은 기꺼이 두 번 서명하고, 두 트랜잭션은 논스가 달라 체인에게는 서로 다른 유효한 결제 둘이며, 파이널리티 때문에 두 번째를 취소해 줄 사람이 없습니다.
해법은 체인에 있을 수 없습니다 — 체인에는 사용자 의도라는 개념이 없으니까요. 해법은 애플리케이션에 삽니다: 의도 하나, ID 하나, 그리고 모든 계층(버튼 상태, 서버, 제출 큐)이 이미 본 ID 에는 행동을 거부하는 것. 이것은 더 큰 진실의 가장 작은 예입니다: 애플리케이션 레벨의 보장은 정산 계층이 완벽해도 애플리케이션 레벨에서 만들어야 합니다.
동작 방식
체크아웃 하나, 빌드 둘 — intent ID 가 있는 버전과 없는 버전 — 그리고 일부러 넓힌 경쟁 구간.
PoC
anvil 을 겨냥한 민트/구매 버튼. 버전 A: 클릭 시 intent { id, action, params } 생성, 대기 중인 id 에는 버튼 비활성화, 서버(또는 클라이언트 큐)는 같은 id 의 두 번째 제출을 거부, id → txHash 매핑을 저장해 재시도에는 새 트랜잭션 대신 기존 영수증을 반환. 버전 B: 그것 전부 없음. 인위적으로 느린 RPC 아래에서 둘 다 더블클릭하고, A 는 정확히 하나, B 는 둘이 체결됨을 확인합니다.
무엇을 증명하나
의도 기준 중복 제거는 애플리케이션의 책임이고, 쌉니다: UUID 하나, 조회 테이블 하나, 비활성화된 버튼 하나. 체인 자체의 유일성(논스)은 같은 서명 바이트의 재사용을 막을 뿐 — 같은 인간의 소망이 두 번 서명되는 것은 전혀 막지 못합니다.