Developers · clasp-connect
Add Clasp to your dapp
Clasp is a wallet your users reach in the tab they already have open — no extension, no app store, nothing for them to install. Adding it is one more connector in a wagmi config you already have, or a small SDK if you are not on wagmi. One package covers both the XRP Ledger and XRPL EVM. Everything an integration needs is on this page: install, both entry points, the wire protocol, what the user sees before they sign, and the parts that bite.
01
An origin, not an extension
Clasp does not inject a provider into your page. It cannot — and the reason it cannot is the reason there is nothing for your users to install.
A passkey is welded to the site that made it. The credential names one rpID — claspwallet.com — and a browser will only let that origin assert it. So no build of Clasp could run inside your page and still reach a key, and no extension could hold one either. The SDK opens a Clasp window instead, and the two pages talk over postMessage.
Two things fall out of that. Your users install nothing: the wallet is a URL, and a passkey they already carry is the whole sign-in. And the approval screen is a top-level page on Clasp’s origin, so your page can’t read it, restyle it or click it — and nor can anything you have embedded in your page.
The whole conversation
dapp page ──window.open──▶ {walletUrl}/connect (popup, top-level, Clasp origin)
◀──────postMessage──── ready
───────postMessage───▶ request { connect | sign_and_submit
| evm_connect | evm_personal_sign
| evm_sign_and_submit | evm_sign_typed_data }
[ review UI · passkey touch · sign · submit ]
◀──────postMessage──── response { result | error }
popup LINGERS (60s), then closes itself
───────window.open───▶ a follow-up request NAVIGATES the same named
window — no new popup, no popup blockerOne request per popup document. The first message the popup receives locks it to that origin: every reply goes back there, anything from anywhere else is ignored, and the origin printed on the approval screen is the one the browser stamped — never a string you sent.
The popup lingers, and you never close it
Clasp answers your request and then stays open for about 60 seconds on its terminal view. It closes itself after that.
The SDK never calls popup.close(). Neither should your cleanup.
A chained second request — approve, then supply, fired from an async continuation with no click behind it — reuses that same named window. window.open on a window that is already open is a navigation, and blockers permit navigations; what they block is creating a window without a user gesture.
Every navigation is a fresh document with a fresh lock, so one request per popup still holds. Nothing carries over between them.
A rejection closes the window at once. That one is the user’s own hand.
Tidying either half of that breaks it, and it breaks quietly. A done view that closed itself, or a popup.close() in your own cleanup, leaves the second request in a chain with no window to navigate — so it tries to open one, outside a gesture, and the blocker takes it. The first production integration hit exactly that: the chained call never reached the wallet, and six approvals that were already mined on chain came back to the dapp as rejections.
02
Install
One package, three entry points, and nothing else installed behind it.
npm
npm i clasp-connect # vanilla SDK — zero dependencies
npm i clasp-connect @wagmi/core viem # + the wagmi connectorclasp-connect 0.3.1, MIT. Zero runtime dependencies — nothing is pulled in behind it.
@wagmi/core and viem are optional peers, and they are types-only: the connector imports their types and ships no code from either. Any @wagmi/core in ^2.9.0 || ^3.0.0 works, with viem 2.x. Leave both out if you only want the vanilla SDK.
ESM only, and side-effect free, so a bundler drops whatever you never import. Node >=20.19 to build against it.
Three entry points, all from the one install:
| Import | What it is |
|---|---|
| clasp-connect | The vanilla SDK — the ClaspConnect client and its popup transport, both chains. |
| clasp-connect/wagmi | The connector, claspConnector(), and the two chain objects below. |
| clasp-connect/rainbowkit | claspWallet — the same connector, wrapped for a wallet list. |
Clasp serves two chains, and both ship as wagmi chain objects you can hand straight to a config:
| Chain id | Network | Export | RPC | Explorer |
|---|---|---|---|---|
| 1440000 | Mainnet | xrplevm | https://rpc.xrplevm.org | https://explorer.xrplevm.org |
| 1449000 | Testnet | xrplevmTestnet | https://rpc.testnet.xrplevm.org | https://explorer.testnet.xrplevm.org |
Both objects leave contracts.multicall3 undefined on purpose. Multicall3 isn’t deployed on XRPL EVM, and saying so is what makes viem read with one eth_call at a time rather than batching into a contract that isn’t there.
03
wagmi and RainbowKit
If your dapp already runs on wagmi, Clasp is one connector in the config you have. There is no provider to detect and no extension to wait for.
wagmi config
import { createConfig, http } from "wagmi";
import { claspConnector, xrplevm, xrplevmTestnet } from "clasp-connect/wagmi";
export const config = createConfig({
chains: [xrplevm, xrplevmTestnet],
transports: { [xrplevm.id]: http(), [xrplevmTestnet.id]: http() },
connectors: [claspConnector()],
});That’s the integration. useConnect, useAccount, useSendTransaction, useWriteContract and useSignTypedData all work against it, and so does SIWE through signMessage.
Reads never reach the connector at all. useBalance, useReadContract and the rest go through config.getClient(), which is built from your chains and transports — so you already own the whole read surface, and the connector’s job is accounts and signing.
The two chain literals ship with the package: xrplevm is 1440000 and xrplevmTestnet is 1449000. Both leave multicall3 undefined on purpose, because it isn’t deployed on XRPL EVM — viem then reads with individual eth_calls instead of batching against a contract that isn’t there.
RainbowKit
rainbowkit
import { connectorsForWallets } from "@rainbow-me/rainbowkit";
import { claspWallet } from "clasp-connect/rainbowkit";
const connectors = connectorsForWallets(
[{ groupName: "Recommended", wallets: [claspWallet] }],
{ appName: "My dapp", projectId: "…" },
);claspWallet() returns a descriptor — id, name, rdns, iconUrl, iconBackground, installed and createConnector — and the wallet list, the chain switcher and the SIWE adapter work against it unchanged.
installed: true is the field that matters. Clasp is a website, not an extension, and without that flag RainbowKit sends people into a download-or-scan-a-QR flow for something there is nothing to download.
RainbowKit isn’t a dependency of the SDK, or even a peer. The descriptor is a plain object matching their Wallet shape, and a type test in the Clasp repo compiles it against the real package, so the two can’t drift apart quietly.
Three things the config doesn’t show
claspConnector() takes no wallet URL, and that is deliberate: claspwallet.com is a constant inside the package, and the only option is timeoutMs. A passkey is welded to the hostname, so a different host is a different, empty wallet — and it fails quietly rather than loudly. Before you ship has the long version.
What persists is { address, chainId }, under the key clasp-connect.v1.evm, in your origin’s wagmi storage. No secret ever leaves the wallet, and the wallet keeps nothing between popups — so disconnecting is local by construction.
reconnect, isAuthorized and getProvider never open a popup. wagmi calls all three on every page load with no click behind them, so restoring a session on load is safe; only an interactive connect opens a window.
page load, and a click
import { connect, getAccount, reconnect } from "@wagmi/core";
// Page load: restores { address, chainId } from storage. No popup.
await reconnect(config);
const { address } = getAccount(config);
// From a click, and only from a click — this opens the Clasp window.
await connect(config, { connector: config.connectors[0] });Those are @wagmi/core actions. The React hooks are the same calls with a subscription attached, so the shape holds either way.
04
The vanilla SDK
The XRP Ledger has no wagmi, so this is how you reach it — and it’s the whole wire, if you’d rather not take a framework at all. One class, six methods, no dependencies.
One client, both ledgers
One instance speaks to both chains. Every method opens a Clasp-owned popup, and the approval happens in there — never on your page, and never with a key your page can reach.
the client
import { ClaspConnect } from "clasp-connect";
const clasp = new ClaspConnect({
walletUrl: "https://claspwallet.com",
});walletUrl is the one thing you have to pass, and https://claspwallet.com is the only value for it. A Clasp passkey is welded to that hostname, so another host is not a mirror — it’s a different wallet, holding none of this user’s money.
timeoutMs is optional and defaults to five minutes. A person is reading a review at the other end of it, so don’t shorten it to a network timeout.
One request in flight per instance — a second one rejects with busy. And every call has to start inside a click, or the browser blocks the popup before Clasp sees it.
The XRPL pair
Two methods. connect shares an address; signAndSubmit renders a transaction, takes a fresh touch, and submits it. Connecting first is optional — a payment flow can call signAndSubmit cold.
XRPL — connect and pay
// Ask for an address. Pass a challenge and a signature over it
// comes back too, so your backend can check the user holds the key.
const { address, publicKey, proof } = await clasp.connect({
challenge: crypto.randomUUID(),
});
// Pay. Passing address is optional; when you do, the approving
// passkey has to derive that exact account or nothing gets signed.
const { hash, engineResult } = await clasp.signAndSubmit({
address,
tx: {
TransactionType: "Payment",
Destination: "rEzuT5Grm7tyFYP4QqX58pcvfCaJjKzcnG",
Amount: "1000000", // drops, so this is 1 XRP
},
});
// engineResult is "tesSUCCESS" — anything else rejected.connect resolves { address, publicKey, network, proof? }. The proof is there only if you sent a challenge, and it’s an ed25519 signature your backend can check on its own.
signAndSubmit resolves only on tesSUCCESS. Any other engine result rejects instead, carrying the code.
The wallet fills Account, Fee, Sequence and LastLedgerSequence itself, and refuses a request that supplies any of them. That refusal is what makes the review worth reading: a site never sets the fee a user pays.
On the XRP Ledger, Clasp signs Payment and TrustSet and nothing else. Anything else is refused rather than warned about — §05 below, what the user sees, has the rest.
The EVM four
Four methods, each carrying an EIP-1193 payload verbatim. A dapp that already talks to an injected wallet can hand the same values straight through.
XRPL EVM — connect, sign, send
const { address, chainId } = await clasp.evmConnect({
network: "mainnet",
});
// messageHex is personal_sign params[0] verbatim: the message as
// hex bytes. siweMessage is yours — any ERC-4361 builder will do.
const utf8hex = (s: string) =>
"0x" +
[...new TextEncoder().encode(s)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const { signature } = await clasp.evmPersonalSign({
address,
messageHex: utf8hex(siweMessage(location.host, address)),
});
// eth_sendTransaction, minus every field the wallet fills itself.
const { hash } = await clasp.evmSignAndSubmit({
address,
tx: { to: "0x…", value: "0xde0b6b3a7640000" }, // hex wei
});XRPL EVM — typed data
// typedDataJson is eth_signTypedData_v4 params[1] verbatim: the
// EIP-712 payload as a JSON string. Here it is an ERC-2612 permit.
const { signature } = await clasp.evmSignTypedData({
address,
typedDataJson: JSON.stringify({
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
domain: { name: "USDC", version: "1", chainId, verifyingContract: token },
message: {
owner: address,
spender,
value: "12500000",
nonce: 0,
deadline: "1790000000",
},
}),
});evmConnect resolves { address, chainId, network }, with the address EIP-55 checksummed by the wallet. The two signing methods both resolve { signature } — 65 bytes, hex.
evmSignAndSubmit resolves at broadcast, with the hash — the same contract as every other EVM wallet, so wait for your own receipt. The popup carries on confirming after your promise has settled; that’s its own display, not your answer.
tx carries to, value and data, and nothing else. Gas, nonce, chain id and from belong to the wallet — supplying one is refused, never overridden.
A sign-in message has to name the host that’s asking, byte for byte, port included, or the wallet refuses it. Sending arbitrary calldata is fine, though — §05 below covers how the popup renders it.
05
What the user sees
Between your call and a signature there’s a review, and what the popup can render decides what it will sign. The two ledgers answer that differently, and the split is deliberate.
XRPL: a whitelist
On the XRP Ledger, Clasp signs two transaction types and nothing else. If the review can’t render a field’s meaning, the request comes back refused — not shown as a hex blob with an Approve button under it. Send anything outside this table and you get a refusal, not a warning.
| Type | Fields you may send | Refused |
|---|---|---|
| Payment | Destination, Amount — a drops string or {currency, issuer, value} — and an optional DestinationTag. | SendMax, Paths and Flags. No partial payments, no cross-currency sends. |
| TrustSet | LimitAmount, as {currency, issuer, value}. | Flags. The wallet sets tfSetNoRipple itself. |
The structural check lives in one file, sdk/src/protocol.ts, and both the SDK and the popup run it — the popup trusts nothing the SDK claims to have validated. This side stays a whitelist until the XRPL review engine is built. Widening it is a design decision, not a patch.
XRPL EVM: open signing
The EVM side was a two-shape whitelist too, until August 2026. It was opened on purpose: the first lending app we integrated had nine write shapes and the old rule could sign none of them. Clasp now works with the whole XRPL EVM ecosystem the way MetaMask and Rabby do.
The popup signs your exact bytes, and the review is an interpretation of those bytes at the deepest fidelity that settles. Nothing is re-encoded between what the user read and what gets signed.
| Tier | What decides it | What the user sees |
|---|---|---|
| A | A plain native send, or canonical calldata for one of six standard selectors — transfer, approve, transferFrom, setApprovalForAll, and safeTransferFrom on both ERC-721 and ERC-1155. A non-canonical encoding rides the tiers below instead of being refused. | The operation first class: amounts at their real decimals, the spender, recipient or operator, the token id, the ERC-1155 quantity, and Unlimited spelled out for a max-uint approval. The three ERC-20 shapes only read as token operations once the contract vouches for itself — symbol and decimals read on chain — or it is the configured USDC, whose shipped metadata wins. |
| B | The calldata matched a function in the contract’s Blockscout-verified source. One eip1167 proxy hop, a 2.5 second budget. A miss, or an explorer that doesn’t answer in time, falls to C. | The function name and a typed argument tree, with its provenance stated on the row. The names are the explorer’s claim; every amount and address is decoded locally from the calldata bytes. |
| C | Nothing above settled. | The selector, the byte length, and the exact bytes whole — plus which of three things happened: the contract has no verified source, the explorer couldn’t be reached, or the source is verified and this call matches nothing in it. A standing warning sits beside it. It never refuses. |
A call that carries XRP alongside its calldata is legal at every tier, and the XRP rides as its own row on the review.
Two rules that shape how your dapp behaves
A request never changes tier once it’s on screen. The review settles once, and Approve stays disabled until it does. A slow explorer costs your user a moment, never a surprise.
Warnings sit beside an enabled Approve. They never block. An unlimited approval, a zero-address destination, an approval for all, calldata nothing could decode, and a call the network says will revert all warn and stay signable. If you expect your call to warn, that isn’t a bug report.
Messages and typed data
A message signature follows the same idea. Clean UTF-8 renders as text; bytes that aren’t valid UTF-8, or that hide control or bidi characters, render as the exact hex behind a warning — the hex is what gets signed, so showing it whole is the honest review, and unreadable is never a refusal. Messages cap at 8 KiB. Typed data renders as a field tree, and a domain naming a chain the user isn’t connected to warns rather than blocks.
The two things Clasp still refuses
Opening the tier ladder didn’t mean everything signs. Two refusals survive on the EVM side, and neither of them is about calldata. A method Clasp simply doesn’t implement is a different thing — the reference below says what’s on the wire, and the connector answers the rest by naming the method that replaces it.
eth_sign — refused at the connector with EIP-1193 4200, before a popup opens. It signs a bare hash with no context, which is the one thing no review can render. Use personal_sign.
SIWE domain forgery — a sign-in message’s domain must byte-equal the host the request came from, port included. A message claiming example.com sent from app.example.com comes back unsupported_message, naming both hosts. So does a message that is SIWE-shaped but doesn’t parse as SIWE — it never falls back to a plain-text render, because a backend lenient enough to accept that would hold a signature the user never read as a sign-in.
06
Method and error reference
Six methods on the wire, eleven error codes, and one rule about 4001 that will save you an evening.
The six methods
What each one resolves to, and what the person on the other side does to make that happen. The vanilla SDK exposes all six; the wagmi connector reaches four of them for you.
| method | resolves to | what the user does |
|---|---|---|
| connect | { address, publicKey, network, proof? } — the XRPL r… address and its ed25519 public key. The proof arrives only if you sent a challenge. | Sees your origin, touches once, taps Share. |
| sign_and_submit | { hash, engineResult } — only tesSUCCESS resolves. | Reads the decoded Payment or TrustSet, then touches. The wallet fills the fee itself. |
| evm_connect | { address, chainId, network } — EIP-55 checksummed, on chain 1440000 or 1449000. | The same two steps as connect. No proof here — SIWE does that job on this side. |
| evm_personal_sign | { signature } — 65 bytes, ECDSA, ERC-191. | Reads the message as text, as a SIWE sign-in, or as the exact hex. Then touches. |
| evm_sign_and_submit | { hash } — at broadcast, not at the receipt. | Reads the tiered review, then touches. The gas is the wallet's, never yours. |
| evm_sign_typed_data | { signature } — 65 bytes, over the EIP-712 struct hash. | Reads the domain and the message as a field tree, then touches. Nothing gets sealed. |
evm_sign_and_submit resolves the moment the transaction is broadcast, carrying its hash — the contract every EVM wallet keeps, and what lets you chain a second write off your own receipt wait. The popup keeps confirming after you already have the hash, because the Seal it presses is about finality and the response is not. A revert after broadcast reaches you the way it does with any other wallet: through the receipt.
What the connector answers, and how
Through clasp-connect/wagmi you rarely touch the wire directly. Four things can happen to an EIP-1193 call, and which one is not a detail — three of them never open a window.
| answered | methods | what that means |
|---|---|---|
| locally | eth_chainId, net_version, eth_accounts, eth_requestAccounts, wallet_switchEthereumChain | From the connector's own state. No network call, no window. |
| popup | personal_sign, eth_sendTransaction, eth_signTypedData_v4 | One window, one approval, one touch. The FIRST one has to start inside a click; a follow-up reuses the window that is already open. |
| forwarded | eth_call, eth_getBalance, eth_estimateGas, eth_getLogs, eth_getTransactionReceipt, eth_getTransactionByHash, eth_getTransactionCount, eth_blockNumber, eth_getBlockByNumber, eth_getBlockByHash, eth_getCode, eth_getStorageAt, eth_gasPrice, eth_maxPriorityFeePerGas, eth_feeHistory | Fifteen read-only calls, POSTed to your configured chain's RPC from your own page. |
| refused | eth_sign, eth_signTypedData, eth_signTypedData_v3, eth_signTransaction, and anything unrecognised | EIP-1193 4200, with a message naming what to use instead. |
The local answers matter more than they look. viem asks for the chain id before every signing operation, and answering that from memory is what keeps your click alive for the window that opens right after it. The forwarded ones are compatibility, not the normal path — wagmi’s own reads go through config.transports, and these fifteen exist for code that takes the provider directly. Note that they leave from your page, so your CSP governs the fetch, not ours. And eth_sign is refused on purpose: it is blind signing, and it is one of the two things Clasp will not do on this chain. §05 has the other one, and what the review shows instead.
Error codes
Every failure arrives as one of eleven codes. The vanilla SDK throws a ClaspError carrying the code verbatim; the wagmi connector translates it to the EIP-1193 number in the second column.
| code | eip-1193 | when it happens |
|---|---|---|
| user_rejected | 4001 | The user declined, or closed the window. The SDK raises it when the popup disappears. |
| unsupported_tx | 4200 | On XRPL, a type or field outside the whitelist. On EVM, structure only — a malformed shape, or a field the wallet fills. Arbitrary calldata is never this. |
| unsupported_message | 4200 | A SIWE message whose domain isn't the asking origin's host, one that is SIWE-shaped but malformed, or a message over 8 KiB. Refused unsigned. Binary bytes are not this — they render as hex and stay signable. |
| wrong_wallet | 4001 | The passkey that was touched derives a different address than the one you pinned. Nothing was signed. |
| network_unavailable | 4901 | The requested network isn't enabled in that build. Both families. |
| tx_failed | -32003 | Validated with a failing engine result, or the EVM receipt reverted. The fee was spent, and the message says so. |
| not_confirmed | -32002 | Expired without validating on XRPL, or an uncertain broadcast on EVM. The message says which one, and what may still land. |
| bad_request | -32602 | A malformed envelope or params. The request never reached a review screen. |
| timeout | -32002 | The SDK's own deadline elapsed — five minutes unless you set timeoutMs. If the window never answered at all, the message names your COOP header. |
| busy | -32002 | That client already has a request in flight. One at a time, per instance. |
| popup_blocked | -32002 | The browser blocked the window. Nearly always: the call didn't start in a click handler. |
Only a real no wears 4001
viem replaces any 4001’s message with its own line — “User rejected the request.” — so whatever the wallet wrote is gone by the time your code reads it. That is why timeout, busy and popup_blocked map to -32002 instead. None of the three is a user act, and dressing them as one is how the first production integration reported six mined approvals as six rejections, for an evening. user_rejected and wrong_wallet keep 4001, because those two really are the person.
The same trap, one method over: every eth_sendTransaction failure throws 4200, including a malformed one. -32602 would be the truer code, but viem retries a -32602 from eth_sendTransaction as wallet_sendTransaction — exactly as it does a -32601 — so the purer code buys a ghost retry and a second, more confusing error. A malformed transaction therefore comes back as 4200, with a message that opens Malformed transaction: and then names the reason.
A -32002 about a blocked window means your call didn’t start inside a click handler. Nothing was signed, and retrying from a real click is the whole fix.
A timeout where the window never answered is almost always your own Cross-Origin-Opener-Policy — the message says so, and §07 says what to set instead.
The one code never to retry is 4001. The user already answered.
07 · The parts that bite
Before you ship
Five things cost an afternoon each. Read them before you start debugging, not after.
A COOP header on your own site kills the transport.
Clasp is a popup, and a popup answers through window.opener. Shipping Cross-Origin-Opener-Policy: same-origin on your pages severs that relationship, so every request you send hangs until it times out. Use same-origin-allow-popups, or leave the header off. Clasp serves its own /connect route with unsafe-none for the mirror-image reason: both ends have to allow it. This is the most common way an integration fails.
Open from a click, and never close the window yourself.
window.open outside a user gesture is blocked. A chained request — approve, then supply — fires from an async continuation, where a fresh window cannot be opened at all. It works because the popup lingers on its finished view and the SDK never closes it, so the second request navigates the window that is still there: blockers permit navigating an open window and forbid creating one without a gesture. Call close() in your own cleanup and every chained flow you have stops working.
The wallet URL is a constant, not a setting.
A passkey welds to the hostname it was created on. Point the SDK at a different host and your user gets a different, empty wallet — no error, no warning, just an account with none of their money in it. That is why claspConnector() has no URL option: there is one wallet origin and it is not configurable.
Sending a gas or nonce field is a refusal, not an override.
from, gas, gasPrice, maxFeePerGas, maxPriorityFeePerGas, nonce, chainId, type and accessList belong to the wallet. Supply one and the request is refused by name — Clasp never quietly replaces a value you sent. It estimates gas itself, and when the estimate says the call will revert it says so, shows the fee as an upper bound, and waits for a second press, so nothing revert-predicted is signed before that warning has been read. The wagmi connector drops these fields for you; on the raw wire they are yours to leave out.
eth_sendTransaction resolves at broadcast, not at the receipt.
You get the hash the moment the transaction reaches the network, which is the contract everywhere in the EVM world; wait for the receipt yourself if you need one. The popup carries on confirming after your promise has settled, because the seal presses only on a successful receipt. Holding the answer back until the receipt is what once made every auto-chaining dapp time out, so do not read a resolved promise as mined.
All of it ships inside clasp-connect on npm, README and wire types included. If a line on this page stops matching the package, the package is right.
One connector, both ledgers.
Your users arrive with a wallet they already have open, in the tab they are already in. Nothing to install, on either side.
How the wallet worksclasp-connect · MIT