Wallet integration
For wallet developers. The signing surface the app uses, how a wallet gets listed, how the signed envelope is checked, and what Ledger and WalletConnect do.
The app never sees a wallet's secret key. It builds an unsigned transaction, hands it to the connected wallet, checks the signed envelope that comes back, and only then submits it.
That handoff goes through Stellar Wallets Kit
(@creit.tech/stellar-wallets-kit, pinned at 2.7.1), wired in src/lib/wallet/kit.ts.
What the app calls
The app's Connector interface (src/lib/wallet/connector.ts) signs whole transactions only:
| Connector method | Kit call |
|---|---|
connect | authModal, the wallet picker |
getPublicKey | getAddress, the address the kit stored |
signTransaction | signTransaction with the network passphrase and the address |
disconnect | The app's own teardown (see below) |
The kit is also initialized, themed and switched between networks (init, setTheme,
setNetwork). The app never calls signAuthEntry or signMessage. Every Soroban call the app
builds is authorized by the source account, so the envelope signature covers it.
The requirement on a wallet is the SEP-43 transaction-signing surface: return the address, and sign a transaction XDR for a given network passphrase. Nothing app-specific is needed.
The signed envelope is checked
Every guard the app runs before signing (the contract allow-list, the authorization-tree checks,
the revoke and transfer shapes, the fee caps) runs on the transaction the app built. The bytes that
reach the network are whatever the wallet returns, so verifySignedEnvelope
(src/lib/wallet/signed-envelope.ts) refuses to pass them on unless:
- the wallet returned a non-empty string that parses as an envelope for the same network,
- it is the same kind of envelope (a fee bump only when a fee bump was asked for),
- its transaction hash equals the hash of the transaction the app built, and
- it carries a signature from the connected key, under that key's hint, that verifies over that hash.
The signer reported onward is the key whose signature verified, never an address the wallet echoes back. The secret-key connector runs the same check on its own signature.
How a wallet gets listed
The picker's module list is built in buildModules:
const modules = [...defaultModules(), new LedgerModule()];
// plus WalletConnect when NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is setdefaultModules() in kit 2.7.1 returns fifteen wallets, in this order:
| # | Wallet | Product id |
|---|---|---|
| 1 | Albedo | albedo |
| 2 | Freighter | freighter |
| 3 | Fordefi | fordefi |
| 4 | Rabet | rabet |
| 5 | xBull | xbull |
| 6 | LOBSTR | lobstr |
| 7 | Hana Wallet | hana |
| 8 | Klever Wallet | klever |
| 9 | OneKey Wallet | onekey |
| 10 | Bitget Wallet | BitgetWallet |
| 11 | Cactus Link | cactuslink |
| 12 | D'CENT Wallet | dcent |
| 13 | Scopuly | scopuly |
| 14 | MetaMask | metamask |
| 15 | GHOSTSIG | ghostsig |
A wallet that ships a module in the kit's defaults appears once the app moves to a kit version that includes it. There is nothing to register on the app's side.
Two modules are added on top:
- Ledger (
LEDGER), over WebUSB, always. It needs a browserBufferglobal, which the app adds only when none exists. - WalletConnect (
wallet_connect), only whenNEXT_PUBLIC_WALLETCONNECT_PROJECT_IDis set.
The picker shows 16 entries without WalletConnect and 17 with it. The kit's Trezor and HOT Wallet
modules are not in defaultModules() and are not added, so they do not appear.
Wallets that are not installed stay in the list with an install link, so the list is predictable. The picker's colors follow the page's light or dark theme.
Ledger
The app signs on a Ledger with nonBlindTx: true, so the device parses and shows the operations and
the merge destination instead of signing a 32-byte hash. There is no fallback to hash signing.
Device refusals become messages a user can act on (src/lib/wallet/ledger-errors.ts):
| Device answer | Message |
|---|---|
| Rejected on the device | You rejected the transaction on your Ledger |
| Hash signing not enabled | Update the Stellar app on your Ledger and try again |
| Transaction too large for the device | Close this account with another wallet |
| Transaction not parseable | Update the Stellar app on the device, or close this account with another wallet |
A transaction too large for the device to show cannot be signed on a Ledger.
WalletConnect
The picker lists WalletConnect through a lightweight stand-in that needs no network. The real module, which opens the relay connection and starts Reown AppKit, is built only when WalletConnect is used: when you pick it, when you sign with a restored WalletConnect session, or when you disconnect or close a restored session, which has to go through the relay. Restoring a saved session after a reload reads the stored address and does not start it.
realWalletConnect in src/lib/wallet/kit.ts passes appKitOptions: { features: { analytics: false } } and signClientOptions: { telemetryEnabled: false }, so AppKit reports no optional
analytics event and the sign client keeps no telemetry log. AppKit still queues its mandatory
events (the initialization and a successful connection, with the page URL) and sends them to
pulse.walletconnect.org with navigator.sendBeacon. The Content Security Policy leaves the
pulse.walletconnect.* hosts out of connect-src, so the browser blocks those requests. The
security model lists every
host the browser contacts.
Picking WalletConnect waits up to 15 seconds for the relay handshake and then fails with WalletConnect could not reach its relay. The chain is the public or test network to match the app; switching the app's network updates the allowed chain.
Signing for someone else on /sign
A co-signer on /sign signs through withScopedKitSession: the wallet picked for that one
signature never becomes the app's connected wallet. The session that existed before is put back
afterwards, or the kit is disconnected when there was none, whether the signature succeeded or not.
A teardown failure is shown to the co-signer and never replaces the signing outcome.
Session lifetime
The active connector lives in module scope, not in the persisted store, because a pasted-seed connector holds the seed and must never be serialized.
That state survives client-side navigation, so a connection made on /demolish stays usable on
/allowances. A hard reload loses it, and only a wallet-kit connection can be restored afterwards,
from the kit's own stored selection and address.
Disconnecting clears the connector and the display store first, then tears down the kit: the selected wallet is disconnected and every WalletConnect session is closed through the sign client. A store-only disconnect would leave the extension authorizing the site, and a later connect could silently reuse that session. A wallet that refuses the teardown is reported to you, and the app is disconnected either way.
Adding a wallet in your own deployment
When you run your own copy, you can add a custom module to buildModules, so a wallet that is not
yet in the kit's defaults can be listed by your deployment.
Related
SEP support for the full list of proposals the app implements.