v1.1.0
What's Changed
A transaction request can now carry an idempotency key, so retrying a send cannot broadcast twice. There was no way to supply one before, which made a retry after a timeout a second transaction.
import { createWalletFromEnv, isEvmAdapter } from "@opensea/wallet-adapters"
// Generate the key where you decide to send, and reuse it on every retry of
// that send. A fresh key on a retry is a second transaction.
declare const idempotencyKey: string
const wallet = createWalletFromEnv()
if (isEvmAdapter(wallet)) {
await wallet.sendTransaction({
to: "0x0000000000000000000000000000000000000000",
data: "0x",
value: "0",
chainId: 1,
idempotencyKey,
})
}createWalletFromEnv() returns the WalletAdapter union, so narrow before reaching for a
chain-specific method. On Solana:
import { createWalletFromEnv, isSvmAdapter } from "@opensea/wallet-adapters"
declare const idempotencyKey: string
const svm = createWalletFromEnv({ chainType: "svm" })
if (isSvmAdapter(svm)) {
await svm.signTransaction({ transaction: "AQID", idempotencyKey })
}The Privy adapters forward it as privy-idempotency-key, which Privy stores with the request and its response for 24 hours, returning the stored response rather than re-executing.
One key per transaction, not per attempt
The reuse is the mechanism. A retry carrying the same key gets the stored response; a retry carrying a fresh key sends again. So the key belongs outside your retry loop. Reusing a key with a changed body is rejected with a 400, which ties it to the request you actually built.
This is not reference_id, which Privy also accepts on a send. That is for reconciliation and does not deduplicate.
Adapters that cannot honour a key throw
bankr, fireblocks, turnkey and private-key reject a supplied key with IdempotencyUnsupportedError, before any network call, rather than dropping it. A silently ignored key turns a retry into exactly the second transaction you were trying to avoid, so an error is the safer failure. Check capabilities.idempotentSend to know which you have.
A blank key throws BlankIdempotencyKeyError, on every adapter including the Privy ones. Privy answers 200 to an empty and to a whitespace-only key, so forwarding one protects nothing while looking like it does, and an empty string almost always arrives from an unset variable.
capabilities.idempotentSend is optional, so an adapter written against 1.0.x still satisfies WalletCapabilities.
Verified against the live API
Whether Privy honours the header is a question about Privy, so it was checked there rather than only against mocks. Reusing a key with a changed body returns a 400 naming the reuse, which Privy can only produce by having stored the key and compared bodies.
The same-key-same-body case is deliberately not offered as evidence: ed25519 signing is deterministic, so two independent signatures over one message are byte-identical whether or not a stored response came back.
Full Changelog: v1.0.1...v1.1.0