Skip to content

Releases: ProjectOpenSea/wallet-adapters

v1.2.1

Choose a tag to compare

@ryanio ryanio released this 10 Sep 01:28

What's Changed

Patch Changes

  • bd4aa58: Every adapter now invokes the configured fetchImpl with globalThis as the receiver, matching how @opensea/sdk invokes the same seam. Each adapter previously copied the transport into a local and called it bare, which leaves the receiver undefined.

    That bare call works for unbound native fetch, because Web IDL replaces a null or undefined receiver with the global object. What it does not survive is a call site written as a member expression: reading the transport off the config and calling it in place makes the config object the receiver, and browsers reject that with "Illegal invocation". Pinning the receiver at one helper removes the difference between the two spellings.

    The five byte-identical private fetchImpl methods, one per adapter, collapse into a single fetchWith helper. Five copies of a rule meant a fix applied to one and not the others would be a silent divergence, and the private method took the same name as the config.fetchImpl field it read, which is what made the bare call easy to write. The helper is duplicated from the SDK rather than imported: @opensea/wallet-adapters ships with only @noble/hashes and @noble/curves as runtime dependencies, and depending on the SDK for five lines would pull in ethers and @opensea/seaport-js.

    No public API changes. FetchImpl and every adapter config are unchanged, and a caller who passes a bound transport or an arrow is unaffected because those ignore the receiver.

Full Changelog: ProjectOpenSea/opensea-devtools@wallet-adapters-v1.2.0...wallet-adapters-v1.2.1

v1.2.0

Choose a tag to compare

@ryanio ryanio released this 09 Sep 19:13

What's Changed

Every adapter config takes an optional fetchImpl, exported as the FetchImpl type. It defaults to the global fetch, so nothing changes for existing callers.

It exists so a test can assert on the requests an adapter builds without standing up a server or reassigning globalThis.fetch. Reassigning the global is process-wide, which makes tests order-dependent and leaks into anything else sharing the process.

import { PrivyAdapter, type FetchImpl } from "@opensea/wallet-adapters"

const calls: string[] = []
const fetchImpl: FetchImpl = async (input, init) => {
  calls.push(String(input))
  return new Response(JSON.stringify({ data: { signature: "0x00" } }), {
    status: 200,
    headers: { "content-type": "application/json" },
  })
}

const wallet = new PrivyAdapter({
  appId: "app",
  appSecret: "secret",
  walletId: "wallet",
  fetchImpl,
})

await wallet.signMessage({ message: "hello" })
void calls

FetchImpl is typeof globalThis.fetch, so any spec-compatible implementation satisfies it. Covers PrivyConfig, TurnkeyConfig, FireblocksConfig, BankrConfig and PrivateKeyConfig.

Full Changelog: ProjectOpenSea/opensea-devtools@wallet-adapters-v1.1.0...wallet-adapters-v1.2.0

v1.1.0

Choose a tag to compare

@ryanio ryanio released this 09 Sep 15:51

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

v1.0.1

Choose a tag to compare

@ryanio ryanio released this 09 Sep 02:11

What's Changed

Fixes a gap in 1.0.0: the viem and ethers bridges accepted a Solana adapter and built a broken client instead of rejecting it.

Both bridges are typed EvmWalletAdapter, which stops a TypeScript caller. This package publishes main and types, so it does not stop anyone else, and in 1.0.0 neither bridge checked at runtime.

The viem bridge did not fail, which is worse than failing. Against a real Privy Solana wallet it returned a client whose account.address was a base58 Solana key, eth_accounts reported that key as an Ethereum address, and the first transaction died with An unknown RPC error occurred, naming neither the wallet nor the reason.

Both bridges now call requireEvmAdapter and give the same message the other EVM-only flows give:

wallet "privy-svm" signs for svm; a viem client requires an EVM wallet

The check runs before getAddress(). That order matters: viem rejects a base58 key, so a guard placed after the address lookup is unreachable and the caller sees an address error instead of a chain-type one. The ethers guard sits in the EthersAdapterSigner constructor rather than only in walletAdapterToEthersSigner, so constructing the signer directly is covered too.

A correction to the 1.0.0 notes

Those notes said the viem bridge rejected a Solana wallet. That was true of @opensea/tool-sdk's walletAdapterToClient wrapper, which does call requireEvmAdapter, and not of this package's own bridge. If you are on 1.0.0 and pass adapters from JavaScript, upgrade.

Found by driving the published 1.0.0 against a real Privy Solana wallet rather than by a type error, which is also how the end-to-end signing path got its first real exercise: message signing on both chains verified against the wallet's own public key, and a devnet transaction signed and verified over its real message bytes.

Full Changelog: v1.0.0...v1.0.1

v1.0.0

Choose a tag to compare

@ryanio ryanio released this 09 Sep 00:21

What's Changed

WalletAdapter is now chain-generic, so a non-EVM wallet can implement it. This is the major that makes Solana support possible, and it adds the first SVM provider.

Breaking

WalletAdapter is a union discriminated on chainType, with EvmWalletAdapter and SvmWalletAdapter over a shared BaseWalletAdapter. Narrow before reaching for chain-specific methods:

import type { WalletAdapter } from "@opensea/wallet-adapters"
import { createWalletFromEnv, isEvmAdapter, requireEvmAdapter } from "@opensea/wallet-adapters"

const wallet: WalletAdapter = createWalletFromEnv()

if (isEvmAdapter(wallet)) {
  await wallet.sendTransaction({
    to: "0x0000000000000000000000000000000000000000",
    data: "0x",
    value: "0",
    chainId: 1,
  })
}

// Or, for a flow that is EVM-only in substance, throw a message naming the reason.
const evm = requireEvmAdapter(wallet, "swap execution")

TransactionRequest is renamed EvmTransactionRequest and kept as a deprecated alias, so the rename alone breaks nobody. The ethers and viem bridges now take EvmWalletAdapter, since both are EVM clients. The five existing providers declare chainType: "evm" and are otherwise unchanged, so an adapter you did not write keeps working.

What actually breaks is an adapter you implement yourself: it now needs a chainType.

Solana

PrivySvmAdapter signs through Privy's wallet RPC. signTransaction is required and sendTransaction is optional, the reverse of EVM, because sign-only providers are ordinary on Solana: a fee payer co-signs, the transaction is simulated first, or the caller broadcasts with its own commitment policy.

import { createWalletFromEnv, isSvmAdapter } from "@opensea/wallet-adapters"

const svm = createWalletFromEnv({ chainType: "svm" })
if (isSvmAdapter(svm)) {
  const { signedTransaction } = await svm.signTransaction({ transaction: "AQID" })
}

A Privy wallet is bound to one chain_type, so the Solana wallet is a separate wallet with its own id, read from PRIVY_SVM_WALLET_ID. Both chains can be configured at once, and createWalletFromEnv() with no argument still returns the EVM one.

SvmTransactionRequest carries the serialized transaction rather than a to and value, since a Solana transaction is instructions over accounts with no single recipient. That also keeps a Solana SDK out of this package's dependency graph. SvmSignedTransaction.signedTransaction is always base64, whichever form the request used, because that is what Solana RPC sendTransaction takes.

Fixed

PrivyAdapter.signTypedData sent params.typedData as a JSON string containing primaryType, where Privy takes a params.typed_data object containing primary_type. EIP-712 signing through Privy had never worked in any published version. Request bodies are now pinned to the types @privy-io/node publishes, so a field they rename fails a type check here rather than as a 4xx.

Four flows that are EVM-only in substance now say so and reject a Solana wallet with a message naming the reason: x402 settlement, the viem bridge, swaps.execute, and the auth and smoke commands that sign EIP-712.

Not yet verified

No release has signed a real Solana transaction. Every shape here is checked against Privy's published types and the adapter is unit tested against a stubbed transport, but the end-to-end path is unexercised.

Full Changelog: v0.3.5...v1.0.0

v0.3.5

Choose a tag to compare

@ryanio ryanio released this 23 Aug 19:04

What's changed

  • signTypedData infers the EIP-712 primary type as the struct no other struct references, rather than the first key in types. Declaring a dependency before the root signed the dependency. It now refuses an ambiguous or circular type set instead of falling back to the first name, which would reintroduce the bug.
  • The tests are typechecked.

Community contributions

  • EIP-712 primary type inference, thanks @Nexory (#2)

Full changelog: ProjectOpenSea/opensea-devtools@wallet-adapters-v0.3.4...wallet-adapters-v0.3.5

v0.3.4

Choose a tag to compare

@ryanio ryanio released this 03 Aug 17:12

What's Changed

  • Picks up the stablechain / Chain.StableChain (chain id 988) spec sync via @opensea/api-types@0.8.7.

No adapter behaviour changes in this release.

Full Changelog: v0.3.3...v0.3.4

v0.3.3

Choose a tag to compare

@ryanio ryanio released this 12 Jul 20:36

What's Changed

  • Harden EIP-712 encoding of non-integer types so malformed input throws instead of being silently misencoded. The address encoder now validates a 0x-prefixed hex string of at most 20 bytes; bytes/bytesN values with odd-length or non-hex input, or wider than 32 bytes, are rejected; and the shared hex parser rejects odd-length and non-hex strings rather than truncating. (#532)
  • Validate integer values before EIP-712 encoding instead of silently wrapping out-of-domain values. A negative value for an unsigned uint* type now throws (previously it wrapped into a huge positive integer via two's complement); values exceeding the declared width are also rejected. In-range signed int* two's-complement encoding is retained per EIP-712. (#530)

Full Changelog: ProjectOpenSea/opensea-devtools@wallet-adapters-v0.3.2...wallet-adapters-v0.3.3

v0.3.2

Choose a tag to compare

@ryanio ryanio released this 01 Jul 02:44

What's Changed

  • Fix BankrAdapter.signTypedData throwing on EIP-712 payloads containing BigInt fields (e.g. EIP-3009 value/validAfter/validBefore, chainId). BigInts are now serialized to strings before sending to the Bankr /wallet/sign API. (#473)

Full Changelog: ProjectOpenSea/opensea-devtools@wallet-adapters-v0.3.1...wallet-adapters-v0.3.2

v0.3.1

Choose a tag to compare

@ryanio ryanio released this 17 Jun 00:10

What's Changed

  • Make RPC_URL optional for signing-only workflows. The factory and the private-key adapter no longer require RPC_URL when an adapter is only used to sign (not broadcast) transactions, so a key configured purely for signing no longer fails to initialize. A read provider is created lazily, only when a chain operation actually needs one. (#436)

Full Changelog: ProjectOpenSea/opensea-devtools@wallet-adapters-v0.3.0...wallet-adapters-v0.3.1