diff --git a/.changeset/networks87.md b/.changeset/networks87.md
new file mode 100644
index 00000000..44d721b7
--- /dev/null
+++ b/.changeset/networks87.md
@@ -0,0 +1,22 @@
+---
+"@clip-wallet/core": minor
+"@clip-wallet/config": minor
+"@clip-wallet/vault": minor
+"@clip-wallet/1mask": minor
+"@clip-wallet/engine": minor
+"@clip-wallet/extension-kit": minor
+"@clip-wallet/ui": minor
+"@clip-wallet/chains-evm": minor
+"@clip-wallet/chains-substrate": minor
+"@clip-wallet/chains-cosmos": minor
+"@clip-wallet/chains-tron": minor
+"@clip-wallet/chains-xrpl": minor
+"@clip-wallet/chains-antelope": minor
+"@clip-wallet/chains-multiversx": minor
+"@clip-wallet/chains-icp": minor
+"@clip-wallet/chains-stacks": minor
+"@clip-wallet/chains-fuel": minor
+"@clip-wallet/chains-bitcoincash": minor
+---
+
+Every CLPR network with a self-custodial key model: twelve new families (Cosmos SDK chains Osmosis, dYdX, ZIGChain, Provenance, THORChain and Initia; TRON; XRP Ledger; Vaulta, Telos and XPR Network; MultiversX; Internet Computer; Stacks; Fuel; Bitcoin Cash), Chainflip in chains-substrate, STRATO and Arc mainnet in chains-evm, and their dapp providers where a standard lets the wallet appear as itself.
diff --git a/.gitleaksignore b/.gitleaksignore
index da21e04e..b2250f6c 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -102,3 +102,28 @@ services/backup/test/oidc-fixtures.ts:jwt:20
services/backup/test/oidc-fixtures.ts:jwt:21
services/backup/test/oidc-fixtures.ts:jwt:22
services/backup/test/oidc-fixtures.ts:jwt:23
+
+# packages/chains-antelope/test/fixtures.ts: an Antelope public key (PUB_K1) in a test fixture
+e5aaffa83cc45b83045fa3ab2b1c4514f8aa83f8:packages/chains-antelope/test/fixtures.ts:generic-api-key:53
+packages/chains-antelope/test/fixtures.ts:generic-api-key:53
+# packages/chains-antelope/test/serializer.test.ts: an Antelope public key (PUB_K1) in a serializer test
+e5aaffa83cc45b83045fa3ab2b1c4514f8aa83f8:packages/chains-antelope/test/serializer.test.ts:generic-api-key:44
+packages/chains-antelope/test/serializer.test.ts:generic-api-key:44
+# packages/chains-xrpl/test/codec.test.ts: XRPL NFToken and offer ids (public ledger object ids) in codec tests
+1fb0d0f6ce12beeec2764035960fdba226889570:packages/chains-xrpl/test/codec.test.ts:generic-api-key:27
+1fb0d0f6ce12beeec2764035960fdba226889570:packages/chains-xrpl/test/codec.test.ts:generic-api-key:28
+packages/chains-xrpl/test/codec.test.ts:generic-api-key:27
+packages/chains-xrpl/test/codec.test.ts:generic-api-key:28
+# packages/chains-xrpl/test/xrpl.test.ts: an XRPL NFToken offer id (a public ledger object id)
+1fb0d0f6ce12beeec2764035960fdba226889570:packages/chains-xrpl/test/xrpl.test.ts:generic-api-key:329
+packages/chains-xrpl/test/xrpl.test.ts:generic-api-key:329
+# packages/1mask/src/shared/cosmos.ts: Initia chain feature names (eth key sign, eth secp256k1 initia)
+b96d0e3f552b0471e9f435f9c7d4daa424d978dd:packages/1mask/src/shared/cosmos.ts:generic-api-key:102
+b96d0e3f552b0471e9f435f9c7d4daa424d978dd:packages/1mask/src/shared/cosmos.ts:generic-api-key:103
+packages/1mask/src/shared/cosmos.ts:generic-api-key:102
+packages/1mask/src/shared/cosmos.ts:generic-api-key:103
+# packages/chains-cosmos/test/helpers.ts: a secp256k1 public key (base64) in a test helper
+b96d0e3f552b0471e9f435f9c7d4daa424d978dd:packages/chains-cosmos/test/helpers.ts:generic-api-key:92
+packages/chains-cosmos/test/helpers.ts:generic-api-key:92
+# .gitleaksignore itself: the comment above quoted those feature names in the commit that added it
+ffcd993f6685732a06c3f4d751d75f77f8c1a2dc:.gitleaksignore:generic-api-key:120
diff --git a/.harness/spec.md b/.harness/spec.md
index 678eca08..356b5001 100644
--- a/.harness/spec.md
+++ b/.harness/spec.md
@@ -1,7 +1,7 @@
# Clip Wallet: product spec
Clip Wallet is a **non-custodial** wallet for every CLPR network. One recovery phrase, one set of accounts across
-14 network families, and money that moves to wherever an app needs it. It is also a **kit**: anyone can ship a wallet
+26 network families (85 of the 87 CLPR networks), and money that moves to wherever an app needs it. It is also a **kit**: anyone can ship a wallet
of their own on the same packages. Pre-release: **test networks only**.
## Principles
diff --git a/AGENTS.md b/AGENTS.md
index f13c98ce..74083ef3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md: Clip Wallet
-Clip Wallet is a non-custodial wallet for every CLPR network: 14 network families, 1Mask for every dapp, decoded
+Clip Wallet is a non-custodial wallet for the CLPR networks (85 of 87; Canton and Mixin have no self-custodial model, docs/r1/networks87.md): 26 network families, 1Mask for every dapp, decoded
approvals, a security floor, features (staking, swaps, on-ramps, Secure Trade), social (contacts, Clip handles,
notifications, Discover), sandboxed Clip Plugins, and route-and-fund / settle-on-Hedera on CLPRouter. It is also a
kit: the `@clip-wallet/*` packages, `@clip-wallet/extension-kit`, `@clip-wallet/desktop-kit`, `@clip-wallet/mobile-kit`
@@ -32,8 +32,9 @@ Release work (Docker for the last one): `pnpm --filter @clip-wallet/extension pa
```
packages/core shared types (the contract); additive changes only
packages/config clip.config.ts schema (zod): identity, theme, networks, languages, app ids, scheme, route, services, mainnet; /node loader
-packages/vault phrase, derivation for 14 families, encryption, approval-bound signing, passkey unlock
+packages/vault phrase, derivation for 26 families, encryption, approval-bound signing, passkey unlock
packages/chains-* evm hedera solana bitcoin sui aptos cardano substrate starknet ton near stellar tezos algorand
+ cosmos (+provenance thorchain initia) tron xrpl antelope multiversx icp stacks fuel bitcoincash
packages/1mask dapp connectors for all families + WalletConnect; announces the wallet's identity
packages/engine environment-free orchestration (approvals, portfolio, catalog, wiring) shared by extension and mobile
packages/extension-kit the browser extension as a library: background, pages, WXT config (clipWallet()), security floor
diff --git a/README.md b/README.md
index 5701a83a..fca0f46e 100644
--- a/README.md
+++ b/README.md
@@ -10,15 +10,16 @@
One wallet for every network, and the networks stay out of your way.
- A non-custodial wallet for 14 network families on one recovery phrase. It works with every dapp as it is, and it
- decodes every request into plain words before you sign. It is also an open kit for launching your own wallet.
+ A non-custodial wallet for 85 of the 87 CLPR networks, 26 network families on one recovery phrase. It works with
+ dapps as they are, and it decodes every request into plain words before you sign. It is also an open kit for
+ launching your own wallet.
-
+
@@ -59,13 +60,16 @@ mistake loses money. One example is an address that can receive on several netwo
-- **One seed, 14 families.** EVM (Ethereum, Base, Arbitrum, Optimism, Hedera EVM and any EVM chain by id), Hedera,
- Solana, Bitcoin, Sui, Aptos, Cardano, Polkadot SDK, Starknet, TON, NEAR, Stellar, Tezos and Algorand, all from one
- BIP-39 phrase.
-- **Works with every dapp, with no changes to the dapp.** 1Mask speaks each ecosystem's own standard: EIP-1193 with
- EIP-6963, the Wallet Standard (Solana, Sui, Aptos, Bitcoin), CIP-30, `injectedWeb3`, TON Connect, NEAR Wallet
- Selector, SEP-43, Beacon, ARC-1, and WalletConnect v2. It is tested against stock wallet pickers and live testnet
- dapps.
+- **One seed, 26 families.** EVM (Ethereum, Base, Arbitrum, Optimism, Hedera EVM and any EVM chain by id), Hedera,
+ Solana, Bitcoin, Sui, Aptos, Cardano, Polkadot SDK (with Chainflip), Starknet, TON, NEAR, Stellar, Tezos, Algorand,
+ the Cosmos SDK chains (Osmosis, dYdX, ZIGChain, Provenance, THORChain, Initia), TRON, XRP Ledger, Stacks, Fuel,
+ Bitcoin Cash, MultiversX, Internet Computer and Antelope (Vaulta, Telos, XPR Network), all from one BIP-39 phrase:
+ 85 of the 87 CLPR networks and 93 mainnets ([the list](https://coldai.org/clip/docs/reference/networks.html)).
+- **Works with dapps as they are.** 1Mask speaks each ecosystem's own standard: EIP-1193 with EIP-6963, the Wallet
+ Standard (Solana, Sui, Aptos, Bitcoin, XRPL's XLS-72d), CIP-30, `injectedWeb3`, TON Connect, NEAR Wallet Selector,
+ SEP-43, Beacon, ARC-1, a Keplr-compatible Cosmos API, TIP-1193 with TIP-6963, SIP-030 with WBIP-004, the
+ FuelConnector, and WalletConnect v2 (including Bitcoin Cash's wc2-bch-bcr). It is tested against stock wallet pickers
+ and live testnet dapps.
- **Approval screens that decode what you sign.** Every request becomes a plain-words `DecodedRequest` that shows the
title, balance changes, fee, time and warnings. Unlimited approvals, permits, look-alike tokens and unknown contract
calls are called out. Requests the wallet can't read are **blocked by default**: no blind signing.
@@ -141,7 +145,9 @@ site gets its own session.
## Networks and dapp standards
-All 14 families are tested end to end on their public testnets with the real build, in two suites. The
+Clip supports 85 of the 87 CLPR networks: every one except Canton and Mixin, which have no model in which a
+self-custodial key wallet can hold funds ([why, and the full list](https://coldai.org/clip/docs/reference/networks.html)).
+The families are tested on their public testnets with the real build, in two suites. The
[dapp matrix](docs/r1/dapp-matrix.md) drives each ecosystem's own dapp library (**connect**, **sign** and verify, **send**
and confirm on chain, **approval** decoded). The [picker matrix](docs/r1/picker-matrix.md) drives the wallet pickers
dapps actually ship, plus 8 hosted testnet dapps.
@@ -162,6 +168,15 @@ dapps actually ship, plus 8 hosted testnet dapps.
| Stellar | Testnet | Stellar Wallets Kit module (SEP-43), SEP-53 | connect · sign · send · approval | Stellar Wallets Kit + module |
| Tezos | Shadownet | Beacon (TZIP-10) | connect · sign · send · approval | Beacon lists Clip; connecting from its modal is a known gap |
| Algorand | TestNet | use-wallet adapter (ARC-1, ARC-6) | connect · send · approval | use-wallet UI + `@clip-wallet/kit-modules/algorand` |
+| Cosmos SDK | osmo-test-5 | Keplr-compatible API at `window.clipwallet.cosmos` | connect · sign ¹ | cosmjs, with Clip's object in place of `window.keplr` |
+| TRON | Nile | TIP-1193 + TIP-6963 | connect · sign ¹ | TronWeb; the tronwallet adapters don't take TIP-6963 wallets yet |
+| XRP Ledger | Testnet | XLS-72d (Wallet Standard) | connect · send · approval (no message signing in XLS-72d) | Wallet Standard discovery |
+| Stacks | Testnet | SIP-030 + WBIP-004 | connect · sign · send · approval | `@stacks/connect` 8 |
+| Fuel | Testnet | FuelConnector | connect · sign ¹ | fuels-ts `Fuel` |
+| Chainflip (Polkadot SDK) | Perseverance | `injectedWeb3` | connect · sign · approval ¹ | `@polkadot/extension-dapp`, as lp.chainflip.io |
+| MultiversX | Devnet | best effort: sdk-dapp's undocumented custom-provider hook | connect · sign ¹ | sdk-dapp 5, where the dapp keeps `window.multiversx` |
+| Bitcoin Cash | Chipnet | WalletConnect (wc2-bch-bcr) | not run (needs a WalletConnect project id) | Cashonize-style WalletConnect dapps |
+| Internet Computer, Antelope | `icp:test`, Jungle4 | none: send and receive only | n/a | n/a |
¹ The remaining steps wait for testnet funds in the matrix wallet. Each approval says plainly why it can't go
ahead, for example "You don't have enough APT to pay the network fee". Bitcoin passed send and approval in an earlier
@@ -358,7 +373,7 @@ screens come from the fixture build. Details: [`packages/route`](packages/route)
| Path | What |
| --- | --- |
| [`packages/core`](packages/core) | The contract: `ChainModule`, `DecodedRequest`, assets, errors |
-| [`packages/vault`](packages/vault) | Phrase, derivation for 14 families, encryption, approval-bound signing, passkeys. The only package that touches keys |
+| [`packages/vault`](packages/vault) | Phrase, derivation for 26 families, encryption, approval-bound signing, passkeys. The only package that touches keys |
| [`packages/chains-*`](packages) | One `ChainModule` per family |
| [`packages/1mask`](packages/1mask) | Dapp connectors for every family, the router, WalletConnect |
| [`packages/engine`](packages/engine) | Orchestration shared by the extension, desktop and mobile |
@@ -389,10 +404,11 @@ screens come from the fixture build. Details: [`packages/route`](packages/route)
- **Optional services see ciphertext and hashes only.** The threat model is in
[`services/backup`](services/backup/README.md).
-**Audit status.** An internal review in October 2026 covered the vault, the approval path for all 14 families, 1Mask,
-WalletConnect, plugins, hardware signing, the services and the supply chain. It found 47 issues (2 critical, 5 high,
-18 medium, 22 low) and all of them are fixed, each with a regression test: there are no open findings
-([report](docs/audit/internal-audit-2026-10.md)). The **external audit comes before any mainnet build**.
+**Audit status.** An internal review in October 2026 covered the vault, the approval path for the first 14 families,
+1Mask, WalletConnect, plugins, hardware signing, the services and the supply chain. It found 47 issues (2 critical,
+5 high, 18 medium, 22 low) and all of them are fixed, each with a regression test: there are no open findings
+([report](docs/audit/internal-audit-2026-10.md)). The 12 newer families came after that review. The **external
+audit comes before any mainnet build**.
Report vulnerabilities privately: [SECURITY.md](SECURITY.md) (GitHub private reporting, or
[shayan@coldai.org](mailto:shayan@coldai.org)).
@@ -405,7 +421,7 @@ Report vulnerabilities privately: [SECURITY.md](SECURITY.md) (GitHub private rep
| Extension (Firefox) | Builds as MV3 and passes `addons-linter`. No Firefox e2e yet, and Clip Plugins are left out |
| Desktop (macOS, Windows, Linux) | Electron app with a built-in dapp browser. e2e on all three in CI. Unsigned unless signing secrets are set |
| Mobile (iOS, Android) | Screens and engine tested (vitest and jest-expo). No store builds yet |
-| Networks | 14 families on testnets. **Mainnet is off**, behind a build flag and a checklist |
+| Networks | 26 families (85 of the 87 CLPR networks) on testnets. **Mainnet is off**, behind a build flag and a checklist |
| Settle on Hedera | Testnet contracts live. The test Connector runs locally |
| Store listings | Packages, copy, screenshots and permission justifications are ready. **Nothing has been submitted yet** |
| Security | Internal review done. **External audit pending** |
diff --git a/apps/desktop/clip.config.ts b/apps/desktop/clip.config.ts
index d9da2179..60946c4f 100644
--- a/apps/desktop/clip.config.ts
+++ b/apps/desktop/clip.config.ts
@@ -19,7 +19,7 @@ export default defineConfig({
icon: "./icon.svg",
theme: { accent: "#FF3C00", accentText: "#FFFFFF", font: "Inter", radius: 14 },
// All 14 families, testnets only (same as the extension).
- networks: ["evm:*", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand"],
+ networks: ["evm:*", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand", "cosmos", "provenance", "thorchain", "initia", "tron", "xrpl", "antelope", "multiversx", "icp", "stacks", "fuel", "bitcoincash"],
passkeys: { enabled: true },
walletConnect: {},
services: MAINNET ? {} : TESTNET_SERVICES,
diff --git a/apps/docs/.vitepress/sidebar.ts b/apps/docs/.vitepress/sidebar.ts
index 8c62d808..6112f9d8 100644
--- a/apps/docs/.vitepress/sidebar.ts
+++ b/apps/docs/.vitepress/sidebar.ts
@@ -74,6 +74,13 @@ const dapps: Item[] = [
{ text: "Stellar", link: "/dapps/stellar" },
{ text: "Algorand", link: "/dapps/algorand" },
{ text: "Tezos (Beacon)", link: "/dapps/tezos" },
+ { text: "Cosmos SDK (Keplr API)", link: "/dapps/cosmos" },
+ { text: "TRON (TIP-1193)", link: "/dapps/tron" },
+ { text: "XRP Ledger (XLS-72d)", link: "/dapps/xrpl" },
+ { text: "Stacks (SIP-030)", link: "/dapps/stacks" },
+ { text: "Fuel", link: "/dapps/fuel" },
+ { text: "Bitcoin Cash", link: "/dapps/bitcoincash" },
+ { text: "MultiversX (best effort)", link: "/dapps/multiversx" },
{ text: "WalletConnect", link: "/dapps/walletconnect" },
],
},
@@ -174,6 +181,7 @@ const reference = (): Item[] => [
text: "Reference",
items: [
{ text: "Overview", link: "/reference/" },
+ { text: "Networks", link: "/reference/networks" },
{ text: "clip.config.ts schema", link: "/reference/config" },
{ text: "Error codes", link: "/reference/errors" },
{ text: "Warning codes", link: "/reference/warnings" },
diff --git a/apps/docs/package.json b/apps/docs/package.json
index b6074a69..a5a16b42 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -31,19 +31,28 @@
"@clip-wallet/1mask": "workspace:*",
"@clip-wallet/backup-client": "workspace:*",
"@clip-wallet/chains-algorand": "workspace:*",
+ "@clip-wallet/chains-antelope": "workspace:*",
"@clip-wallet/chains-aptos": "workspace:*",
"@clip-wallet/chains-bitcoin": "workspace:*",
+ "@clip-wallet/chains-bitcoincash": "workspace:*",
"@clip-wallet/chains-cardano": "workspace:*",
+ "@clip-wallet/chains-cosmos": "workspace:*",
"@clip-wallet/chains-evm": "workspace:*",
+ "@clip-wallet/chains-fuel": "workspace:*",
"@clip-wallet/chains-hedera": "workspace:*",
+ "@clip-wallet/chains-icp": "workspace:*",
+ "@clip-wallet/chains-multiversx": "workspace:*",
"@clip-wallet/chains-near": "workspace:*",
"@clip-wallet/chains-solana": "workspace:*",
+ "@clip-wallet/chains-stacks": "workspace:*",
"@clip-wallet/chains-starknet": "workspace:*",
"@clip-wallet/chains-stellar": "workspace:*",
"@clip-wallet/chains-substrate": "workspace:*",
"@clip-wallet/chains-sui": "workspace:*",
"@clip-wallet/chains-tezos": "workspace:*",
"@clip-wallet/chains-ton": "workspace:*",
+ "@clip-wallet/chains-tron": "workspace:*",
+ "@clip-wallet/chains-xrpl": "workspace:*",
"@clip-wallet/config": "workspace:*",
"@clip-wallet/connect": "workspace:*",
"@clip-wallet/core": "workspace:*",
diff --git a/apps/docs/src/architecture/chain-modules.md b/apps/docs/src/architecture/chain-modules.md
index b613e9e8..5f5ff7fc 100644
--- a/apps/docs/src/architecture/chain-modules.md
+++ b/apps/docs/src/architecture/chain-modules.md
@@ -25,7 +25,10 @@ the change-address helpers. A module that can't decode something throws a `ClipE
How to write one, step by step with a complete example: [Write a chain module](../extend/chain-module.md).
-## The fourteen modules
+## The modules
+
+One package per family; the Cosmos SDK package serves four families (cosmos, provenance, thorchain, initia) and
+the Antelope package three chains. Every network and its CLPR status: [Networks](../reference/networks.md).
| Family | Package | Factory | Test networks | Dapp standard |
| --- | --- | --- | --- | --- |
@@ -43,6 +46,15 @@ How to write one, step by step with a complete example: [Write a chain module](.
| Stellar | `@clip-wallet/chains-stellar` | `createStellarModule()` | Testnet | SEP-43 |
| Tezos | `@clip-wallet/chains-tezos` | `createTezosModule()` | Shadownet | Beacon |
| Algorand | `@clip-wallet/chains-algorand` | `createAlgorandModule()` | TestNet | ARC-1, use-wallet |
+| Cosmos SDK | `@clip-wallet/chains-cosmos` | `createCosmosModule()` | osmo-test-5, dydx-testnet-4, zig-test-2, pio-testnet-1, initiation-2 (THORChain: none) | Keplr-compatible API |
+| TRON | `@clip-wallet/chains-tron` | `createTronModule()` | Nile, Shasta | TIP-1193 + TIP-6963 |
+| XRP Ledger | `@clip-wallet/chains-xrpl` | `createXrplModule()` | Testnet, Devnet | XLS-72d (Wallet Standard) |
+| Stacks | `@clip-wallet/chains-stacks` | `createStacksModule()` | Testnet | SIP-030 + WBIP-004 |
+| Fuel | `@clip-wallet/chains-fuel` | `createFuelModule()` | Testnet | FuelConnector |
+| Bitcoin Cash | `@clip-wallet/chains-bitcoincash` | `createBitcoinCashModule()` | Chipnet, Testnet4 | WalletConnect (wc2-bch-bcr) |
+| MultiversX | `@clip-wallet/chains-multiversx` | `createMultiversXModule()` | Devnet, Testnet | best effort (sdk-dapp hook) |
+| Internet Computer | `@clip-wallet/chains-icp` | `createIcpModule()` | `icp:test` (DFINITY test ledgers) | none: send and receive |
+| Antelope | `@clip-wallet/chains-antelope` | `createAntelopeModule()` | Jungle4, Telos testnet, XPR testnet | none: send and receive |
Each package also exports its networks (most at `@clip-wallet/chains-/networks`) and its token tables. The
catalogue in [`packages/engine/src/catalog.ts`](repo:packages/engine/src/catalog.ts) assembles them for a wallet,
diff --git a/apps/docs/src/architecture/extension-kit.md b/apps/docs/src/architecture/extension-kit.md
index fcd8ba97..851b4378 100644
--- a/apps/docs/src/architecture/extension-kit.md
+++ b/apps/docs/src/architecture/extension-kit.md
@@ -1,7 +1,7 @@
# Extension kit
`@clip-wallet/extension-kit` is the whole browser extension as a library for [WXT](https://wxt.dev): the background
-(vault, approvals, security checks), the pages, 1Mask for all fourteen families and the build wiring. A wallet project,
+(vault, approvals, security checks), the pages, 1Mask for every family and the build wiring. A wallet project,
Clip Wallet's own `apps/extension` included, keeps only its identity, its `wxt.config.ts` and one-line entrypoints.
```mermaid
diff --git a/apps/docs/src/architecture/signing-flow.md b/apps/docs/src/architecture/signing-flow.md
index 8a7590d0..cc58c957 100644
--- a/apps/docs/src/architecture/signing-flow.md
+++ b/apps/docs/src/architecture/signing-flow.md
@@ -1,6 +1,6 @@
# The signing flow
-Every request takes the same path, whichever dapp library sent it and whichever of the fourteen families it is for:
+Every request takes the same path, whichever dapp library sent it and whichever family it is for:
**request → decode → sanitise → approval → single-use signing**. This page follows one request end to end and points
at the code for each step.
diff --git a/apps/docs/src/architecture/vault.md b/apps/docs/src/architecture/vault.md
index 4f1f9bad..fa317ce1 100644
--- a/apps/docs/src/architecture/vault.md
+++ b/apps/docs/src/architecture/vault.md
@@ -1,7 +1,7 @@
# Vault
`@clip-wallet/vault` is the only package that touches recovery phrases and private keys. It implements the `Vault`
-interface from `@clip-wallet/core` as `ClipVault`: create or import a phrase, derive accounts for fourteen families,
+interface from `@clip-wallet/core` as `ClipVault`: create or import a phrase, derive accounts for 26 families,
encrypt everything at rest, and sign only what the person approved.
```mermaid
@@ -52,6 +52,18 @@ the same addresses:
| stellar | ed25519 | `m/44'/148'/i'` (SEP-0005) | Stellar wallets |
| tezos | ed25519 | `m/44'/1729'/i'/0'` | Temple, Kukai |
| algorand | BIP32-Ed25519 (ARC-52) | `m/44'/283'/i'/0/0` | Pera Universal Wallet |
+| cosmos | secp256k1 | `m/44'/118'/0'/0/i` | Keplr, Leap, Cosmostation |
+| provenance | secp256k1 | `m/44'/505'/0'/0/i` | Keplr, Leap |
+| thorchain | secp256k1 | `m/44'/931'/0'/0/i` | Keplr, Ctrl (XDEFI), Vultisig |
+| initia | ethsecp256k1 | `m/44'/60'/0'/0/i` (the EVM key) | Initia Wallet, Keplr |
+| tron | secp256k1 | `m/44'/195'/0'/0/i` | TronLink, TronWeb `fromMnemonic` |
+| xrpl | secp256k1 | `m/44'/144'/i'/0/0` | xrpl.js `Wallet.fromMnemonic`, Ledger Live |
+| antelope | secp256k1 (K1, canonical signatures) | `m/44'/194'/0'/0/i` | TokenPocket; one key for Vaulta, Telos and XPR Network |
+| multiversx | ed25519 | `m/44'/508'/0'/0'/i'` | xPortal, DeFi Wallet |
+| icp | secp256k1 | `m/44'/223'/0'/0/i` | Plug, dfx `identity import` |
+| stacks | secp256k1 | `m/44'/5757'/0'/0/i` | Leather, Xverse |
+| fuel | secp256k1 | `m/44'/1179993420'/i'/0/0` | Fuel Wallet, fuels-ts |
+| bitcoincash | secp256k1 | `m/44'/145'/0'/0/i` | Electron Cash, Paytaca, Bitcoin.com Wallet |
The [vault README](repo:packages/vault/README.md) gives the source for every row and the cases where wallets disagree
(Hedera key types, Algorand's three schemes, TON's native mnemonics, Starknet's account schemes).
diff --git a/apps/docs/src/dapps/bitcoincash.md b/apps/docs/src/dapps/bitcoincash.md
new file mode 100644
index 00000000..65e3f8b1
--- /dev/null
+++ b/apps/docs/src/dapps/bitcoincash.md
@@ -0,0 +1,21 @@
+# Bitcoin Cash (WalletConnect)
+
+Bitcoin Cash has no injected-provider standard. Dapps reach wallets over **WalletConnect v2** with the community
+specification **wc2-bch-bcr** (used by Cashonize, Paytaca and Zapit, and apps such as TapSwap), and that is what Clip
+Wallet answers. It needs a build with a WalletConnect project id (`CLIP_WALLETCONNECT_PROJECT_ID`).
+
+| | |
+| --- | --- |
+| Namespace | `bch` |
+| Chains | `bch:bitcoincash` (mainnet), `bch:bchtest` (chipnet, where BCH upgrades are tested) |
+| Methods | `bch_getAddresses`, `bch_signTransaction`, `bch_signMessage` |
+| Events | `addressesChanged` |
+
+Pair from the dapp's WalletConnect QR code or link as with any other chain: see [WalletConnect](./walletconnect.md).
+Every transaction is decoded into plain words before approval.
+
+## Tested
+
+- Unit tests run the wc2-bch-bcr session and requests against the wallet's WalletConnect host
+ ([`packages/1mask/test/walletconnect-bch.test.ts`](repo:packages/1mask/test/walletconnect-bch.test.ts)). There is no
+ dapp matrix row: it needs a WalletConnect project id and testnet BCH.
diff --git a/apps/docs/src/dapps/cosmos.md b/apps/docs/src/dapps/cosmos.md
new file mode 100644
index 00000000..cf6a2daf
--- /dev/null
+++ b/apps/docs/src/dapps/cosmos.md
@@ -0,0 +1,30 @@
+# Cosmos SDK chains (Keplr-compatible)
+
+Clip Wallet answers the **Keplr API** at `window.clipwallet.cosmos` (a kit-built wallet uses its own global key). It
+never sets `window.keplr`: a dapp written for Keplr works by passing this object where it used `window.keplr`, and
+lists Clip under its own name. Method names, arguments and results follow Keplr's `Keplr` interface
+(`@keplr-wallet/types` 0.13), including the cosmjs offline signers.
+
+<<< @/snippets/dapps/cosmos.ts
+
+| Supported | |
+| --- | --- |
+| `enable(chainIds)`, `disable()` | the connect approval, per chain |
+| `getKey(chainId)` | `bech32Address`, `pubKey`, `algo` (`secp256k1`, or `ethsecp256k1` on Initia) |
+| `signDirect`, `signAmino` | transactions, decoded into plain words before approval |
+| `signArbitrary`, `verifyArbitrary` | ADR-36 messages |
+| `sendTx(chainId, tx, mode)` | broadcast through the wallet's node |
+| `getOfflineSigner`, `getOfflineSignerOnlyAmino`, `getOfflineSignerAuto` | cosmjs signers (`SigningStargateClient.connectWithSigner`) |
+| `getChainInfosWithoutEndpoints`, `getChainInfoWithoutEndpoints` | the chains Clip ships |
+| `on("keystorechange")` | account changes; also the window event `clipwallet_keystorechange` |
+| Chains | Osmosis, dYdX, ZIGChain, Provenance, THORChain, Initia, and their testnets where one exists |
+
+`experimentalSuggestChain` resolves for a chain Clip already ships and refuses any other: a website can't add chains
+to the wallet. Each chain id maps to one wallet family (`cosmos`, `provenance`, `thorchain`, `initia`), so permissions
+and accounts are per family. See [Networks](../reference/networks.md) for coin types and network ids.
+
+## Tested
+
+- **Dapp matrix**, osmo-test-5: cosmjs `SigningStargateClient` over `window.clipwallet.cosmos` connects and an ADR-36
+ signature verifies with `@cosmjs/crypto`. The send and approval levels wait for testnet OSMO.
+ [Results](../testing/results/dapp-matrix.md) · [test page](repo:apps/extension/e2e/matrix/dapps/cosmos.ts)
diff --git a/apps/docs/src/dapps/evm.md b/apps/docs/src/dapps/evm.md
index 8519350c..2cc57751 100644
--- a/apps/docs/src/dapps/evm.md
+++ b/apps/docs/src/dapps/evm.md
@@ -9,7 +9,7 @@ MetaMask.
| EIP-6963 name | `Clip Wallet` |
| EIP-6963 rdns | `org.coldai.clipwallet` (kit-built wallets announce their own) |
| `window.ethereum` | left alone |
-| Networks | the EVM test networks the wallet ships with: Sepolia, Base Sepolia, Arbitrum Sepolia, OP Sepolia, Arc, Hedera EVM and more |
+| Networks | the EVM test networks the wallet ships with: Sepolia, Base Sepolia, Arbitrum Sepolia, OP Sepolia, Arc, STRATO Helium, Hedera EVM and more (every mainnet: [Networks](../reference/networks.md)) |
## Without a library
@@ -58,3 +58,9 @@ Send standard, readable requests: EIP-712 typed data instead of raw hashes, real
- **Picker matrix**: RainbowKit, ConnectKit and Reown AppKit list Clip under "Installed" with its icon, connect,
reconnect and restore after a reload; Reown AppKit Lab on Sepolia signs a message.
[Results](../testing/results/picker-matrix.md)
+
+## STRATO
+
+STRATO's nodes take signed legacy (EIP-155) Ethereum transactions, so EIP-1193 dapps work unchanged: gas is unpriced,
+and the flat per-transaction fee (0.01 USDST, or a voucher) shows on the approval as its own line. Contract calls are
+matched to SolidVM functions by selector; STRATO's EIP-712 "function call" transactions aren't supported.
diff --git a/apps/docs/src/dapps/fuel.md b/apps/docs/src/dapps/fuel.md
new file mode 100644
index 00000000..972b297d
--- /dev/null
+++ b/apps/docs/src/dapps/fuel.md
@@ -0,0 +1,25 @@
+# Fuel (FuelConnector)
+
+Clip Wallet announces a **FuelConnector** with the `FuelConnector` window event, the way Fuel Wallet does, so every
+fuels-ts `Fuel` instance on the page adds it to its connectors under Clip's own name and icon. Because a `Fuel` created
+later misses an early event, Clip announces on install, on `DOMContentLoaded` and on `load`, and is also at
+`window.clipwallet.fuel` for `new Fuel({ connectors: [window.clipwallet.fuel] })`.
+
+<<< @/snippets/dapps/fuel.ts
+
+| Supported | |
+| --- | --- |
+| `connect`, `disconnect`, `isConnected`, `accounts`, `currentAccount` | the connect approval and accounts |
+| `sendTransaction(address, request)` | a fuels-ts `TransactionRequest`, decoded before approval; returns the transaction id |
+| `signTransaction`, `signMessage` | signing without sending |
+| `currentNetwork`, `networks`, `selectNetwork` | Fuel Ignition and the testnet |
+| Not supported | `addNetwork`, `addAsset(s)`: a website can't add networks or assets to the wallet |
+
+The connector is dependency-free: fuels-ts never runs inside the wallet's page script, and transactions cross to the
+wallet as the same JSON Fuel Wallet's connector sends.
+
+## Tested
+
+- **Dapp matrix**, testnet: fuels-ts `Fuel` lists Clip's connector and connects, and a signed message recovers the
+ account with `Signer.recoverAddress(hashMessage(…))`. The send and approval levels wait for testnet ETH.
+ [Results](../testing/results/dapp-matrix.md) · [test page](repo:apps/extension/e2e/matrix/dapps/fuel.ts)
diff --git a/apps/docs/src/dapps/index.md b/apps/docs/src/dapps/index.md
index f236b409..44a60dba 100644
--- a/apps/docs/src/dapps/index.md
+++ b/apps/docs/src/dapps/index.md
@@ -28,8 +28,20 @@ libraries against every build to prove it. See [Compatibility promise](../connec
| Stellar | SEP-43 | Stellar Wallets Kit with `@clip-wallet/kit-modules/stellar` | [Stellar](./stellar.md) |
| Algorand | ARC-1 | use-wallet with `@clip-wallet/kit-modules/algorand` | [Algorand](./algorand.md) |
| Tezos | Beacon (TZIP-10) | Beacon finds Clip; its modal needs a listing to connect | [Tezos](./tezos.md) |
+| Cosmos SDK (Osmosis, dYdX, ZIGChain, Provenance, THORChain, Initia) | Keplr-compatible API at `window.clipwallet.cosmos` | cosmjs and Keplr-style dapps, passing Clip's object instead of `window.keplr` | [Cosmos](./cosmos.md) |
+| TRON | TIP-1193 + TIP-6963 | TIP-6963 discovery; TronWeb for building transactions | [TRON](./tron.md) |
+| XRP Ledger | XLS-72d (Wallet Standard) | `@wallet-standard/app` with the XRPL feature filter | [XRP Ledger](./xrpl.md) |
+| Stacks | SIP-030 + WBIP-004 | `@stacks/connect` 8 | [Stacks](./stacks.md) |
+| Fuel | FuelConnector | fuels-ts `Fuel` | [Fuel](./fuel.md) |
+| Bitcoin Cash | WalletConnect (wc2-bch-bcr) | Cashonize-style WalletConnect dapps | [Bitcoin Cash](./bitcoincash.md) |
+| MultiversX | best effort: sdk-dapp's custom-provider hook | `@multiversx/sdk-dapp` 5, where the dapp keeps `window.multiversx` | [MultiversX](./multiversx.md) |
| Any (phones, desktop apps) | WalletConnect v2 | Any WalletConnect dapp | [WalletConnect](./walletconnect.md) |
+Antelope (Vaulta, Telos, XPR Network) and the Internet Computer have no wallet standard Clip can answer as itself
+(WharfKit's plugins live in the dapp, Anchor Link and Scatter would mean posing as those wallets, and ICRC-94 is still a
+draft), so on those networks Clip sends and receives but doesn't connect to dapps. Every network, with how dapps reach
+it: [Networks](../reference/networks.md).
+
Some ecosystems' pickers show only the wallets in their own registry. For those, the guide shows the one line a dapp
adds (a module, an adapter, a list entry) until Clip is listed. Listing submissions are drafted and wait for the public
release.
diff --git a/apps/docs/src/dapps/multiversx.md b/apps/docs/src/dapps/multiversx.md
new file mode 100644
index 00000000..f8ac8dc9
--- /dev/null
+++ b/apps/docs/src/dapps/multiversx.md
@@ -0,0 +1,26 @@
+# MultiversX (best effort)
+
+::: warning Best effort, through an undocumented hook
+MultiversX has no wallet-discovery standard for browser extensions. Clip Wallet adds itself to
+`window.multiversx.providers`, which `@multiversx/sdk-dapp` 5 merges into its custom providers in `initApp`. sdk-dapp
+documents that list as something the dapp sets, not as a channel for wallets, so it can change without notice, and it
+works only on sdk-dapp dapps that don't replace `window.multiversx` (MultiversX's own template dapp does replace it).
+:::
+
+When it works, Clip appears in the dapp's login options under its own name and icon (type `clipwallet`), never as
+"extension" or the MultiversX DeFi Wallet. Clip doesn't answer the DeFi Wallet's message protocol.
+
+| Supported | |
+| --- | --- |
+| `login({ token })` | connect; with a native-auth token, also signs `address + token` (checked to be for this site) |
+| `signTransaction(s)` | sdk-core `Transaction` objects, decoded before approval (EGLD, ESDT, staking calls) |
+| `signMessage` | MultiversX messages, verifiable with sdk-dapp's `verifyMessage` |
+| Networks | mainnet (`mvx:1`), devnet, testnet |
+
+There is no MultiversX WalletConnect namespace in Clip yet, so the hook is the only way a dapp reaches it.
+
+## Tested
+
+- **Dapp matrix**, devnet: `@multiversx/sdk-dapp` 5.7.3 `initApp` + `ProviderFactory` finds Clip through the hook and
+ connects, and a signed message verifies with `verifyMessage`. The send and approval levels wait for devnet EGLD.
+ [Results](../testing/results/dapp-matrix.md) · [test page](repo:apps/extension/e2e/matrix/dapps/multiversx.ts)
diff --git a/apps/docs/src/dapps/polkadot.md b/apps/docs/src/dapps/polkadot.md
index dc409f45..3d611eed 100644
--- a/apps/docs/src/dapps/polkadot.md
+++ b/apps/docs/src/dapps/polkadot.md
@@ -11,13 +11,19 @@ Accounts are sr25519, shown with the generic SS58 prefix 42; dapps re-encode for
| `accounts.get()`, `accounts.subscribe()` | the accounts the person shared with the site |
| `signer.signPayload` | extrinsics, decoded with the chain's metadata before approval |
| `signer.signRaw` | messages (`type: "bytes"`, wrapped in `` as polkadot.js does) |
-| Networks | Westend, Paseo and their Asset Hubs |
+| Networks | Westend, Paseo and their Asset Hubs; Chainflip's State Chain (Perseverance testnet), with `cF…` addresses |
::: info Metadata
Clip reads chain metadata from the chain itself and doesn't accept metadata from dapps (`metadata.provide()` refuses
it), so polkadot.js apps keeps showing "1 extension that needs to be updated". It's harmless.
:::
+::: info Chainflip
+lp.chainflip.io and other Chainflip dapps use `@polkadot/extension-dapp`, so they find Clip the same way. FLIP shows
+from the State Chain's `Flip.Account`; there is no FLIP transfer between State Chain accounts (FLIP is redeemed to
+Ethereum), and liquidity-provider order calls are shown as the raw call with a caution.
+:::
+
`window.injectedWeb3` is writable, like every other extension's, so `@polkadot/extension-dapp` can add to it.
## Pickers
@@ -33,3 +39,6 @@ listed, add an entry to `walletList`.
[Results](../testing/results/dapp-matrix.md) · [test page](repo:apps/extension/e2e/matrix/dapps/substrate.ts)
- **Picker and hosted dapps**: polkadot.js apps on Westend connects and signs (sr25519 verified); DOT Connect connects;
Talisman Connect connects with a list entry. [Results](../testing/results/picker-matrix.md)
+- **Dapp matrix**, Chainflip Perseverance: `@polkadot/extension-dapp` + `@polkadot/api`, as lp.chainflip.io uses them,
+ connect with a `cF…` address, `signRaw` verifies, and the approval reads "Register as a Chainflip liquidity
+ provider". [test page](repo:apps/extension/e2e/matrix/dapps/chainflip.ts)
diff --git a/apps/docs/src/dapps/stacks.md b/apps/docs/src/dapps/stacks.md
new file mode 100644
index 00000000..b2512b9f
--- /dev/null
+++ b/apps/docs/src/dapps/stacks.md
@@ -0,0 +1,26 @@
+# Stacks (SIP-030 and WBIP-004)
+
+Clip Wallet's Stacks provider implements **SIP-030** at `window.clipwallet.stacks` and registers on
+`window.wbip_providers` (**WBIP-004**), which is where `@stacks/connect` 8 finds wallets for its picker. As SIP-030
+asks, it never sets a shared global (`window.StacksProvider`, `window.LeatherProvider` or `window.XverseProviders`).
+
+<<< @/snippets/dapps/stacks.ts
+
+With `@stacks/connect`, nothing extra is needed: its `request()` lists Clip under its own name and resolves the
+provider from its WBIP-004 id (`clipwallet.stacks`).
+
+| Supported | |
+| --- | --- |
+| `stx_getAddresses` (and `getAddresses`) | the connect approval, then `{ addresses }` |
+| `stx_getNetworks` | the networks Clip ships |
+| `stx_transferStx`, `stx_transferSip10Ft`, `stx_transferSip9Nft` | transfers |
+| `stx_callContract`, `stx_deployContract`, `stx_signTransaction` | contract calls, deployments, signing a built transaction |
+| `stx_signMessage`, `stx_signStructuredMessage` | messages (SIP-018 for structured data) |
+| Errors | SIP-030 JSON-RPC codes: `-32000` user rejection, `-32002` access denied, `-32601`, `-32602` |
+| Networks | `"mainnet"`, `"testnet"` or the CAIP-2 id in the `network` param |
+
+## Tested
+
+- **Dapp matrix**, testnet: `@stacks/connect` 8 `request()` through WBIP-004 connects, a signed message verifies with
+ `verifyMessageSignatureRsv`, an STX transfer confirms on the testnet, and the approval reads "Send 0.000001 STX to …".
+ [Results](../testing/results/dapp-matrix.md) · [test page](repo:apps/extension/e2e/matrix/dapps/stacks.ts)
diff --git a/apps/docs/src/dapps/tron.md b/apps/docs/src/dapps/tron.md
new file mode 100644
index 00000000..1d97e3b1
--- /dev/null
+++ b/apps/docs/src/dapps/tron.md
@@ -0,0 +1,31 @@
+# TRON (TIP-1193 and TIP-6963)
+
+Clip Wallet's TRON provider follows **TIP-1193** and is announced with **TIP-6963** under the wallet's own name, icon
+and rdns. It never sets `isTronLink` and never touches `window.tronLink` or `window.tronWeb`. It is also at
+`window.clipwallet.tron`.
+
+<<< @/snippets/dapps/tron.ts
+
+| Supported | |
+| --- | --- |
+| `eth_requestAccounts` | TIP-1102: the connect approval, then `[address]` |
+| `eth_chainId`, `wallet_switchEthereumChain` | TRON chain ids (`0x2b6653dc` mainnet, `0xcd8690dc` Nile, `0x94a9059e` Shasta); TIP-3326 |
+| `tronWeb.trx.sign(transaction)` | transactions your TronWeb built, decoded before approval |
+| `tronWeb.trx.signMessageV2(message)` | messages |
+| Events | `accountsChanged`, `chainChanged`, `connect`, `disconnect` (TIP-1193) |
+
+TIP-1193 defines no signing methods: it says a provider carries a TronWeb as `tron.tronWeb`. Clip doesn't ship TronWeb
+into the page; `tronWeb` is only the subset dapps sign through (`defaultAddress`, `trx.sign`, `trx.signMessageV2`).
+Build transactions and read the chain with your own TronWeb. Legacy `trx.sign(hexString)` message signing,
+`multiSign` and `_signTypedData` are refused with `4200`.
+
+## Pickers
+
+The TRON wallet adapters (`@tronweb3/tronwallet-adapters`) list a fixed set of wallets and don't yet accept a
+third-party TIP-6963 wallet, so find Clip with TIP-6963 discovery as above.
+
+## Tested
+
+- **Dapp matrix**, Nile: raw TIP-6963 discovery and TIP-1193 connect, and `signMessageV2` verifies with TronWeb's
+ `Trx.verifyMessageV2`. The send and approval levels wait for testnet TRX.
+ [Results](../testing/results/dapp-matrix.md) · [test page](repo:apps/extension/e2e/matrix/dapps/tron.ts)
diff --git a/apps/docs/src/dapps/walletconnect.md b/apps/docs/src/dapps/walletconnect.md
index bf358383..18c8a9dc 100644
--- a/apps/docs/src/dapps/walletconnect.md
+++ b/apps/docs/src/dapps/walletconnect.md
@@ -10,6 +10,7 @@ with no Clip-specific code.
| `solana` | devnet | `solana_signTransaction`, `solana_signAndSendTransaction`, `solana_signMessage` |
| `bip122` | testnet4 | `signPsbt`, `signMessage`, `sendTransfer` |
| `hedera` | testnet | `hedera_signMessage`, `hedera_signTransaction`, `hedera_signAndExecuteTransaction`, … |
+| `bch` | `bch:bchtest` (chipnet) | `bch_getAddresses`, `bch_signTransaction`, `bch_signMessage` (wc2-bch-bcr; see [Bitcoin Cash](./bitcoincash.md)) |
- **Proposals.** Required chains or methods Clip can't serve reject the proposal (`5100`, `5101`); optional ones are
dropped and listed on the approval screen.
diff --git a/apps/docs/src/dapps/xrpl.md b/apps/docs/src/dapps/xrpl.md
new file mode 100644
index 00000000..80f188db
--- /dev/null
+++ b/apps/docs/src/dapps/xrpl.md
@@ -0,0 +1,21 @@
+# XRP Ledger (XLS-72d)
+
+Clip Wallet registers an **XLS-72d** wallet on the Wallet Standard, under its own name and icon (it never claims to be
+GemWallet, Crossmark or Xaman). Dapps that discover XRPL wallets through `@wallet-standard/app` with the
+`@xrpl-wallet-standard/app` feature filter list it like any other.
+
+| Supported | |
+| --- | --- |
+| `standard:connect`, `standard:disconnect`, `standard:events` | connect approval, accounts, account changes |
+| `xrpl:signTransaction` | sign a transaction the dapp built; returns the signed blob |
+| `xrpl:signAndSubmitTransaction` | sign and submit; returns the transaction hash |
+| Chains | `xrpl:0` (mainnet), `xrpl:1` (testnet), `xrpl:2` (devnet) |
+
+XLS-72d has no message signing, so there is none to call. Every transaction is decoded into plain words before
+approval ("Send 10 XRP to r…", "Change your account settings"), with memos shown.
+
+## Tested
+
+- **Dapp matrix**, testnet: Wallet Standard discovery with the XLS-72d feature filter connects, and a signed and
+ submitted AccountSet with a memo validates on the testnet (rippled refuses a payment to yourself); the approval names
+ it. [Results](../testing/results/dapp-matrix.md) · [test page](repo:apps/extension/e2e/matrix/dapps/xrpl.ts)
diff --git a/apps/docs/src/guide/index.md b/apps/docs/src/guide/index.md
index 2ca6f865..6246f3c5 100644
--- a/apps/docs/src/guide/index.md
+++ b/apps/docs/src/guide/index.md
@@ -1,8 +1,8 @@
# What Clip is
-Clip Wallet is a non-custodial wallet for every CLPR network: one recovery phrase for fourteen network families,
-one balance per asset, and plain words before anything is signed. It runs as a browser extension (Chrome, Edge,
-Firefox), an iOS and Android app, and a desktop app.
+Clip Wallet is a non-custodial wallet for the CLPR networks (85 of the 87; see [Networks](../reference/networks.md)):
+one recovery phrase for 26 network families, one balance per asset, and plain words before anything is signed. It
+runs as a browser extension (Chrome, Edge, Firefox), an iOS and Android app, and a desktop app.
It is also three things for developers:
diff --git a/apps/docs/src/guide/repo-layout.md b/apps/docs/src/guide/repo-layout.md
index a7257d2d..866f0480 100644
--- a/apps/docs/src/guide/repo-layout.md
+++ b/apps/docs/src/guide/repo-layout.md
@@ -9,7 +9,7 @@ published to npm as `@clip-wallet/` (plus `create-clip-wallet`), all at on
| --- | --- |
| `packages/core` | The contract: `Family`, `Network`, `AssetRef`, `ChainModule`, `DappRequest`, `DecodedRequest`, `SignablePayload`, `ClipError`, warning and error codes. Additive changes only. |
| `packages/config` | The `clip.config.ts` schema (zod): identity, theme, networks, routing, services, the mainnet checklist. |
-| `packages/vault` | Recovery phrase, key derivation for 14 families, encryption at rest, approval-bound signing, passkey unlock. **The only package that touches keys.** |
+| `packages/vault` | Recovery phrase, key derivation for 26 families, encryption at rest, approval-bound signing, passkey unlock. **The only package that touches keys.** |
| `packages/chains-*` | One `ChainModule` per family: `evm`, `hedera`, `solana`, `bitcoin`, `sui`, `aptos`, `cardano`, `substrate`, `starknet`, `ton`, `near`, `stellar`, `tezos`, `algorand`. |
| `packages/1mask` | Dapp connectors for every family, plus WalletConnect. Announces the wallet's identity. |
| `packages/engine` | Environment-free orchestration (approvals, permissions, portfolio, catalog) shared by mobile and desktop. |
diff --git a/apps/docs/src/index.md b/apps/docs/src/index.md
index c604ee8c..8dd2b643 100644
--- a/apps/docs/src/index.md
+++ b/apps/docs/src/index.md
@@ -31,7 +31,7 @@ features:
link: /connect/
linkText: The SDK
- title: A wallet of your own
- details: create-clip-wallet gives you a branded extension for 14 network families, with its own name, icon and extension id, and a Scaffold-HBAR dapp next to it.
+ details: create-clip-wallet gives you a branded wallet for 26 network families, with its own name, icon and extension id, and a Scaffold-HBAR dapp next to it.
link: /kit/
linkText: The kit
- title: Extend it
diff --git a/apps/docs/src/kit/index.md b/apps/docs/src/kit/index.md
index 41656444..fd1c8799 100644
--- a/apps/docs/src/kit/index.md
+++ b/apps/docs/src/kit/index.md
@@ -1,6 +1,6 @@
# Launch your own wallet
-The Clip Wallet kit lets you ship a wallet under your own name on the same code Clip Wallet runs: fourteen network
+The Clip Wallet kit lets you ship a wallet under your own name on the same code Clip Wallet runs: 26 network
families, 1Mask for every dapp, decoded approvals, the security floor, features, plugins and route-and-fund. One
project gives you the wallet **on every platform**: a browser extension, a desktop app for macOS, Windows and Linux,
and a phone app for iOS and Android, all described by one `clip.config.ts`. You change identity and configuration; the
diff --git a/apps/docs/src/reference/index.md b/apps/docs/src/reference/index.md
index 0772eaeb..ea48b948 100644
--- a/apps/docs/src/reference/index.md
+++ b/apps/docs/src/reference/index.md
@@ -1,6 +1,8 @@
# Reference
-Everything on these pages is generated from the source on every build, so it can't fall behind the code.
+Everything on these pages is generated from the source on every build, so it can't fall behind the code. The
+[Networks](./networks.md) page lists every network the wallet ships, which CLPR network each one is, and how dapps
+reach it; its coverage is checked by a test over the wallet's own catalogue.
| Page | Generated from |
| --- | --- |
diff --git a/apps/docs/src/reference/networks.md b/apps/docs/src/reference/networks.md
new file mode 100644
index 00000000..91109a6e
--- /dev/null
+++ b/apps/docs/src/reference/networks.md
@@ -0,0 +1,194 @@
+# Networks
+
+Clip Wallet supports **85 of the 87 CLPR networks** on mainnet, and **93 mainnets** in total, across 26 network
+families. The two CLPR networks it doesn't support, Canton and Mixin, have no model in which a self-custodial key
+wallet can hold and move funds on its own: see [Not supported](#not-supported).
+
+::: warning Test networks first
+Clip Wallet is pre-release and runs on test networks unless a build opts in to mainnet behind a checklist. "Supported
+on mainnet" means the wallet's catalogue has the network and a build can be switched to it; see
+[Mainnet](../kit/build-and-ship.md#mainnet).
+:::
+
+The coverage isn't written by hand. [`packages/engine/test/networks87.test.ts`](repo:packages/engine/test/networks87.test.ts)
+maps each CLPR network to the mainnet(s) in the wallet's own catalogue (`walletNetworks()` with every family on), fails
+if anything but Canton and Mixin is missing, and prints the table:
+
+```sh
+pnpm --filter @clip-wallet/engine exec vitest run test/networks87.test.ts
+```
+
+The working notes behind this page are in [`docs/r1/networks87.md`](repo:docs/r1/networks87.md).
+
+## How the count works
+
+- **85 of 87 CLPR networks** have at least one mainnet in the catalogue.
+- **93 mainnets** counts every mainnet the wallet ships, CLPR or not: 60 EVM networks (without "Hedera (EVM)"), one
+ each for Hedera, Solana, Bitcoin, Sui, Aptos, Cardano, Starknet, TON, NEAR, Stellar, Tezos and Algorand, five
+ Polkadot SDK networks (Polkadot, Kusama, both Asset Hubs, Chainflip), six Cosmos SDK networks (Osmosis, dYdX,
+ ZIGChain, Provenance, THORChain, Initia), TRON, XRP Ledger, MultiversX, Internet Computer, Stacks, Fuel, Bitcoin Cash,
+ and Vaulta, Telos (native) and XPR Network. Polkadot, Kusama and the Asset Hubs aren't CLPR networks; Telos counts
+ once as an EVM network and once natively.
+
+## Supported CLPR networks
+
+Network ids are CAIP-2 where a namespace exists (each chain package's README cites its sources). Dapp connectivity is
+per family; every family also sends and receives in the wallet itself. "(new)" marks the networks added with the
+twelve newer families and the Chainflip, STRATO and Arc additions.
+
+| Network | Module and mainnet network id | Dapp connectivity | Notes |
+| --- | --- | --- | --- |
+| Abstract | `chains-evm` `eip155:2741` | EIP-6963 / EIP-1193 | |
+| Algorand | `chains-algorand` `algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73k` | existing provider | |
+| Anubis | `chains-evm` `eip155:6714` | EIP-6963 / EIP-1193 | |
+| Arbitrum Nova | `chains-evm` `eip155:42170` | EIP-6963 / EIP-1193 | |
+| Arbitrum One | `chains-evm` `eip155:42161` | EIP-6963 / EIP-1193 | |
+| Arc (new) | `chains-evm` `eip155:5042` | EIP-6963 / EIP-1193 | Arc mainnet (5042) went live 2026-09-16; before, only the testnet was in the list |
+| Aurora | `chains-evm` `eip155:1313161554` | EIP-6963 / EIP-1193 | |
+| Avalanche C-Chain | `chains-evm` `eip155:43114` | EIP-6963 / EIP-1193 | |
+| Base | `chains-evm` `eip155:8453` | EIP-6963 / EIP-1193 | |
+| Bifrost Network | `chains-evm` `eip155:3068` | EIP-6963 / EIP-1193 | EVM layer |
+| Bitcoin | `chains-bitcoin` `bip122:000000000019d6689c085ae165831e93` | existing provider | |
+| Bitcoin Cash (new) | `chains-bitcoincash` `bip122:000000000000000000651ef99cb9fcbe` | WalletConnect wc2-bch-bcr (no injected standard) | |
+| Bittensor | `chains-evm` `eip155:964` | EIP-6963 / EIP-1193 | EVM layer |
+| Blast | `chains-evm` `eip155:81457` | EIP-6963 / EIP-1193 | |
+| BNB Smart Chain | `chains-evm` `eip155:56` | EIP-6963 / EIP-1193 | |
+| BOB | `chains-evm` `eip155:60808` | EIP-6963 / EIP-1193 | |
+| BOT Chain | `chains-evm` `eip155:677` | EIP-6963 / EIP-1193 | |
+| Cardano | `chains-cardano` `cip34:1-764824073` | existing provider | |
+| Celo | `chains-evm` `eip155:42220` | EIP-6963 / EIP-1193 | |
+| Chainflip (new) | `chains-substrate` `polkadot:8b8c140b0af9db70686583e3f6bf2a59` | window.injectedWeb3 (polkadot extension-dapp) | State Chain FLIP from Flip.Account; no FLIP transfer exists (redeem to Ethereum); LP-portal calls decoded |
+| Conflux | `chains-evm` `eip155:1030` | EIP-6963 / EIP-1193 | Conflux eSpace (EVM); Core Space not supported |
+| Core | `chains-evm` `eip155:1116` | EIP-6963 / EIP-1193 | |
+| Cronos | `chains-evm` `eip155:25` | EIP-6963 / EIP-1193 | EVM layer |
+| dYdX (new) | `chains-cosmos` `cosmos:dydx-mainnet-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 118 |
+| Ethereum | `chains-evm` `eip155:1` | EIP-6963 / EIP-1193 | |
+| Etherlink | `chains-evm` `eip155:42793` | EIP-6963 / EIP-1193 | EVM layer of Tezos smart rollups |
+| Flare | `chains-evm` `eip155:14` | EIP-6963 / EIP-1193 | |
+| Fraxtal | `chains-evm` `eip155:252` | EIP-6963 / EIP-1193 | |
+| Fuel (new) | `chains-fuel` `fuel:9889` | FuelConnector event | |
+| Gnosis Chain | `chains-evm` `eip155:100` | EIP-6963 / EIP-1193 | |
+| GRX Chain | `chains-evm` `eip155:1110` | EIP-6963 / EIP-1193 | |
+| Hydration | `chains-evm` `eip155:222222` | EIP-6963 / EIP-1193 | EVM layer |
+| Hyperliquid | `chains-evm` `eip155:999` | EIP-6963 / EIP-1193 | HyperEVM |
+| ICP (new) | `chains-icp` `icp:737ba355e855bd4b61279056603e0550` | none: send/receive only (ICRC-94 discovery still a draft) | no public transfer testnet: icp:test = mainnet limited to DFINITY test ledgers; replies not certificate-verified |
+| Immutable zkEVM | `chains-evm` `eip155:13371` | EIP-6963 / EIP-1193 | |
+| Initia (new) | `chains-cosmos` `cosmos:interwoven-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 60, ethsecp256k1: the EVM key |
+| Injective | `chains-evm` `eip155:1776` | EIP-6963 / EIP-1193 | EVM layer |
+| Ink | `chains-evm` `eip155:57073` | EIP-6963 / EIP-1193 | |
+| Kaia | `chains-evm` `eip155:8217` | EIP-6963 / EIP-1193 | Kaia is EVM (8217) |
+| Katana | `chains-evm` `eip155:747474` | EIP-6963 / EIP-1193 | |
+| Kava | `chains-evm` `eip155:2222` | EIP-6963 / EIP-1193 | EVM layer |
+| KUB Chain | `chains-evm` `eip155:96` | EIP-6963 / EIP-1193 | |
+| Linea | `chains-evm` `eip155:59144` | EIP-6963 / EIP-1193 | |
+| Mantle | `chains-evm` `eip155:5000` | EIP-6963 / EIP-1193 | |
+| MANTRA | `chains-evm` `eip155:5888` | EIP-6963 / EIP-1193 | EVM layer |
+| MegaETH | `chains-evm` `eip155:4326` | EIP-6963 / EIP-1193 | |
+| Mezo | `chains-evm` `eip155:31612` | EIP-6963 / EIP-1193 | EVM layer |
+| Monad | `chains-evm` `eip155:143` | EIP-6963 / EIP-1193 | |
+| Morph | `chains-evm` `eip155:2818` | EIP-6963 / EIP-1193 | |
+| MultiversX (new) | `chains-multiversx` `mvx:1` | best effort: sdk-dapp's undocumented window.multiversx.providers hook | |
+| NEAR | `chains-near` `near:mainnet` | existing provider | |
+| OP Mainnet | `chains-evm` `eip155:10` | EIP-6963 / EIP-1193 | |
+| Osmosis (new) | `chains-cosmos` `cosmos:osmosis-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 118 |
+| Plasma | `chains-evm` `eip155:9745` | EIP-6963 / EIP-1193 | |
+| Plume | `chains-evm` `eip155:98866` | EIP-6963 / EIP-1193 | |
+| Polygon PoS | `chains-evm` `eip155:137` | EIP-6963 / EIP-1193 | |
+| Provenance (new) | `chains-cosmos` `cosmos:pio-mainnet-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 505 (Keplr's testnet uses 1: different key there) |
+| PulseChain | `chains-evm` `eip155:369` | EIP-6963 / EIP-1193 | |
+| Reya | `chains-evm` `eip155:1729` | EIP-6963 / EIP-1193 | |
+| RISE | `chains-evm` `eip155:4153` | EIP-6963 / EIP-1193 | |
+| Robinhood Chain | `chains-evm` `eip155:4663` | EIP-6963 / EIP-1193 | |
+| Ronin | `chains-evm` `eip155:2020` | EIP-6963 / EIP-1193 | |
+| Rootstock | `chains-evm` `eip155:30` | EIP-6963 / EIP-1193 | |
+| Scroll | `chains-evm` `eip155:534352` | EIP-6963 / EIP-1193 | |
+| Sei | `chains-evm` `eip155:1329` | EIP-6963 / EIP-1193 | EVM layer |
+| Solana | `chains-solana` `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | existing provider | |
+| Soneium | `chains-evm` `eip155:1868` | EIP-6963 / EIP-1193 | |
+| Stable | `chains-evm` `eip155:988` | EIP-6963 / EIP-1193 | EVM layer |
+| Stacks (new) | `chains-stacks` `stacks:1` | SIP-030 + WBIP-004 | |
+| Starknet | `chains-starknet` `starknet:SN_MAIN` | existing provider | |
+| Stellar | `chains-stellar` `stellar:pubnet` | existing provider | |
+| STRATO (new) | `chains-evm` `eip155:123354377739506` | EIP-6963 / EIP-1193 | SolidVM, but nodes take signed legacy Ethereum transactions; flat 0.01 USDST fee per transaction |
+| Telos | `chains-evm` `eip155:40` | EIP-6963 / EIP-1193 | Telos EVM (and Telos native in chains-antelope) |
+| Tezos | `chains-tezos` `tezos:NetXdQprcVkpaWU` | existing provider | |
+| THORChain (new) | `chains-cosmos` `cosmos:thorchain-1` | Keplr-compatible API at window.clipwallet.cosmos | mainnet only: stagenet runs on real funds |
+| TON | `chains-ton` `ton:-239` | existing provider | |
+| TRON (new) | `chains-tron` `tron:0x2b6653dc` | TIP-1193 + TIP-6963 | |
+| Unichain | `chains-evm` `eip155:130` | EIP-6963 / EIP-1193 | |
+| Vaulta (new) | `chains-antelope` `antelope:aca376f206b8fc25a6ed44dbdc66547c` | none: send/receive only | accounts are names created on chain; receive needs one for this key |
+| World Chain | `chains-evm` `eip155:480` | EIP-6963 / EIP-1193 | |
+| X Layer | `chains-evm` `eip155:196` | EIP-6963 / EIP-1193 | |
+| XPR Network (new) | `chains-antelope` `antelope:384da888112027f0321850a169f737c3` | none: send/receive only | accounts are names created on chain |
+| XRP Ledger (new) | `chains-xrpl` `xrpl:0` | XLS-72d Wallet Standard (no message signing) | |
+| ZIGChain (new) | `chains-cosmos` `cosmos:zigchain-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 118 |
+| ZKsync Era | `chains-evm` `eip155:324` | EIP-6963 / EIP-1193 | |
+
+## Families and derivation paths
+
+Every derivation is in `@clip-wallet/vault`, the only package that touches keys, and is cross-checked for the public
+"abandon … about" test phrase against each ecosystem's own SDK
+([`packages/vault/test/families87.test.ts`](repo:packages/vault/test/families87.test.ts)). Each chain module serializes
+transactions itself and is checked byte for byte against the official SDK, which is a development dependency only; a
+module's runtime dependencies are `@clip-wallet/core`, `@noble/*` and `@scure/base`.
+
+| Family (vault) | Path (account `i`) | Package | Mainnets / testnets | Dapp standard in 1Mask |
+| --- | --- | --- | --- | --- |
+| `cosmos` | `m/44'/118'/0'/0/i` | `chains-cosmos` | Osmosis, dYdX, ZIGChain / osmo-test-5, dydx-testnet-4, zig-test-2 | Keplr-compatible API (`enable`, `getKey`, `signDirect`, `signAmino`, `signArbitrary`, `verifyArbitrary`, `sendTx`, offline signers) at `window.clipwallet.cosmos`; never `window.keplr` |
+| `provenance` | `m/44'/505'/0'/0/i` | `chains-cosmos` | Provenance / pio-testnet-1 | same |
+| `thorchain` | `m/44'/931'/0'/0/i` | `chains-cosmos` | THORChain / none (stagenet uses real funds) | same |
+| `initia` | `m/44'/60'/0'/0/i` (ethsecp256k1) | `chains-cosmos` | Initia / initiation-2 | same |
+| `tron` | `m/44'/195'/0'/0/i` | `chains-tron` | TRON / Nile, Shasta | TIP-1193 provider announced with TIP-6963; TronWeb-shaped signing subset (`trx.sign`, `signMessageV2`) |
+| `xrpl` | `m/44'/144'/i'/0/0` | `chains-xrpl` | XRP Ledger / testnet, devnet | XLS-72d (Wallet Standard `xrpl:signTransaction`, `xrpl:signAndSubmitTransaction`); no message signing in the standard |
+| `antelope` | `m/44'/194'/0'/0/i` (K1, canonical signatures) | `chains-antelope` | Vaulta, Telos, XPR Network / Jungle4, Telos testnet, XPR testnet | none: send/receive only (WharfKit wallet plugins live in the dapp; Anchor Link/ESR and Scatter injection would mean posing as Anchor or Scatter) |
+| `multiversx` | `m/44'/508'/0'/0'/i'` | `chains-multiversx` | MultiversX / devnet, testnet | best effort: an entry on sdk-dapp's `window.multiversx.providers` custom-provider hook under Clip's own name. **Undocumented and subject to change**; it only works on dapps that don't reset `window.multiversx` (MultiversX's own template dapp does). WalletConnect `mvx_*` methods are handled by the module |
+| `icp` | `m/44'/223'/0'/0/i` | `chains-icp` | Internet Computer / `icp:test` (mainnet limited to DFINITY test ledgers) | none: ICRC-94 (extension discovery) is still a draft; Plug's `window.ic.plug` is proprietary |
+| `stacks` | `m/44'/5757'/0'/0/i` | `chains-stacks` | Stacks / testnet | SIP-030 `request` on `window.clipwallet.stacks`, registered on `window.wbip_providers` (WBIP-004) |
+| `fuel` | `m/44'/1179993420'/i'/0/0` | `chains-fuel` | Fuel Ignition / testnet | FuelConnector announced with the `FuelConnector` event, dependency-free |
+| `bitcoincash` | `m/44'/145'/0'/0/i` | `chains-bitcoincash` | Bitcoin Cash / chipnet, testnet4 | WalletConnect `bch` namespace (wc2-bch-bcr, as Cashonize/Paytaca); no injected standard |
+| `substrate` (existing) | existing | `chains-substrate` | + Chainflip / Perseverance | existing `window.injectedWeb3` (what lp.chainflip.io uses) |
+| `evm` (existing) | existing | `chains-evm` | + STRATO, Arc / STRATO Helium | existing EIP-6963 |
+
+The dapp side of each family: [Clip works with your dapp](../dapps/).
+
+## Not supported
+
+### Canton
+
+Canton is a network of participant (validator) nodes that host parties. Canton 3.x has "external parties" whose key is
+held outside the node, and CIP-0103 defines a dapp API for wallets, but every read of a party's contracts and every
+submission (prepare, sign the hash, execute) goes through the Ledger API of a participant that hosts the party, behind
+that operator's authentication, with no public endpoint. A wallet can hold the key but can't see or move anything
+without an operator relationship.
+
+**Closest honest support:** none in Clip Wallet as shipped. A kit-built wallet whose operator runs (or contracts) a
+validator could add a family that talks to its own participant through a CIP-0103 wallet gateway: an operator product,
+not a wallet derived from a recovery phrase.
+
+### Mixin
+
+Mixin's kernel is a UTXO DAG with CryptoNote-style one-time output keys. Its public RPC has no address or key index,
+so a wallet can't find its own outputs without scanning every snapshot with the view key, and there is no BIP-39
+derivation standard for kernel keys. User funds live in Mixin's account system (Mixin Messenger and Safe, with
+registered accounts and keys behind Mixin's API).
+
+**Closest honest support:** none. XIN as an ERC-20 on Ethereum is an asset, not the network.
+
+## Limits worth knowing
+
+- **STRATO** works through its Ethereum JSON-RPC: legacy (EIP-155) transactions only, gas unpriced, and a flat
+ 0.01 USDST (or voucher) per transaction that the approval shows as its own line. Contract calls are matched to
+ SolidVM functions by selector; STRATO's EIP-712 "function call" transactions are out of scope.
+- **Arc** mainnet launched on 2026-09-16; its native gas is USDC (18 decimals on the EVM side).
+- **Antelope** accounts (Vaulta, Telos, XPR Network) must be created on chain by someone, because they cost RAM. Clip
+ finds the accounts that use its key and says plainly when there is none yet. No dapp connector: send and receive only.
+- **Internet Computer** replies aren't certificate-verified (no BLS check); nothing security-relevant relies on them.
+ An ICP transfer must be approved within 4 minutes (ingress expiry). There is no public transfer testnet: `icp:test`
+ is mainnet limited to DFINITY's test ledgers. No dapp connector: send and receive only.
+- **THORChain** has no public testnet (stagenet runs on real funds). Swaps (deposit memos) decode as blind.
+- **Provenance and Initia testnets:** Keplr derives pio-testnet-1 at coin type 1 and initiation-2 at 118; Clip uses the
+ mainnet coin types (505, 60) everywhere, so those testnet accounts differ from Keplr's.
+- **Chainflip** has no FLIP transfer between State Chain accounts; liquidity-provider order calls are shown as the raw
+ call with a caution.
+- **MultiversX** dapp connectivity is best effort, through an undocumented sdk-dapp hook: see
+ [MultiversX](../dapps/multiversx.md).
diff --git a/apps/docs/src/security/vault-crypto.md b/apps/docs/src/security/vault-crypto.md
index 47e58734..56e6cced 100644
--- a/apps/docs/src/security/vault-crypto.md
+++ b/apps/docs/src/security/vault-crypto.md
@@ -50,9 +50,10 @@ PRF input plays the same role. On macOS desktop, Touch ID releases a device secr
XChaCha20-Poly1305(entropy)`, with the header as associated data, under a key from a fixed backup PRF input. The phrase
never leaves the vault. The [backup service](../services/backup.md) stores the blob and can't open it.
-## Key derivation for fourteen families
+## Key derivation for every family
-BIP-32 for secp256k1, SLIP-10 for ed25519, BIP32-Ed25519 for Cardano and Algorand, sr25519 for Polkadot SDK chains,
-and the Stark curve's key grinding for Starknet. The paths match each ecosystem's popular wallets; see
+BIP-32 for secp256k1 (Initia's ethsecp256k1 included), SLIP-10 for ed25519, BIP32-Ed25519 for Cardano and Algorand,
+sr25519 for Polkadot SDK chains, and the Stark curve's key grinding for Starknet; Antelope signatures are made
+canonical, as its chains require. The paths match each ecosystem's popular wallets; see
[Vault](../architecture/vault.md#derivation). Every family is checked against its own SDK in the tests, with public test
vectors only.
diff --git a/apps/docs/src/snippets/dapps/cosmos.ts b/apps/docs/src/snippets/dapps/cosmos.ts
new file mode 100644
index 00000000..98a4d9ca
--- /dev/null
+++ b/apps/docs/src/snippets/dapps/cosmos.ts
@@ -0,0 +1,30 @@
+// Cosmos SDK chains: Clip's Keplr-compatible provider is window.clipwallet.cosmos (never window.keplr). A dapp written
+// for Keplr swaps `window.keplr` for it; the offline signer goes to cosmjs as usual.
+interface CosmosKey {
+ name: string;
+ algo: string;
+ pubKey: Uint8Array;
+ bech32Address: string;
+}
+interface StdSignature {
+ pub_key: { type: string; value: string };
+ signature: string;
+}
+interface CosmosProvider {
+ enable(chainIds: string | string[]): Promise;
+ getKey(chainId: string): Promise;
+ signArbitrary(chainId: string, signer: string, data: string | Uint8Array): Promise;
+ getOfflineSigner(chainId: string): unknown; // an OfflineDirectSigner for SigningStargateClient.connectWithSigner
+ on(event: "keystorechange", listener: () => void): () => void;
+}
+
+export async function connectCosmos(chainId = "osmo-test-5") {
+ const cosmos = (window as unknown as { clipwallet?: { cosmos?: CosmosProvider } }).clipwallet?.cosmos;
+ if (!cosmos) throw new Error("Clip Wallet isn't installed in this browser.");
+ await cosmos.enable(chainId); // the connect approval
+ const key = await cosmos.getKey(chainId); // key.bech32Address: osmo1…
+ // ADR-36 sign-in message, verifiable with @cosmjs/crypto or Keplr's verifyADR36Amino
+ const signature = await cosmos.signArbitrary(chainId, key.bech32Address, "Sign in to example.app");
+ const signer = cosmos.getOfflineSigner(chainId);
+ return { address: key.bech32Address, signature, signer };
+}
diff --git a/apps/docs/src/snippets/dapps/fuel.ts b/apps/docs/src/snippets/dapps/fuel.ts
new file mode 100644
index 00000000..fcb0dfa9
--- /dev/null
+++ b/apps/docs/src/snippets/dapps/fuel.ts
@@ -0,0 +1,17 @@
+// Fuel: Clip announces a FuelConnector with the "FuelConnector" window event, so fuels-ts `new Fuel()` lists it.
+// It is also at window.clipwallet.fuel, for `new Fuel({ connectors: [window.clipwallet.fuel] })`.
+interface FuelConnectorLike {
+ name: string;
+ connect(): Promise;
+ accounts(): Promise;
+ signMessage(address: string, message: string): Promise;
+}
+
+export async function connectFuel() {
+ const fuel = (window as unknown as { clipwallet?: { fuel?: FuelConnectorLike } }).clipwallet?.fuel;
+ if (!fuel) throw new Error("Clip Wallet isn't installed in this browser.");
+ await fuel.connect();
+ const [address] = await fuel.accounts(); // a 0x… b256 address
+ const signature = await fuel.signMessage(address!, "Sign in to example.app"); // Signer.recoverAddress(hashMessage(…))
+ return { address, signature };
+}
diff --git a/apps/docs/src/snippets/dapps/stacks.ts b/apps/docs/src/snippets/dapps/stacks.ts
new file mode 100644
index 00000000..309d4122
--- /dev/null
+++ b/apps/docs/src/snippets/dapps/stacks.ts
@@ -0,0 +1,20 @@
+// Stacks: Clip's SIP-030 provider is window.clipwallet.stacks, registered on window.wbip_providers (WBIP-004), which
+// is where @stacks/connect 8 finds wallets. Without @stacks/connect, call it directly:
+interface Sip030Response {
+ jsonrpc: "2.0";
+ id: number;
+ result: T;
+}
+interface StacksProvider {
+ request(method: string, params?: unknown): Promise>;
+}
+
+export async function connectStacks() {
+ const stacks = (window as unknown as { clipwallet?: { stacks?: StacksProvider } }).clipwallet?.stacks;
+ if (!stacks) throw new Error("Clip Wallet isn't installed in this browser.");
+ const { result } = await stacks.request<{ addresses: { symbol: string; address: string; publicKey: string }[] }>("stx_getAddresses", { network: "testnet" });
+ const address = result.addresses[0]!.address; // ST… on testnet
+ const signed = await stacks.request<{ signature: string; publicKey: string }>("stx_signMessage", { message: "Sign in to example.app", network: "testnet" });
+ const sent = await stacks.request<{ txid: string }>("stx_transferStx", { recipient: address, amount: "1", network: "testnet" });
+ return { address, signature: signed.result.signature, txid: sent.result.txid };
+}
diff --git a/apps/docs/src/snippets/dapps/tron.ts b/apps/docs/src/snippets/dapps/tron.ts
new file mode 100644
index 00000000..8d6f0d0e
--- /dev/null
+++ b/apps/docs/src/snippets/dapps/tron.ts
@@ -0,0 +1,37 @@
+// TRON: Clip announces a TIP-1193 provider with TIP-6963 under its own name (it never sets window.tronLink or
+// window.tronWeb). Find it by its rdns, connect, then sign with the TronWeb subset it carries.
+interface TronProvider {
+ request(args: { method: string; params?: unknown }): Promise;
+ tronWeb: {
+ defaultAddress: { base58: string | false };
+ trx: { sign(transaction: unknown): Promise; signMessageV2(message: string): Promise };
+ };
+ on(event: "accountsChanged" | "chainChanged" | "disconnect", listener: (...args: unknown[]) => void): unknown;
+}
+interface AnnounceDetail {
+ info: { uuid: string; name: string; icon: string; rdns: string };
+ provider: TronProvider;
+}
+
+export function findTronWallet(rdns = "org.coldai.clipwallet"): Promise {
+ return new Promise((resolve, reject) => {
+ const onAnnounce = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (detail?.info.rdns !== rdns) return;
+ window.removeEventListener("TIP6963:announceProvider", onAnnounce);
+ resolve(detail.provider);
+ };
+ window.addEventListener("TIP6963:announceProvider", onAnnounce);
+ window.dispatchEvent(new Event("TIP6963:requestProvider"));
+ setTimeout(() => reject(new Error("No TIP-6963 wallet with that rdns.")), 1000);
+ });
+}
+
+export async function connectTron() {
+ const tron = await findTronWallet();
+ const [address] = (await tron.request({ method: "eth_requestAccounts" })) as string[]; // TIP-1102
+ const chainId = await tron.request({ method: "eth_chainId" }); // "0xcd8690dc" on Nile
+ const signature = await tron.tronWeb.trx.signMessageV2("Sign in to example.app"); // TronWeb Trx.verifyMessageV2
+ // Transactions: build them with your own TronWeb, then tron.tronWeb.trx.sign(transaction).
+ return { address, chainId, signature };
+}
diff --git a/apps/docs/src/snippets/kit/inpage.content.ts b/apps/docs/src/snippets/kit/inpage.content.ts
index 6b42b406..81011360 100644
--- a/apps/docs/src/snippets/kit/inpage.content.ts
+++ b/apps/docs/src/snippets/kit/inpage.content.ts
@@ -1,4 +1,4 @@
-// src/entrypoints/inpage.content.ts: 1Mask's providers for all 14 families (MAIN world), under your identity.
+// src/entrypoints/inpage.content.ts: 1Mask's providers for every family (MAIN world), under your identity.
import { defineContentScript } from "wxt/utils/define-content-script";
import { CONTENT_MATCHES, installInpage } from "@clip-wallet/extension-kit/inpage";
diff --git a/apps/docs/src/testing/matrices.md b/apps/docs/src/testing/matrices.md
index 9735cce6..843b9640 100644
--- a/apps/docs/src/testing/matrices.md
+++ b/apps/docs/src/testing/matrices.md
@@ -5,7 +5,7 @@ Clip-specific on the dapp side.
## The dapp matrix
-For each of the fourteen families, that ecosystem's own dapp library in a local page talks to the real public testnet.
+For each family with a dapp standard, that ecosystem's own dapp library in a local page talks to the real public testnet.
| Level | Passes when |
| --- | --- |
diff --git a/apps/extension/clip.config.ts b/apps/extension/clip.config.ts
index 95cce2c3..efebfab4 100644
--- a/apps/extension/clip.config.ts
+++ b/apps/extension/clip.config.ts
@@ -22,7 +22,7 @@ export default defineConfig({
// ColdAI orange; white text on orange buttons is the owner's preference (contrast 3.6:1 ≥ the schema's 3:1).
theme: { accent: "#FF3C00", accentText: "#FFFFFF", font: "Inter", radius: 14 },
// All 14 families (testnets; mainnet stays gated below).
- networks: ["evm:*", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand"],
+ networks: ["evm:*", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand", "cosmos", "provenance", "thorchain", "initia", "tron", "xrpl", "antelope", "multiversx", "icp", "stacks", "fuel", "bitcoincash"],
// rpOrigin unset: the extension's own origin is the WebAuthn RP (Chrome 122+). Set an https origin you
// own (and add it to host_permissions) to keep passkeys stable across extension ids and browsers.
passkeys: { enabled: true },
diff --git a/apps/extension/e2e/matrix.spec.ts b/apps/extension/e2e/matrix.spec.ts
index de793bc9..2dfc8cde 100644
--- a/apps/extension/e2e/matrix.spec.ts
+++ b/apps/extension/e2e/matrix.spec.ts
@@ -126,6 +126,8 @@ async function openDapp(context: BrowserContext, dapp: string): Promise {
});
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
+ // MATRIX_DEBUG=1: the dapp page's console in the test output (no secrets reach a dapp page).
+ if (process.env.MATRIX_DEBUG) page.on("console", (m) => process.stdout.write(`[${dapp} page] ${m.type()}: ${m.text().slice(0, 300)}\n`));
await page.goto(`${ORIGIN}/`);
await page.waitForFunction(() => !!(window as unknown as { __matrix?: unknown }).__matrix, undefined, { timeout: 30_000 }).catch(() => {
throw new Error(`${dapp} dapp didn't start: ${errors.join(" | ") || "no page error"}`);
@@ -216,8 +218,10 @@ interface Spec {
recipient?: RegExp;
/** Message signing isn't part of this ecosystem's dapp API. */
noSign?: string;
+ /** L3/L4 is a call, not a transfer (Chainflip has no FLIP transfer): the approval title must match this instead. */
+ call?: RegExp;
}
-const SPECS: Record = {
+const SPECS: Partial> = {
evm: { amount: "0.000000000000000001", symbol: "ETH" },
"hedera-evm": { amount: "0.00000001", symbol: "HBAR" },
// Hedera refuses transfers to yourself, so this one pays the matrix's EVM-path account (alias 0x05AC…).
@@ -235,6 +239,17 @@ const SPECS: Record = {
stellar: { amount: "0.0000001", symbol: "XLM" },
tezos: { amount: "0.000001", symbol: "XTZ" },
algorand: { amount: "0.000001", symbol: "ALGO", noSign: "Algorand's dapp APIs (use-wallet v5, ARC-1) have no message signing; ARC-60 signData is a draft Clip doesn't offer." },
+ // networks87
+ cosmos: { amount: "0.000001", symbol: "OSMO" },
+ // java-tron refuses transfers to yourself: 1 sun to the Nile black-hole address (it exists, so no opening fee).
+ tron: { amount: "0.000001", symbol: "TRX", recipient: /T9yD14/ },
+ // 1 µSTX to the testnet burn address (stx_transferStx refuses the sender as recipient).
+ stacks: { amount: "0.000001", symbol: "STX", recipient: /ST0000/ },
+ fuel: { amount: "0.000000001", symbol: "ETH" },
+ chainflip: { amount: "", symbol: "FLIP", call: /Register as a Chainflip liquidity provider/ },
+ multiversx: { amount: "0.000000000000000001", symbol: "EGLD" },
+ // rippled refuses an XRP payment to yourself (temREDUNDANT): L3 is a no-op AccountSet with a memo.
+ xrpl: { amount: "", symbol: "XRP", call: /Change your account settings/, noSign: "XLS-72d (the XRPL browser wallet standard) has no message signing." },
};
/** Hedera over WalletConnect answers with the 0.0.x account id: it must be the account behind our key's EVM alias. */
@@ -267,7 +282,7 @@ async function runTarget(target: Target, context: BrowserContext, extensionId: s
}
test.skip(!!why, why ?? "");
}
- const spec = SPECS[target];
+ const spec = SPECS[target]!;
const net = TARGETS[target].network;
const expected = addressOf(target);
// The chain module derives the same address from the vault's public key (what the wallet shows on Receive).
@@ -333,10 +348,14 @@ async function runTarget(target: Target, context: BrowserContext, extensionId: s
const short = expected.slice(0, 6);
const recipientShown = spec.recipient ? spec.recipient.test(text) : text.includes(short) || /yourself|your own|to you\b/i.test(text);
const problems: string[] = [];
- if (!text.includes(spec.symbol)) problems.push(`no ${spec.symbol}`);
- // Amounts below the display precision show as "<0.000001 ETH" (the wallet's formatting); either form is right.
- if (!text.includes(spec.amount) && !new RegExp(`<0\\.0*1\\s*${spec.symbol}`).test(text)) problems.push(`amount ${spec.amount} not shown`);
- if (!recipientShown) problems.push(`recipient ${spec.recipient ?? `${short}…`} not shown`);
+ if (spec.call) {
+ if (!spec.call.test(view.title)) problems.push(`title "${view.title}" isn't ${spec.call}`);
+ } else {
+ if (!text.includes(spec.symbol)) problems.push(`no ${spec.symbol}`);
+ // Amounts below the display precision show as "<0.000001 ETH" (the wallet's formatting); either form is right.
+ if (!text.includes(spec.amount) && !new RegExp(`<0\\.0*1\\s*${spec.symbol}`).test(text)) problems.push(`amount ${spec.amount} not shown`);
+ if (!recipientShown) problems.push(`recipient ${spec.recipient ?? `${short}…`} not shown`);
+ }
if (view.overflowing.length) problems.push(`row values overflow their label: ${view.overflowing.join(", ")}`);
const fundsReason = view.notices.find((n) => /enough|can't pay|no \S+ yet|isn't on \S+ yet|doesn't exist (?:on \S+ )?yet|receive some \S+ first/i.test(n));
if (/unreadable|blind/i.test(view.title) && !funded && fundsReason) {
diff --git a/apps/extension/e2e/matrix/addresses.json b/apps/extension/e2e/matrix/addresses.json
index de11b2cd..6151ed87 100644
--- a/apps/extension/e2e/matrix/addresses.json
+++ b/apps/extension/e2e/matrix/addresses.json
@@ -1,5 +1,5 @@
{
- "//": "Public receive addresses of the dapp matrix wallet (account 0 per family, testnet), derived from DAPP_MATRIX_MNEMONIC with ClipVault defaults, exactly as the extension background derives them. The phrase lives only in the git-excluded .env.dapp-matrix. matrix.spec.ts checks every dapp connect returns these.",
+ "//": "Public receive addresses of the dapp matrix wallet (account 0 per family, testnet), derived from DAPP_MATRIX_MNEMONIC with ClipVault defaults, exactly as the extension background derives them. The phrase lives only in the git-excluded .env.dapp-matrix. matrix.spec.ts checks every dapp connect returns these. networks87 entries are keyed by matrix target; 'address' is the spelling on the target's testnet, 'vaultAddress' the vault's own (mainnet) spelling when it differs.",
"evm": {
"address": "0x05ACD02A8E18c130D902FB732bb6AD22DA4f2717",
"publicKey": "03d8ac1935c3bae4c197bd79e641ec34d576d58fb3c3fcf0dbc466b6a34b342928",
@@ -70,5 +70,77 @@
"address": "4BSAZBB73QMGRVOUSB7YIFKHKKZ5CDBH2PWDTZGWSLCXU7ID67DLM2C7V4",
"publicKey": "e0640c843fdc1868d5d4907f84154752b3d10c27d3ec39e4d692c57a7d03f7c6",
"path": "m/44'/283'/0'/0/0"
+ },
+ "cosmos": {
+ "address": "osmo1re86ed5ck45dnyex8ajvytrae4c5ct7xjm6edd",
+ "publicKey": "02996ee5775c1a4a3768f070349d88b29fae8fea7d893e39cd92f5258a7ee3fe75",
+ "path": "m/44'/118'/0'/0/0",
+ "vaultAddress": "cosmos1re86ed5ck45dnyex8ajvytrae4c5ct7x6qffml",
+ "dydx": "dydx1re86ed5ck45dnyex8ajvytrae4c5ct7xne8dmg",
+ "zig": "zig1re86ed5ck45dnyex8ajvytrae4c5ct7xm2vmeh"
+ },
+ "provenance": {
+ "address": "tp16f3hw77j3vwa3chtnplx3qs4sn2z4ww0cdzgzd",
+ "publicKey": "0259c124b3fa12acf5a0eada8a480e3741a61262c2c74c7a90a5add75aea642edb",
+ "path": "m/44'/505'/0'/0/0",
+ "vaultAddress": "pb16f3hw77j3vwa3chtnplx3qs4sn2z4ww0tx8yq8"
+ },
+ "thorchain": {
+ "address": "thor13pajudku86vd59c8wkr4ctwcxdy8qpnap6afpx",
+ "publicKey": "0312ef34472f7bda3a6897e21c526a629c396b9b777d7e1282d66664f3ce1f5036",
+ "path": "m/44'/931'/0'/0/0"
+ },
+ "initia": {
+ "address": "init1qkkdq25wrrqnpkgzldejhd4dytdy7fch0qn2v7",
+ "publicKey": "03d8ac1935c3bae4c197bd79e641ec34d576d58fb3c3fcf0dbc466b6a34b342928",
+ "path": "m/44'/60'/0'/0/0"
+ },
+ "tron": {
+ "address": "TGZxYSGcQHaZMcSB2c7ZB1n97yMk2UPz41",
+ "publicKey": "03ae797c9eb51a0664d6d6db08b7aa2093fea8b67ff7bdc32c372aec96c0e7142e",
+ "path": "m/44'/195'/0'/0/0"
+ },
+ "xrpl": {
+ "address": "rDvWLtQL3Furv3sVzERai1MXvKFTQjZgrM",
+ "publicKey": "03fe696b09c509d16c13df23756973df369940903dd7981b31c5b5289ef44407b1",
+ "path": "m/44'/144'/0'/0/0"
+ },
+ "antelope": {
+ "address": "PUB_K1_7VxUN24cMwi94AHSbLkSnbKNfwoN8wjV4Fv1FA8W7bXQdTVzro",
+ "publicKey": "0357ecf6e9045a00598d81f83b9e818b9dd3938b1473b9fea160cb284fc2af8e81",
+ "path": "m/44'/194'/0'/0/0"
+ },
+ "multiversx": {
+ "address": "erd1qhkcc53w80nppnsprtzs7k2wh7xwvt54c8u88d0cd6pn3n6ldudshmqgfg",
+ "publicKey": "05ed8c522e3be610ce011ac50f594ebf8ce62e95c1f873b5f86e8338cf5f6f1b",
+ "path": "m/44'/508'/0'/0'/0'"
+ },
+ "icp": {
+ "address": "gm6de-vncl6-w4cya-5z35g-cincb-f2zqe-6jhln-cnvjw-gvqf6-3shog-6ae",
+ "publicKey": "024441fa83b13a740213901f59b896d13723c08a4970c905026b5352a4e1537c98",
+ "path": "m/44'/223'/0'/0/0"
+ },
+ "stacks": {
+ "address": "ST1EC4TTQPQX24TT3VY2H6YYW99N0HRQ5VMRB2M1T",
+ "publicKey": "02eeedf4fbb22fb0a2d49fdcea998350db0533f76719fafde4c17d0484a0dd8b39",
+ "path": "m/44'/5757'/0'/0/0",
+ "vaultAddress": "SP1EC4TTQPQX24TT3VY2H6YYW99N0HRQ5VQXK4TEJ"
+ },
+ "fuel": {
+ "address": "0x1Cc3e8E1B340846d9Eb275D51435189deDe13f20801bbc3693B2D04c1B322A75",
+ "publicKey": "029369f709755465d14dcfae2ac912f7c8030e6e91c9d16f15ec3faae5fabe9083",
+ "path": "m/44'/1179993420'/0'/0/0"
+ },
+ "bitcoincash": {
+ "address": "bchtest:qq5jt8xqr6gckvpnn4dr4ajfd023mf7fzcj5tplmu2",
+ "publicKey": "0294f196fc1ba00ed17aa43b05b6e047e7ee2e958067bc76e6b91efd2dcbfd9de4",
+ "path": "m/44'/145'/0'/0/0",
+ "vaultAddress": "bitcoincash:qq5jt8xqr6gckvpnn4dr4ajfd023mf7fzckx0xavmk"
+ },
+ "chainflip": {
+ "address": "cFNXs2hYYRhGp1A4AfXaTbK3vQSgnSLvtvJrvJrCbNvhtTKc1",
+ "publicKey": "ce36fef24a0cbca4fff77554417922d15290a8296c08ef0759845140c1eab52d",
+ "path": "",
+ "vaultAddress": "5Gj64MPAV7C2ezdS5tpq4fVgDYteHCptB62RFxvaVyECHCQb"
}
}
diff --git a/apps/extension/e2e/matrix/chain.ts b/apps/extension/e2e/matrix/chain.ts
index 1ba28635..bea77bea 100644
--- a/apps/extension/e2e/matrix/chain.ts
+++ b/apps/extension/e2e/matrix/chain.ts
@@ -21,19 +21,41 @@ import { NEAR_TESTNET, createNearModule } from "@clip-wallet/chains-near";
import { STELLAR_TESTNET, createStellarModule } from "@clip-wallet/chains-stellar";
import { TEZOS_SHADOWNET, createTezosModule } from "@clip-wallet/chains-tezos";
import { ALGORAND_TESTNET, createAlgorandModule } from "@clip-wallet/chains-algorand";
+import { INITIA_TESTNET, OSMOSIS_TESTNET, PROVENANCE_TESTNET, createCosmosModule } from "@clip-wallet/chains-cosmos";
+import { TRON_NILE, createTronModule } from "@clip-wallet/chains-tron";
+import { STACKS_TESTNET, createStacksModule } from "@clip-wallet/chains-stacks";
+import { FUEL_TESTNET, createFuelModule } from "@clip-wallet/chains-fuel";
+import { BCH_CHIPNET, createBitcoinCashModule } from "@clip-wallet/chains-bitcoincash";
+import { MULTIVERSX_DEVNET, createMultiversXModule } from "@clip-wallet/chains-multiversx";
+import { ICP_TEST, createIcpModule } from "@clip-wallet/chains-icp";
+import { XRPL_TESTNET, createXrplModule } from "@clip-wallet/chains-xrpl";
+import { JUNGLE4, createAntelopeModule } from "@clip-wallet/chains-antelope";
import ADDRESSES from "./addresses.json" with { type: "json" };
export type MatrixFamily = Exclude;
-/** "hedera-evm" = Hedera testnet reached through 1Mask's EIP-1193 provider (the EVM account, chain 296). */
-export type Target = Family | "hedera-evm";
+/**
+ * "hedera-evm" = Hedera testnet reached through 1Mask's EIP-1193 provider (the EVM account, chain 296).
+ * networks87: "cosmos" is Osmosis testnet (osmo-test-5), "chainflip" the Chainflip Perseverance testnet (substrate
+ * family), "multiversx" devnet through sdk-dapp's undocumented custom-provider hook. Provenance, Initia, ICP, Bitcoin
+ * Cash and Antelope are funding/balance targets only: no injected dapp standard Clip can answer under its own identity
+ * (Bitcoin Cash's is WalletConnect; docs/r1/networks87.md), so they have no dapp page.
+ */
+export type Target =
+ | "evm" | "hedera-evm" | "hedera" | "solana" | "bitcoin" | "sui" | "aptos" | "cardano" | "substrate" | "starknet" | "ton" | "near" | "stellar" | "tezos" | "algorand"
+ | "cosmos" | "tron" | "stacks" | "fuel" | "chainflip" | "xrpl"
+ | "multiversx" | "provenance" | "initia" | "icp" | "bitcoincash" | "antelope";
interface Entry {
address: string;
publicKey: string;
path: string;
taproot?: string;
+ /** The vault's own spelling when the target network spells it differently (Cosmos prefixes, Stacks ST, bchtest:, cF…). */
+ vaultAddress?: string;
}
-const ADDR = ADDRESSES as unknown as Record;
+const ADDR = ADDRESSES as unknown as Record;
+/** addresses.json entry: per target where it has one (networks87), else per family. */
+const entryOf = (t: Target): Entry => ADDR[t] ?? ADDR[TARGETS[t].family]!;
const SEPOLIA = EVM_NETWORKS.find((n) => n.chainId === 11155111)!;
const HEDERA_EVM = HEDERA_EVM_NETWORKS.find((n) => n.chainId === 296)!;
@@ -55,29 +77,45 @@ export const TARGETS: Record ChainModu
stellar: { network: STELLAR_TESTNET, module: createStellarModule, family: "stellar", minimum: 15_000_000n, faucetHint: "1.5 XLM (account reserve 1 XLM + fees)" },
tezos: { network: TEZOS_SHADOWNET, module: createTezosModule, family: "tezos", minimum: 1_000_000n, faucetHint: "1 XTZ (shadownet; first op reveals the key)" },
algorand: { network: ALGORAND_TESTNET, module: createAlgorandModule, family: "algorand", minimum: 200_000n, faucetHint: "0.2 ALGO (min balance 0.1 + fees)" },
+ // networks87
+ cosmos: { network: OSMOSIS_TESTNET, module: () => createCosmosModule({ family: "cosmos" }), family: "cosmos", minimum: 100_000n, faucetHint: "0.1 OSMO (osmo-test-5)" },
+ tron: { network: TRON_NILE, module: createTronModule, family: "tron", minimum: 2_000_000n, faucetHint: "2 TRX (Nile)" },
+ stacks: { network: STACKS_TESTNET, module: createStacksModule, family: "stacks", minimum: 10_000n, faucetHint: "0.01 STX (testnet)" },
+ fuel: { network: FUEL_TESTNET, module: createFuelModule, family: "fuel", minimum: 200n, faucetHint: "0.0000002 ETH (Fuel testnet)" },
+ xrpl: { network: XRPL_TESTNET, module: createXrplModule, family: "xrpl", minimum: 1_100_000n, faucetHint: "1.1 XRP (testnet; 1 XRP base reserve + fees)" },
+ chainflip: { network: SUBSTRATE_NETWORKS.find((n) => n.name === "Chainflip Perseverance")!, module: createSubstrateModule, family: "substrate", minimum: 10n ** 17n, faucetHint: "0.1 tFLIP funded from Sepolia" },
+ provenance: { network: PROVENANCE_TESTNET, module: () => createCosmosModule({ family: "provenance" }), family: "provenance", minimum: 10n ** 9n, faucetHint: "1 HASH (pio-testnet-1)" },
+ initia: { network: INITIA_TESTNET, module: () => createCosmosModule({ family: "initia" }), family: "initia", minimum: 100_000n, faucetHint: "0.1 INIT (initiation-2)" },
+ multiversx: { network: MULTIVERSX_DEVNET, module: createMultiversXModule, family: "multiversx", minimum: 10n ** 16n, faucetHint: "0.01 xEGLD (devnet)" },
+ icp: { network: ICP_TEST, module: createIcpModule, family: "icp", minimum: 100_000n, faucetHint: "0.001 TESTICP" },
+ bitcoincash: { network: BCH_CHIPNET, module: createBitcoinCashModule, family: "bitcoincash", minimum: 5_000n, faucetHint: "5,000 sats (chipnet)" },
+ antelope: { network: JUNGLE4, module: createAntelopeModule, family: "antelope", minimum: 1_0000n, faucetHint: "an account for the key, then 1 EOS + CPU (Jungle4)" },
};
-export const TARGET_ORDER: Target[] = ["evm", "hedera-evm", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand"];
+export const TARGET_ORDER: Target[] = [
+ "evm", "hedera-evm", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand",
+ "cosmos", "tron", "stacks", "fuel", "xrpl", "chainflip", "multiversx", "provenance", "initia", "icp", "bitcoincash", "antelope",
+];
const hexToBytes = (h: string) => Uint8Array.from(Buffer.from(h.replace(/^0x/, ""), "hex"));
/** The matrix account for a target, shaped as the wallet's background holds it. */
export function accountOf(t: Target): Account {
const family = TARGETS[t].family;
- const e = ADDR[family];
+ const e = entryOf(t);
return {
id: `${family}:0`,
family,
index: 0,
- curve: "secp256k1",
+ curve: family === "substrate" ? "sr25519" : family === "multiversx" ? "ed25519" : "secp256k1",
derivationPath: e.path,
publicKey: e.publicKey,
- address: e.address,
+ address: e.vaultAddress ?? e.address,
} as Account;
}
-export const addressOf = (t: Target): string => ADDR[TARGETS[t].family].address;
-export const publicKeyOf = (t: Target): string => ADDR[TARGETS[t].family].publicKey;
+export const addressOf = (t: Target): string => entryOf(t).address;
+export const publicKeyOf = (t: Target): string => entryOf(t).publicKey;
const modules = new Map();
const moduleOf = (t: Target) => {
@@ -180,6 +218,20 @@ export function explorerTx(t: Target, id: string): string {
return `https://shadownet.tzkt.io/${id}`;
case "algorand":
return `https://lora.algokit.io/testnet/transaction/${id}`;
+ case "cosmos":
+ return `https://www.mintscan.io/osmosis-testnet/tx/${id}`;
+ case "tron":
+ return `https://nile.tronscan.org/#/transaction/${id}`;
+ case "stacks":
+ return `https://explorer.hiro.so/txid/${id.startsWith("0x") ? id : `0x${id}`}?chain=testnet`;
+ case "fuel":
+ return `https://app-testnet.fuel.network/tx/${id}`;
+ case "chainflip":
+ return `https://scan.perseverance.chainflip.io/blocks/${id}`;
+ case "xrpl":
+ return `https://testnet.xrpl.org/transactions/${id}`;
+ case "multiversx":
+ return `https://devnet-explorer.multiversx.com/transactions/${id}`;
default:
return `${n.explorerUrl}/tx/${id}`;
}
@@ -254,6 +306,34 @@ export async function confirmTx(t: Target, id: string, timeoutMs = 120_000): Pro
const r = await j(`https://api.shadownet.tzkt.io/v1/operations/${id}`).catch(() => []);
return Array.isArray(r) && r.length > 0 && r.every((o: { status?: string }) => o.status === "applied");
}
+ case "cosmos": {
+ const r = await j(`${n.rpcUrls[0]!.replace(/\/$/, "")}/cosmos/tx/v1beta1/txs/${id}`).catch(() => null);
+ return r?.tx_response?.code === 0 && Number(r.tx_response.height) > 0;
+ }
+ case "tron": {
+ const r = await j("https://nile.trongrid.io/wallet/gettransactioninfobyid", { method: "POST", body: JSON.stringify({ value: id }) }).catch(() => null);
+ return !!r?.blockNumber && (r.receipt?.result === undefined || r.receipt.result === "SUCCESS") && r.result !== "FAILED";
+ }
+ case "stacks": {
+ const r = await j(`https://api.testnet.hiro.so/extended/v1/tx/${id.startsWith("0x") ? id : `0x${id}`}`).catch(() => null);
+ return r?.tx_status === "success";
+ }
+ case "fuel": {
+ const r = await j("https://testnet.fuel.network/v1/graphql", { method: "POST", body: JSON.stringify({ query: `{ transaction(id: "${id}") { status { __typename } } }` }) }).catch(() => null);
+ return r?.data?.transaction?.status?.__typename === "SuccessStatus";
+ }
+ case "multiversx": {
+ const r = await j(`https://devnet-api.multiversx.com/transactions/${id}`).catch(() => null);
+ return r?.status === "success";
+ }
+ case "xrpl": {
+ const r = await rpc("https://s.altnet.rippletest.net:51234", "tx", [{ transaction: id }]).catch(() => null);
+ return r?.validated === true && r?.meta?.TransactionResult === "tesSUCCESS";
+ }
+ case "chainflip": {
+ const r = await rpc(n.rpcUrls[0]!, "chain_getBlock", [id]).catch(() => null);
+ return !!r;
+ }
case "algorand": {
const r = await fetch(`https://testnet-idx.4160.nodely.dev/v2/transactions/${id}`);
if (!r.ok) return false;
diff --git a/apps/extension/e2e/matrix/dapps/chainflip.ts b/apps/extension/e2e/matrix/dapps/chainflip.ts
new file mode 100644
index 00000000..b19b45c9
--- /dev/null
+++ b/apps/extension/e2e/matrix/dapps/chainflip.ts
@@ -0,0 +1,58 @@
+/**
+ * Chainflip State Chain (Perseverance testnet): the discovery and signing path lp.chainflip.io uses
+ * (@polkadot/extension-dapp over window.injectedWeb3, addresses re-encoded to ss58 2112 "cF…", signer.signRaw /
+ * signPayload). L2 is a signRaw verified with @polkadot/util-crypto signatureVerify. Chainflip has no plain FLIP
+ * transfer, so L3 sends the cheapest state-changing call an LP portal starts with, liquidityProvider.registerLpAccount,
+ * built with @polkadot/api and signed through the extension; every Chainflip call needs FLIP for its fee (NeedsFunds
+ * until the account is funded from Sepolia tFLIP).
+ */
+import { web3Accounts, web3Enable, web3FromSource } from "@polkadot/extension-dapp";
+import { ApiPromise, WsProvider } from "@polkadot/api";
+import { cryptoWaitReady, encodeAddress, signatureVerify } from "@polkadot/util-crypto";
+import { stringToHex, u8aWrapBytes } from "@polkadot/util";
+import { MESSAGE, NeedsFunds, expose } from "../dapp-kit";
+
+const RPC = "wss://perseverance.chainflip.xyz";
+let address = "";
+let source = "";
+
+expose({
+ info: { dapp: "@polkadot/extension-dapp + @polkadot/api in a local page, Chainflip Perseverance", why: "what lp.chainflip.io uses to reach Polkadot-style wallets; the portal itself needs a funded LP account" },
+ steps: {
+ connect: async () => {
+ const exts = await web3Enable("Clip dapp matrix");
+ const clip = exts.find((e) => e.name === "clip-wallet");
+ if (!clip) throw new Error(`clip-wallet not in injectedWeb3 (${exts.map((e) => e.name).join(", ")})`);
+ const accounts = await web3Accounts({ extensions: ["clip-wallet"] });
+ source = accounts[0]!.meta.source;
+ address = encodeAddress(accounts[0]!.address, 2112);
+ return { address, source };
+ },
+ sign: async () => {
+ await cryptoWaitReady();
+ const injector = await web3FromSource(source);
+ const { signature } = await injector.signer.signRaw!({ address, data: stringToHex(MESSAGE), type: "bytes" });
+ return { valid: signatureVerify(u8aWrapBytes(MESSAGE), signature, address).isValid, how: "@polkadot/util-crypto signatureVerify over -wrapped message" };
+ },
+ send: async () => {
+ const api = await ApiPromise.create({ provider: new WsProvider(RPC), noInitWarn: true });
+ const flip = (await api.query.flip!.account!(address)) as unknown as { balance: { toBigInt(): bigint } };
+ const injector = await web3FromSource(source);
+ const tx = api.tx.liquidityProvider!.registerLpAccount!();
+ if (flip.balance.toBigInt() === 0n) {
+ // Still let the wallet decode and sign it (the approval is L4), then stop before broadcasting.
+ await tx.signAsync(address, { signer: injector.signer });
+ await api.disconnect();
+ throw new NeedsFunds("signed, but the account has no FLIP for the fee (fund it from Sepolia tFLIP)");
+ }
+ const id = await new Promise((resolve, reject) => {
+ tx.signAndSend(address, { signer: injector.signer }, ({ status, dispatchError }) => {
+ if (dispatchError) reject(new Error(dispatchError.toString()));
+ else if (status.isInBlock) resolve(status.asInBlock.toHex());
+ }).catch(reject);
+ });
+ await api.disconnect();
+ return { id };
+ },
+ },
+});
diff --git a/apps/extension/e2e/matrix/dapps/cosmos.ts b/apps/extension/e2e/matrix/dapps/cosmos.ts
new file mode 100644
index 00000000..d5131a88
--- /dev/null
+++ b/apps/extension/e2e/matrix/dapps/cosmos.ts
@@ -0,0 +1,60 @@
+/**
+ * Cosmos (Osmosis testnet, osmo-test-5): the Keplr API as a Keplr dapp calls it, on Clip's own global
+ * (window.clipwallet.cosmos): enable + getOfflineSigner, whose getAccounts() cosmjs reads; ADR-36 signArbitrary
+ * verified the way Keplr's verifyADR36Amino does it (Keplr makeADR36AminoSignDoc rebuilt with @cosmjs/amino
+ * makeSignDoc + serializeSignDoc, sha256, @cosmjs/crypto Secp256k1.verifySignature, signer = pubkeyToAddress);
+ * and a 1-uosmo MsgSend to yourself with @cosmjs/stargate SigningStargateClient.signAndBroadcast (SIGN_MODE_DIRECT
+ * through the offline signer). @keplr-wallet/cosmos itself isn't bundled: its tiny-secp256k1 dependency needs a
+ * native build the workspace doesn't allow.
+ */
+import { makeSignDoc, pubkeyToAddress, serializeSignDoc } from "@cosmjs/amino";
+import { Secp256k1, Secp256k1Signature, sha256 } from "@cosmjs/crypto";
+import { fromBase64, toBase64, toUtf8 } from "@cosmjs/encoding";
+import { GasPrice, SigningStargateClient, assertIsDeliverTxSuccess } from "@cosmjs/stargate";
+import { MESSAGE, NeedsFunds, expose, waitFor } from "../dapp-kit";
+
+const CHAIN_ID = "osmo-test-5";
+const RPC = "https://rpc.osmotest5.osmosis.zone";
+const REST = "https://lcd.osmotest5.osmosis.zone";
+
+interface KeplrLike {
+ enable(chainIds: string | string[]): Promise;
+ getOfflineSigner(chainId: string): { getAccounts(): Promise<{ address: string; algo: string; pubkey: Uint8Array }[]> } & Parameters[1];
+ signArbitrary(chainId: string, signer: string, data: string): Promise<{ pub_key: { type: string; value: string }; signature: string }>;
+}
+
+const provider = () => waitFor(() => (window as unknown as { clipwallet?: { cosmos?: KeplrLike } }).clipwallet?.cosmos, "window.clipwallet.cosmos");
+let address = "";
+
+expose({
+ info: { dapp: "Keplr API on window.clipwallet.cosmos + @cosmjs/stargate SigningStargateClient in a local page, osmo-test-5", why: "Osmosis dapps talk to Keplr's API through cosmjs; the hosted Osmosis frontend only knows wallet-specific globals" },
+ steps: {
+ connect: async () => {
+ const k = await provider();
+ await k.enable(CHAIN_ID);
+ const accounts = await k.getOfflineSigner(CHAIN_ID).getAccounts();
+ address = accounts[0]!.address;
+ return { address, algo: accounts[0]!.algo, accounts: accounts.length };
+ },
+ sign: async () => {
+ const k = await provider();
+ const sig = await k.signArbitrary(CHAIN_ID, address, MESSAGE);
+ const doc = makeSignDoc([{ type: "sign/MsgSignData", value: { signer: address, data: toBase64(toUtf8(MESSAGE)) } }], { gas: "0", amount: [] }, "", "", 0, 0);
+ const signerOk = pubkeyToAddress(sig.pub_key, "osmo") === address;
+ const valid = signerOk && (await Secp256k1.verifySignature(Secp256k1Signature.fromFixedLength(fromBase64(sig.signature)), sha256(serializeSignDoc(doc)), fromBase64(sig.pub_key.value)));
+ return { valid, how: "ADR-36 amino doc (Keplr makeADR36AminoSignDoc) via @cosmjs/amino, sha256, @cosmjs/crypto Secp256k1.verifySignature" };
+ },
+ send: async () => {
+ const k = await provider();
+ const res = await fetch(`${REST}/cosmos/auth/v1beta1/accounts/${address}`);
+ if (res.status === 404) throw new NeedsFunds("the account doesn't exist on osmo-test-5 yet");
+ // Osmosis x/txfees: the EIP-1559 base fee, with headroom, as the Osmosis frontend does.
+ const base = (await (await fetch(`${REST}/osmosis/txfees/v1beta1/cur_eip_base_fee`)).json()) as { base_fee?: string };
+ const price = Math.max(0.025, Number(base.base_fee ?? "0.025") * 1.5);
+ const client = await SigningStargateClient.connectWithSigner(RPC, k.getOfflineSigner(CHAIN_ID), { gasPrice: GasPrice.fromString(`${price.toFixed(6)}uosmo`) });
+ const r = await client.signAndBroadcast(address, [{ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: { fromAddress: address, toAddress: address, amount: [{ denom: "uosmo", amount: "1" }] } }], "auto", "Clip Wallet dapp matrix");
+ assertIsDeliverTxSuccess(r);
+ return { id: r.transactionHash };
+ },
+ },
+});
diff --git a/apps/extension/e2e/matrix/dapps/fuel.ts b/apps/extension/e2e/matrix/dapps/fuel.ts
new file mode 100644
index 00000000..04443f7f
--- /dev/null
+++ b/apps/extension/e2e/matrix/dapps/fuel.ts
@@ -0,0 +1,72 @@
+/**
+ * Fuel (testnet): fuels-ts `Fuel` connector manager (the Fuel connector standard, discovery by the "FuelConnector"
+ * window event) picking Clip's connector from its connectors list, connect + accounts, Account.signMessage verified
+ * with fuels-ts `Signer.recoverAddress(hashMessage(msg), sig)`, and a 1-unit base-asset (ETH) transfer to yourself
+ * with `wallet.transfer`, which assembles the transaction on testnet.fuel.network and hands it to the connector.
+ */
+import type { FuelConnector } from "fuels";
+// fuels' own self-contained browser build. The package's "exports" map only offers the Node build, whose
+// @fuel-ts/crypto imports pbkdf2Sync/createHmac/randomUUID from node:crypto, which the matrix bundler stubs out.
+// @ts-ignore: the browser build has no declaration file; it is typed as the package just below.
+import * as fuelsBrowser from "../../../node_modules/fuels/dist/browser.min.mjs";
+import { MESSAGE, NeedsFunds, expose } from "../dapp-kit";
+
+const { Fuel, Provider, Signer, hashMessage } = fuelsBrowser as typeof import("fuels");
+
+const TESTNET = "https://testnet.fuel.network/v1/graphql";
+const fuel = new Fuel({ storage: null });
+let address = "";
+
+type Connector = FuelConnector;
+const clipConnector = () => (window as unknown as { clipwallet?: { fuel?: Connector } }).clipwallet?.fuel;
+
+/** Clip's connector from the Fuel connectors list (it announces itself; re-announce if this Fuel came up later). */
+async function pickClip(): Promise {
+ const own = clipConnector();
+ if (!own) throw new Error("window.clipwallet.fuel not available");
+ for (let i = 0; i < 40; i++) {
+ const found = (await fuel.connectors()).find((c) => c === own || c.name === own.name);
+ if (found) {
+ await fuel.selectConnector(found.name);
+ return found;
+ }
+ // The documented discovery event, with the connector the wallet put on window (a Fuel created after the
+ // wallet's own announcements missed them).
+ if (i === 10) window.dispatchEvent(new CustomEvent("FuelConnector", { detail: own }));
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ throw new Error("Clip's connector never showed up in Fuel's connectors list");
+}
+
+expose({
+ info: {
+ dapp: "fuels-ts Fuel connector manager (FuelConnector event discovery) + fuels Provider in a local page, Fuel testnet",
+ why: "fuels-ts Fuel is the connector API every Fuel dapp (and @fuels/react) uses; a local page avoids a dapp's own build and its WalletConnect setup",
+ },
+ steps: {
+ connect: async () => {
+ const c = await pickClip();
+ if (!(await fuel.connect())) throw new Error("connect was declined");
+ const accounts = await fuel.accounts();
+ address = (await fuel.currentAccount()) ?? accounts[0] ?? "";
+ if (!address) throw new Error("no Fuel account");
+ const network = await fuel.currentNetwork();
+ return { address, connector: c.name, accounts, chainId: network.chainId };
+ },
+ sign: async () => {
+ const wallet = await fuel.getWallet(address, new Provider(TESTNET));
+ const signature = await wallet.signMessage(MESSAGE);
+ const recovered = Signer.recoverAddress(hashMessage(MESSAGE), signature).toString();
+ return { valid: recovered.toLowerCase() === address.toLowerCase(), how: "fuels-ts Signer.recoverAddress(hashMessage(message), signature)", signature };
+ },
+ send: async () => {
+ const provider = new Provider(TESTNET);
+ const wallet = await fuel.getWallet(address, provider);
+ const balance = await wallet.getBalance(await provider.getBaseAssetId());
+ if (balance.isZero()) throw new NeedsFunds("the Fuel testnet account has no ETH (faucet-testnet.fuel.network)");
+ const tx = await wallet.transfer(address, 1);
+ const result = await tx.waitForResult();
+ return { id: tx.id, status: result.status };
+ },
+ },
+});
diff --git a/apps/extension/e2e/matrix/dapps/multiversx.ts b/apps/extension/e2e/matrix/dapps/multiversx.ts
new file mode 100644
index 00000000..2363ea48
--- /dev/null
+++ b/apps/extension/e2e/matrix/dapps/multiversx.ts
@@ -0,0 +1,78 @@
+/**
+ * MultiversX (devnet): @multiversx/sdk-dapp 5.7.3 driven headless (no unlock panel), the way an sdk-dapp dapp logs in
+ * once the user picked a provider.
+ * - connect: `initApp({ dAppConfig: { environment: "devnet", nativeAuth: false } })`, which merges
+ * `window.multiversx.providers` into `ProviderFactory.customProviders` (sdk-dapp's UNDOCUMENTED custom-provider hook:
+ * 1Mask adds Clip's own entry there, type "clipwallet"), then `ProviderFactory.create({ type: "clipwallet" })` and
+ * `login()` (native auth off here: with it, login() also asks for a sign-in signature, a second approval).
+ * - sign: `signMessage(new Message({ data }))` through sdk-dapp's DappProvider, checked with sdk-dapp's own
+ * `verifyMessage` (MessageComputer + UserVerifier).
+ * - send: a 1-attoEGLD transfer to yourself built with sdk-core 15, signed through DappProvider.signTransactions (sdk-dapp
+ * sets the nonce) and sent to the devnet gateway's /transaction/send.
+ * The page fails at connect if Clip's entry isn't in the custom-provider list: dapps that replace `window.multiversx`
+ * (MultiversX's template dapp does) never see it.
+ */
+import { Address, Message, MessageComputer, Transaction } from "@multiversx/sdk-core";
+import type { initApp as InitApp } from "@multiversx/sdk-dapp/out/methods/initApp/initApp";
+import type { verifyMessage as VerifyMessage } from "@multiversx/sdk-dapp/out/providers/DappProvider/helpers/signMessage/verifyMessage";
+import type { ProviderFactory as PF } from "@multiversx/sdk-dapp/out/providers/ProviderFactory";
+// sdk-dapp 5 has no package "exports"/"main": bundlers need the .mjs files, TypeScript finds the types next to them
+// without the extension (imported as types above).
+// @ts-expect-error untyped .mjs path
+import { initApp as initAppImpl } from "@multiversx/sdk-dapp/out/methods/initApp/initApp.mjs";
+// @ts-expect-error untyped .mjs path
+import { verifyMessage as verifyMessageImpl } from "@multiversx/sdk-dapp/out/providers/DappProvider/helpers/signMessage/verifyMessage.mjs";
+// @ts-expect-error untyped .mjs path
+import { ProviderFactory as ProviderFactoryImpl } from "@multiversx/sdk-dapp/out/providers/ProviderFactory.mjs";
+import { MESSAGE, NeedsFunds, expose } from "../dapp-kit";
+
+const initApp = initAppImpl as typeof InitApp;
+const verifyMessage = verifyMessageImpl as typeof VerifyMessage;
+const ProviderFactory = ProviderFactoryImpl as typeof PF;
+
+const GATEWAY = "https://devnet-gateway.multiversx.com";
+const TYPE = "clipwallet";
+
+type DappProvider = Awaited>;
+let provider: DappProvider;
+let address = "";
+
+expose({
+ info: {
+ dapp: "@multiversx/sdk-dapp 5.7.3 initApp + ProviderFactory (custom provider from window.multiversx.providers), native auth, devnet",
+ why: "sdk-dapp is how MultiversX dapps connect wallets; it has no wallet discovery, so Clip lists itself through the undocumented window.multiversx.providers hook",
+ },
+ steps: {
+ connect: async () => {
+ // Native auth off for L1: with it, login() also asks for a sign-in signature (a second approval the matrix's
+ // connect level doesn't answer). L2 signs a message instead.
+ await initApp({ dAppConfig: { environment: "devnet", nativeAuth: false } } as Parameters[0]);
+ const entry = (ProviderFactory.customProviders as { type: string; name: string }[]).find((p) => p.type === TYPE);
+ if (!entry) throw new Error("Clip Wallet isn't in sdk-dapp's custom providers (window.multiversx.providers)");
+ provider = await ProviderFactory.create({ type: TYPE });
+ const login = await provider.login();
+ if (!login?.address) throw new Error("login returned no address");
+ address = login.address;
+ return { address, name: entry.name };
+ },
+ sign: async () => {
+ const signed = await provider.signMessage(new Message({ address: Address.newFromBech32(address), data: new TextEncoder().encode(MESSAGE) }));
+ if (!signed) throw new Error("no signed message");
+ const r = await verifyMessage(JSON.stringify(new MessageComputer().packMessage(signed)));
+ return { valid: r.isVerified && r.address === address && r.message === MESSAGE, how: "sdk-dapp verifyMessage (MessageComputer + UserVerifier)" };
+ },
+ send: async () => {
+ const acct = (await (await fetch(`${GATEWAY}/address/${address}`)).json()) as { data?: { account?: { balance?: string } } };
+ if (BigInt(acct.data?.account?.balance ?? "0") < 100_000_000_000_000n) throw new NeedsFunds("the account has no devnet EGLD for the fee (devnet wallet faucet)");
+ const me = Address.newFromBech32(address);
+ const tx = new Transaction({ sender: me, receiver: me, value: 1n, gasLimit: 50_000n, gasPrice: 1_000_000_000n, chainID: "D", version: 2 });
+ const [signed] = await provider.signTransactions([tx]);
+ if (!signed) throw new Error("no signed transaction");
+ const r = (await (
+ await fetch(`${GATEWAY}/transaction/send`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(signed.toPlainObject()) })
+ ).json()) as { data?: { txHash?: string }; error?: string };
+ if (!r.data?.txHash) throw new Error(`devnet refused: ${r.error ?? "no hash"}`);
+ return { id: r.data.txHash };
+ },
+ },
+});
diff --git a/apps/extension/e2e/matrix/dapps/stacks.ts b/apps/extension/e2e/matrix/dapps/stacks.ts
new file mode 100644
index 00000000..1863ba63
--- /dev/null
+++ b/apps/extension/e2e/matrix/dapps/stacks.ts
@@ -0,0 +1,59 @@
+/**
+ * Stacks (testnet): @stacks/connect v8 `request()` (SIP-030) against the wallet it finds through WBIP-004
+ * (`window.wbip_providers`, the registry Connect's own wallet picker reads): getAddresses, stx_signMessage verified with
+ * @stacks/encryption verifyMessageSignatureRsv (and the address re-derived from the returned key), and a 1 µSTX
+ * stx_transferStx that the wallet builds, signs and broadcasts. Stacks nodes refuse a transfer to the sender itself
+ * (mempool rejection TransferRecipientIsSender), so the µSTX goes to the testnet boot address ST000…2AMW42H.
+ * Unfunded: the wallet still decodes and signs (broadcast: false), then NeedsFunds.
+ */
+import { request } from "@stacks/connect";
+import { verifyMessageSignatureRsv } from "@stacks/encryption";
+import { getAddressFromPublicKey } from "@stacks/transactions";
+import { MESSAGE, NeedsFunds, expose, waitFor } from "../dapp-kit";
+
+const HIRO = "https://api.testnet.hiro.so";
+/** Testnet boot address (the boot contracts' deployer): nobody's account, any µSTX sent there just stays. */
+const SINK = "ST000000000000000000002AMW42H";
+type Provider = Parameters[0] extends infer O ? (O extends { provider?: infer P } ? P : never) : never;
+let provider: Provider;
+let address = "";
+let publicKey = "";
+
+/** @stacks/connect-ui `getProviderFromId`: the WBIP-004 id is the provider's path on window. */
+const fromId = (id: string) => id.split(".").reduce((o, k) => o?.[k], window);
+
+expose({
+ info: {
+ dapp: "@stacks/connect 8 request() (SIP-030) + WBIP-004 discovery + @stacks/encryption in a local page, testnet",
+ why: "Connect's request() is what Stacks dapps call (the picker modal lists wbip_providers; the page picks Clip's entry instead of clicking the modal)",
+ },
+ steps: {
+ connect: async () => {
+ const entry = await waitFor(
+ () => ((window as unknown as { wbip_providers?: { id: string; name: string }[] }).wbip_providers ?? []).find((p) => p.name === "Clip Wallet"),
+ "Clip Wallet in window.wbip_providers",
+ );
+ provider = fromId(entry.id);
+ if (!provider) throw new Error(`${entry.id} not on window`);
+ const r = await request({ provider, enableLocalStorage: false }, "getAddresses", { network: "testnet" });
+ const stx = r.addresses.find((a) => a.address.startsWith("ST"));
+ if (!stx) throw new Error(`no testnet STX address in ${JSON.stringify(r.addresses)}`);
+ address = stx.address;
+ publicKey = stx.publicKey;
+ return { address, publicKey, providerId: entry.id };
+ },
+ sign: async () => {
+ const r = await request({ provider, enableLocalStorage: false }, "stx_signMessage", { message: MESSAGE });
+ const valid = verifyMessageSignatureRsv({ message: MESSAGE, signature: r.signature, publicKey: r.publicKey }) && getAddressFromPublicKey(r.publicKey, "testnet") === address;
+ return { valid, how: "@stacks/encryption verifyMessageSignatureRsv (\\x17Stacks Signed Message prefix) + getAddressFromPublicKey === connected address" };
+ },
+ send: async () => {
+ const bal = await fetch(`${HIRO}/extended/v1/address/${address}/stx`).then((x) => (x.ok ? (x.json() as Promise<{ balance?: string }>) : { balance: "0" }));
+ const funded = BigInt(bal.balance ?? "0") > 10_000n;
+ const r = await request({ provider, enableLocalStorage: false }, "stx_transferStx", { recipient: SINK, amount: "1", memo: "clip matrix", network: "testnet", ...(funded ? {} : { broadcast: false }) } as never);
+ if (!funded) throw new NeedsFunds("signed, but the account has no testnet STX");
+ if (!r.txid) throw new Error(`no txid in ${JSON.stringify(r)}`);
+ return { id: r.txid, publicKey };
+ },
+ },
+});
diff --git a/apps/extension/e2e/matrix/dapps/tron.ts b/apps/extension/e2e/matrix/dapps/tron.ts
new file mode 100644
index 00000000..0a5d08c6
--- /dev/null
+++ b/apps/extension/e2e/matrix/dapps/tron.ts
@@ -0,0 +1,74 @@
+/**
+ * TRON (Nile): discovery over TIP-6963 and the TIP-1193 provider it announces, as a TRON dapp written against the
+ * standards does it; TronWeb 6 for everything else.
+ * - connect: `TIP6963:requestProvider` → pick Clip's announcement by rdns → `eth_requestAccounts` (TIP-1102) on Nile
+ * (`wallet_switchEthereumChain`, TIP-3326, if the provider starts elsewhere).
+ * - sign: `provider.tronWeb.trx.signMessageV2` (TIP-104), checked with TronWeb's static `Trx.verifyMessageV2`, which
+ * recovers the signer's address.
+ * - send: a 1-sun TRX transfer built with TronWeb's transactionBuilder.sendTrx (no key), signed with
+ * `provider.tronWeb.trx.sign` and broadcast with TronWeb. TRON refuses transfers to yourself ("Cannot transfer TRX
+ * to yourself"), so it goes to the zero "black hole" address, which exists on Nile (no account-opening fee).
+ * No @tronweb3/tronwallet-adapters: none of its adapters connects a generic TIP-6963 wallet (TronLinkAdapter and
+ * TokenPocketAdapter listen to TIP-6963 but take only their own wallet's announcement, `info.name === "TronLink"` / …).
+ */
+import { TronWeb, Trx } from "tronweb";
+import { MESSAGE, NeedsFunds, expose, waitFor } from "../dapp-kit";
+
+const NILE = "https://nile.trongrid.io";
+const NILE_CHAIN_ID = "0xcd8690dc";
+const BLACK_HOLE = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb";
+const RDNS = "org.coldai.clipwallet";
+
+interface Tip1193 {
+ request(a: { method: string; params?: unknown }): Promise;
+ tronWeb: { trx: { sign(tx: unknown): Promise<{ txID: string; signature: string[] }>; signMessageV2(m: string): Promise } };
+}
+interface Announced {
+ info: { uuid: string; name: string; icon: string; rdns: string };
+ provider: Tip1193;
+}
+
+const tronWeb = new TronWeb({ fullHost: NILE });
+const announced: Announced[] = [];
+window.addEventListener("TIP6963:announceProvider", (e) => announced.push((e as CustomEvent).detail));
+window.dispatchEvent(new Event("TIP6963:requestProvider"));
+
+let provider: Tip1193;
+let address = "";
+
+expose({
+ info: {
+ dapp: "TIP-6963 discovery + TIP-1193 provider + TronWeb 6 in a local page, Nile",
+ why: "TIP-6963/TIP-1193 is the TRON standard for injected wallets; tronwallet-adapters has no adapter for a generic TIP-6963 wallet",
+ },
+ steps: {
+ connect: async () => {
+ const d = await waitFor(() => announced.find((a) => a.info.rdns === RDNS), "Clip Wallet (TIP-6963)");
+ provider = d.provider;
+ if ((await provider.request({ method: "eth_chainId" })) !== NILE_CHAIN_ID) {
+ await provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: NILE_CHAIN_ID }] });
+ }
+ const accounts = (await provider.request({ method: "eth_requestAccounts" })) as string[];
+ address = accounts[0]!;
+ return { address, chainId: await provider.request({ method: "eth_chainId" }), name: d.info.name };
+ },
+ sign: async () => {
+ const signature = await provider.tronWeb.trx.signMessageV2(MESSAGE);
+ return { valid: Trx.verifyMessageV2(MESSAGE, signature) === address, how: "TronWeb Trx.verifyMessageV2 (TIP-104 signMessageV2)" };
+ },
+ send: async () => {
+ // java-tron can't build a transaction for an account that doesn't exist yet.
+ const account = (await tronWeb.trx.getAccount(address)) as { address?: string };
+ if (!account.address) throw new NeedsFunds("the account doesn't exist on Nile yet (receive TRX first)");
+ const tx = await tronWeb.transactionBuilder.sendTrx(BLACK_HOLE, 1, address);
+ const signed = await provider.tronWeb.trx.sign(tx);
+ const r = (await tronWeb.trx.sendRawTransaction(signed as never)) as { result?: boolean; txid?: string; code?: string; message?: string };
+ if (!r.result) {
+ const why = r.message && /^[0-9a-f]+$/i.test(r.message) ? new TextDecoder().decode(Uint8Array.from(r.message.match(/../g)!.map((h) => parseInt(h, 16)))) : r.message;
+ if (/balance is not sufficient|account resource insufficient/i.test(why ?? "")) throw new NeedsFunds(`Nile refused: ${why}`);
+ throw new Error(`Nile refused: ${r.code ?? ""} ${why ?? ""}`.trim());
+ }
+ return { id: r.txid ?? signed.txID };
+ },
+ },
+});
diff --git a/apps/extension/e2e/matrix/dapps/xrpl.ts b/apps/extension/e2e/matrix/dapps/xrpl.ts
new file mode 100644
index 00000000..91599255
--- /dev/null
+++ b/apps/extension/e2e/matrix/dapps/xrpl.ts
@@ -0,0 +1,50 @@
+/**
+ * XRP Ledger (testnet, xrpl:1): XLS-72d Browser Wallet Standard discovery exactly as @xrpl-wallet-standard/app's
+ * getRegisterdXRPLWallets does it (getWallets() filtered by REQUIRED_FEATURES: standard:connect, standard:events,
+ * xrpl:signTransaction, xrpl:signAndSubmitTransaction), then xrpl:signAndSubmitTransaction with options.autofill.
+ *
+ * L2 (sign a message) doesn't exist: XLS-72d has no message-signing feature, so there's no `sign` step.
+ * L3 is a no-op AccountSet carrying a memo, not "1 drop to yourself": rippled refuses an XRP Payment to the sending
+ * account (temREDUNDANT), so it never reaches a ledger. The AccountSet costs only the fee and changes nothing.
+ */
+import { getWallets } from "@wallet-standard/app";
+import { NeedsFunds, expose, waitFor } from "../dapp-kit";
+
+// testnet.xrpl-labs.com answers CORS preflights from a page; s.altnet.rippletest.net:51234 does not.
+const RPC = "https://testnet.xrpl-labs.com/";
+/** @xrpl-wallet-standard/core REQUIRED_FEATURES (packages/core/src/utils.ts). */
+const REQUIRED_FEATURES = ["standard:connect", "standard:events", "xrpl:signTransaction", "xrpl:signAndSubmitTransaction"] as const;
+type F = Record;
+
+let wallet: ReturnType["get"]>[number];
+let account: (typeof wallet)["accounts"][number];
+
+async function rpc(method: string, params: Record) {
+ const res = await fetch(RPC, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ method, params: [params] }) });
+ return ((await res.json()) as { result: Record }).result;
+}
+
+expose({
+ info: {
+ dapp: "XLS-72d Wallet Standard discovery (@wallet-standard/app + the @xrpl-wallet-standard/app filter) in a local page, testnet",
+ why: "@xrpl-wallet-standard/app only wraps getWallets() with that filter but pulls in xrpl.js and an alpha Xahau fork; the page runs the same filter",
+ },
+ steps: {
+ connect: async () => {
+ const xrpl = () => getWallets().get().filter((w) => REQUIRED_FEATURES.every((f) => f in w.features));
+ wallet = await waitFor(() => xrpl().find((w) => w.name === "Clip Wallet"), "Clip Wallet (XRPL)");
+ const r = await (wallet.features as F)["standard:connect"].connect();
+ account = r.accounts[0]!;
+ return { address: account.address, chains: wallet.chains };
+ },
+ send: async () => {
+ const info = await rpc("account_info", { account: account.address, ledger_index: "current" });
+ if (info.status === "error") throw new NeedsFunds(`the account isn't activated on testnet yet (${String(info.error)})`);
+ const tx_json = { TransactionType: "AccountSet", Account: account.address, Memos: [{ Memo: { MemoData: Array.from(new TextEncoder().encode("clip dapp matrix"), (b) => b.toString(16).padStart(2, "0")).join("").toUpperCase() } }] };
+ const r = await (wallet.features as F)["xrpl:signAndSubmitTransaction"].signAndSubmitTransaction({ tx_json, account, network: "xrpl:1", options: { autofill: true } });
+ const result = r.tx_json?.meta?.TransactionResult;
+ if (r.tx_json?.validated && result !== "tesSUCCESS") throw new Error(`the ledger answered ${result}`);
+ return { id: r.tx_hash, validated: !!r.tx_json?.validated };
+ },
+ },
+});
diff --git a/apps/extension/e2e/phase2.spec.ts b/apps/extension/e2e/phase2.spec.ts
index 3a08e2ef..55faa2c7 100644
--- a/apps/extension/e2e/phase2.spec.ts
+++ b/apps/extension/e2e/phase2.spec.ts
@@ -1,5 +1,6 @@
/**
- * Phase 2: all 14 chain families (real wiring) and the new screens (fixture mode).
+ * Phase 2 and networks87: every chain family the build ships (real wiring) and the new screens (fixture mode).
+ * THORChain has no public testnet, so a testnet build has no THORChain account to list.
*/
import path from "node:path";
import type { Page } from "@playwright/test";
@@ -25,12 +26,28 @@ const ADDRESS: Record = {
stellar: /^G[A-Z2-7]{55}$/,
tezos: /^tz1[1-9A-HJ-NP-Za-km-z]{33}$/,
algorand: /^[A-Z2-7]{58}$/,
+ // networks87: the vault's own (mainnet) spelling; Receive spells it per network (receiveAddress).
+ cosmos: /^cosmos1[02-9ac-hj-np-z]{38}$/,
+ provenance: /^pb1[02-9ac-hj-np-z]{38}$/,
+ initia: /^init1[02-9ac-hj-np-z]{38}$/,
+ tron: /^T[1-9A-HJ-NP-Za-km-z]{33}$/,
+ xrpl: /^r[1-9A-HJ-NP-Za-km-z]{24,34}$/,
+ antelope: /^PUB_K1_[1-9A-HJ-NP-Za-km-z]{50}$/,
+ multiversx: /^erd1[02-9ac-hj-np-z]{58}$/,
+ icp: /^([a-z2-7]{5}-){10}[a-z2-7]{3}$/,
+ stacks: /^SP[0-9A-HJKMNP-TV-Z]{38,39}$/,
+ fuel: /^0x[0-9a-fA-F]{64}$/,
+ bitcoincash: /^bitcoincash:q[02-9ac-hj-np-z]{41}$/,
};
/** One coin per family, as Receive lists them (Substrate testnets: Westend and Paseo). */
-const RECEIVE_SYMBOLS = ["ETH", "HBAR", "SOL", "BTC", "SUI", "APT", "ADA", "WND", "PAS", "STRK", "GRAM", "NEAR", "XLM", "XTZ", "ALGO"];
+const RECEIVE_SYMBOLS = [
+ "ETH", "HBAR", "SOL", "BTC", "SUI", "APT", "ADA", "WND", "PAS", "STRK", "GRAM", "NEAR", "XLM", "XTZ", "ALGO",
+ // networks87 testnets (Chainflip Perseverance's FLIP, Jungle4's EOS, MultiversX devnet EGLD, ICP test ledger TESTICP)
+ "OSMO", "ZIG", "HASH", "INIT", "TRX", "XRP", "EOS", "TLOS", "XPR", "EGLD", "TESTICP", "STX", "BCH", "FLIP",
+];
-extensionTest(REAL_BUILD)("real: accounts for all 14 families derive after onboarding and show in Receive", async ({ context, extensionId }) => {
+extensionTest(REAL_BUILD)("real: accounts for every family derive after onboarding and show in Receive", async ({ context, extensionId }) => {
const page = await openPage(context, extensionId, "popup.html");
const shot = shotOf(page);
await onboard(page);
@@ -64,6 +81,13 @@ extensionTest(REAL_BUILD)("real: accounts for all 14 families derive after onboa
await expect(page.getByTestId("receive-address")).toHaveText(sui);
await expect(page.getByRole("img", { name: /QR code/ })).toBeVisible();
await shot("receive-sui");
+
+ // networks87: an address spelled per network. OSMO on osmo-test-5 receives on the osmo1… form of the vault's key.
+ await page.getByRole("button", { name: "Back", exact: true }).click();
+ await page.getByRole("button", { name: "Receive" }).click();
+ await page.locator(".clip-asset-row", { has: page.locator(".clip-asset-row__symbol", { hasText: /^OSMO$/ }) }).first().click();
+ await expect(page.getByTestId("receive-address")).toHaveText(/^osmo1[02-9ac-hj-np-z]{38}$/);
+ await shot("receive-osmo");
});
extensionTest(FIXTURE_BUILD)("fixtures: Stake, Swap, Explore, Backup and Accounts screens", async ({ context, extensionId }) => {
diff --git a/apps/extension/e2e/shots/matrix/chainflip-approval.png b/apps/extension/e2e/shots/matrix/chainflip-approval.png
new file mode 100644
index 00000000..b266597e
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/chainflip-approval.png differ
diff --git a/apps/extension/e2e/shots/matrix/chainflip-sign.png b/apps/extension/e2e/shots/matrix/chainflip-sign.png
new file mode 100644
index 00000000..de35715b
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/chainflip-sign.png differ
diff --git a/apps/extension/e2e/shots/matrix/cosmos-sign.png b/apps/extension/e2e/shots/matrix/cosmos-sign.png
new file mode 100644
index 00000000..66a5cd00
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/cosmos-sign.png differ
diff --git a/apps/extension/e2e/shots/matrix/fuel-sign.png b/apps/extension/e2e/shots/matrix/fuel-sign.png
new file mode 100644
index 00000000..75ccd56b
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/fuel-sign.png differ
diff --git a/apps/extension/e2e/shots/matrix/multiversx-sign.png b/apps/extension/e2e/shots/matrix/multiversx-sign.png
new file mode 100644
index 00000000..1039009a
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/multiversx-sign.png differ
diff --git a/apps/extension/e2e/shots/matrix/results.json b/apps/extension/e2e/shots/matrix/results.json
index 3832c0d9..5bf15a8a 100644
--- a/apps/extension/e2e/shots/matrix/results.json
+++ b/apps/extension/e2e/shots/matrix/results.json
@@ -34,12 +34,12 @@
},
"approval": {
"status": "pass",
- "why": "Send <0.000001 ETH to 0x05AC…2717",
+ "why": "Send <0.000001 ETH to 0x05AC\u20262717",
"detail": {
- "title": "Send <0.000001 ETH to 0x05AC…2717",
+ "title": "Send <0.000001 ETH to 0x05AC\u20262717",
"network": "Sepolia",
"rows": "Fee $0.07 Ready in about 10 seconds To 0x05ACD02A8E18c130D902FB732bb6AD22DA4f2717 Amount <0.000001 ETH Requested by matrix-dapp.example Network fee at most 0.000074 ETH",
- "details": "1 Network fee 2 Send <0.000001 ETH to 0x05AC…2717 If it fails, nothing leaves your balance.",
+ "details": "1 Network fee 2 Send <0.000001 ETH to 0x05AC\u20262717 If it fails, nothing leaves your balance.",
"notices": [
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
],
@@ -96,12 +96,12 @@
},
"approval": {
"status": "pass",
- "why": "Send <0.000001 HBAR to 0x05AC…2717",
+ "why": "Send <0.000001 HBAR to 0x05AC\u20262717",
"detail": {
- "title": "Send <0.000001 HBAR to 0x05AC…2717",
+ "title": "Send <0.000001 HBAR to 0x05AC\u20262717",
"network": "Hedera Testnet (EVM)",
"rows": "From Your balance Fee $0.0023 Ready in about 10 seconds To 0x05ACD02A8E18c130D902FB732bb6AD22DA4f2717 Amount <0.000001 HBAR Requested by matrix-dapp.example Network fee at most 0.067385 HBAR",
- "details": "1 Network fee 2 Send <0.000001 HBAR to 0x05AC…2717 −<0.000001 HBAR If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "details": "1 Network fee 2 Send <0.000001 HBAR to 0x05AC\u20262717 \u2212<0.000001 HBAR If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
"notices": [
"We couldn't preview the exact result of this on the network. Check the details before you approve.",
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
@@ -184,12 +184,12 @@
},
"approval": {
"status": "pass",
- "why": "Send 0.000000001 SOL to G1zR…dxzk",
+ "why": "Send 0.000000001 SOL to G1zR\u2026dxzk",
"detail": {
- "title": "Send 0.000000001 SOL to G1zR…dxzk",
+ "title": "Send 0.000000001 SOL to G1zR\u2026dxzk",
"network": "Solana Devnet",
"rows": "Fee $0.0006 Ready in a moment To G1zRMYPBYE6CvsvZY1gAwfNnr5ZjW6eZJeGARQZvdxzk (you) gets 0.000000001 SOL Network fee 0.000005 SOL",
- "details": "1 Network fee 2 Send 0.000000001 SOL to G1zR…dxzk If it fails, nothing leaves your balance.",
+ "details": "1 Network fee 2 Send 0.000000001 SOL to G1zR\u2026dxzk If it fails, nothing leaves your balance.",
"notices": [
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
],
@@ -312,12 +312,12 @@
},
"approval": {
"status": "pass",
- "why": "Send 0.000000001 SUI to 0x6103…c5f9",
+ "why": "Send 0.000000001 SUI to 0x6103\u2026c5f9",
"detail": {
- "title": "Send 0.000000001 SUI to 0x6103…c5f9",
+ "title": "Send 0.000000001 SUI to 0x6103\u2026c5f9",
"network": "Sui Testnet",
"rows": "Fee $0.0012 Ready in a moment Sends to Your own account Network fee 0.00100988 SUI (at most 0.002988)",
- "details": "1 Network fee 2 Send 0.000000001 SUI to 0x6103…c5f9 If it fails, nothing leaves your balance.",
+ "details": "1 Network fee 2 Send 0.000000001 SUI to 0x6103\u2026c5f9 If it fails, nothing leaves your balance.",
"notices": [
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
],
@@ -434,7 +434,7 @@
"approval": {
"title": "Sign a message for matrix-dapp.example",
"network": "Cardano Preprod",
- "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet) Signed as addr_test1qr…v5dr79",
+ "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet) Signed as addr_test1qr\u2026v5dr79",
"details": "1 Sign a message for matrix-dapp.example If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
"notices": [
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
@@ -508,12 +508,12 @@
},
"approval": {
"status": "pass",
- "why": "Send 0.000000000001 WND to 5Gj64M…HCQb",
+ "why": "Send 0.000000000001 WND to 5Gj64M\u2026HCQb",
"detail": {
- "title": "Send 0.000000000001 WND to 5Gj64M…HCQb",
+ "title": "Send 0.000000000001 WND to 5Gj64M\u2026HCQb",
"network": "Westend",
"rows": "Fee $0.02 Ready in about 10 seconds To 5Gj64MPAV7C2ezdS5tpq4fVgDYteHCptB62RFxvaVyECHCQb Amount 0.000000000001 WND Network fee 0.015627473774 WND Valid for about 6 minutes Sent by matrix-dapp.example (it gets your signature)",
- "details": "1 Network fee 2 Send 0.000000000001 WND to 5Gj64M…HCQb If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "details": "1 Network fee 2 Send 0.000000000001 WND to 5Gj64M\u2026HCQb If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
"notices": [
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
],
@@ -571,12 +571,12 @@
},
"approval": {
"status": "pass",
- "why": "Send 0.000000000000000001 STRK to 0x014f…e6b8",
+ "why": "Send 0.000000000000000001 STRK to 0x014f\u2026e6b8",
"detail": {
- "title": "Send 0.000000000000000001 STRK to 0x014f…e6b8",
+ "title": "Send 0.000000000000000001 STRK to 0x014f\u2026e6b8",
"network": "Starknet Sepolia",
"rows": "Fee $0.001 Ready in about 10 seconds To 0x014ff78c22689465708f38822fe1cc843d857e3b29647ba01908074e65d8e6b8 Amount 0.000000000000000001 STRK Network fee 0.018781775980538304 STRK Network fee at most 0.042259649934599701 STRK",
- "details": "1 Network fee 2 Send 0.000000000000000001 STRK to 0x014f…e6b8 If it fails, nothing leaves your balance.",
+ "details": "1 Network fee 2 Send 0.000000000000000001 STRK to 0x014f\u2026e6b8 If it fails, nothing leaves your balance.",
"notices": [
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
],
@@ -634,12 +634,12 @@
},
"approval": {
"status": "pass",
- "why": "Send 0.000000001 GRAM to 0QBQ…aXFi",
+ "why": "Send 0.000000001 GRAM to 0QBQ\u2026aXFi",
"detail": {
- "title": "Send 0.000000001 GRAM to 0QBQ…aXFi",
+ "title": "Send 0.000000001 GRAM to 0QBQ\u2026aXFi",
"network": "TON Testnet",
- "rows": "From Your balance Fee $0.0011 Ready in about 5 seconds To 0QBQBJfGnfXxWzVRhBaupSxxbZH81iSAlSPw7YdZNopaaXFi Amount 0.000000001 GRAM Wallet Not active yet. This first transfer also activates it. Network fee ≈ 0.000742136 GRAM",
- "details": "1 Network fee 2 Send 0.000000001 GRAM to 0QBQ…aXFi −<0.000001 GRAM Settles once delivery is proven. If it doesn't arrive in time, the money comes back to you. These changes are estimated; this network can't preview them.",
+ "rows": "From Your balance Fee $0.0011 Ready in about 5 seconds To 0QBQBJfGnfXxWzVRhBaupSxxbZH81iSAlSPw7YdZNopaaXFi Amount 0.000000001 GRAM Wallet Not active yet. This first transfer also activates it. Network fee \u2248 0.000742136 GRAM",
+ "details": "1 Network fee 2 Send 0.000000001 GRAM to 0QBQ\u2026aXFi \u2212<0.000001 GRAM Settles once delivery is proven. If it doesn't arrive in time, the money comes back to you. These changes are estimated; this network can't preview them.",
"notices": [
"Paying on this network from another one isn't available yet. Today we can pay on Hedera.",
"You don't have enough GRAM for this and its network fee.",
@@ -764,12 +764,12 @@
},
"approval": {
"status": "pass",
- "why": "Send 0.0000001 XLM to GB4N…VYVJ",
+ "why": "Send 0.0000001 XLM to GB4N\u2026VYVJ",
"detail": {
- "title": "Send 0.0000001 XLM to GB4N…VYVJ",
+ "title": "Send 0.0000001 XLM to GB4N\u2026VYVJ",
"network": "Stellar Testnet",
"rows": "Fee $0.00 Ready in about 5 seconds To GB4NG3E6SHS5PKVVIRSYJZFG5GKLBLHLR7B46F2HJF5L2FZIKGYBVYVJ Asset XLM (native) Valid until 2026-10-05T14:04:00Z Network fee up to 0.00001 XLM Sent by matrix-dapp.example (it gets the signed transaction)",
- "details": "1 Network fee 2 Send 0.0000001 XLM to GB4N…VYVJ If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "details": "1 Network fee 2 Send 0.0000001 XLM to GB4N\u2026VYVJ If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
"notices": [
"matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
],
@@ -906,7 +906,7 @@
"levels": {
"connect": {
"status": "pass",
- "why": "EIP-6963 \"Use Clip Wallet\" → eth_requestAccounts → 0x05ACD02A8E18c130D902FB732bb6AD22DA4f2717"
+ "why": "EIP-6963 \"Use Clip Wallet\" \u2192 eth_requestAccounts \u2192 0x05ACD02A8E18c130D902FB732bb6AD22DA4f2717"
},
"sign": {
"status": "n/a",
@@ -925,5 +925,390 @@
"evm-live-connected.png"
],
"at": "2026-10-05T14:00:16.723Z"
+ },
+ "cosmos": {
+ "target": "cosmos",
+ "network": "cosmos:osmo-test-5",
+ "dapp": "Keplr API on window.clipwallet.cosmos + @cosmjs/stargate SigningStargateClient in a local page, osmo-test-5",
+ "dappWhy": "Osmosis dapps talk to Keplr's API through cosmjs; the hosted Osmosis frontend only knows wallet-specific globals",
+ "levels": {
+ "connect": {
+ "status": "pass",
+ "why": "account osmo1re86ed5ck45dnyex8ajvytrae4c5ct7xjm6edd",
+ "detail": {
+ "address": "osmo1re86ed5ck45dnyex8ajvytrae4c5ct7xjm6edd",
+ "algo": "secp256k1",
+ "accounts": 1
+ }
+ },
+ "sign": {
+ "status": "pass",
+ "why": "ADR-36 amino doc (Keplr makeADR36AminoSignDoc) via @cosmjs/amino, sha256, @cosmjs/crypto Secp256k1.verifySignature",
+ "detail": {
+ "valid": true,
+ "how": "ADR-36 amino doc (Keplr makeADR36AminoSignDoc) via @cosmjs/amino, sha256, @cosmjs/crypto Secp256k1.verifySignature",
+ "approval": {
+ "title": "Sign a message for matrix-dapp.example",
+ "network": "Osmosis Testnet",
+ "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet)",
+ "details": "1 Sign a message for matrix-dapp.example If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ }
+ },
+ "approval": {
+ "status": "skip",
+ "why": "needs funds first: the account doesn't exist on osmo-test-5 yet"
+ },
+ "send": {
+ "status": "skip",
+ "why": "needs 0.1 OSMO on Osmosis Testnet at osmo1re86ed5ck45dnyex8ajvytrae4c5ct7xjm6edd; has 0",
+ "detail": {
+ "declinedAs": "NeedsFunds"
+ }
+ }
+ },
+ "shots": [
+ "cosmos-sign.png"
+ ],
+ "at": "2026-10-06T12:16:20.574Z"
+ },
+ "tron": {
+ "target": "tron",
+ "network": "tron:0xcd8690dc",
+ "dapp": "TIP-6963 discovery + TIP-1193 provider + TronWeb 6 in a local page, Nile",
+ "dappWhy": "TIP-6963/TIP-1193 is the TRON standard for injected wallets; tronwallet-adapters has no adapter for a generic TIP-6963 wallet",
+ "levels": {
+ "connect": {
+ "status": "pass",
+ "why": "account TGZxYSGcQHaZMcSB2c7ZB1n97yMk2UPz41",
+ "detail": {
+ "address": "TGZxYSGcQHaZMcSB2c7ZB1n97yMk2UPz41",
+ "chainId": "0xcd8690dc",
+ "name": "Clip Wallet"
+ }
+ },
+ "sign": {
+ "status": "pass",
+ "why": "TronWeb Trx.verifyMessageV2 (TIP-104 signMessageV2)",
+ "detail": {
+ "valid": true,
+ "how": "TronWeb Trx.verifyMessageV2 (TIP-104 signMessageV2)",
+ "approval": {
+ "title": "Sign a message for matrix-dapp.example",
+ "network": "TRON Nile Testnet",
+ "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet)",
+ "details": "1 Sign a message for matrix-dapp.example If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ }
+ },
+ "approval": {
+ "status": "skip",
+ "why": "needs funds first: the account doesn't exist on Nile yet (receive TRX first)"
+ },
+ "send": {
+ "status": "skip",
+ "why": "needs 2 TRX on TRON Nile Testnet at TGZxYSGcQHaZMcSB2c7ZB1n97yMk2UPz41; has 0",
+ "detail": {
+ "declinedAs": "NeedsFunds"
+ }
+ }
+ },
+ "shots": [
+ "tron-sign.png"
+ ],
+ "at": "2026-10-06T12:16:37.425Z"
+ },
+ "stacks": {
+ "target": "stacks",
+ "network": "stacks:2147483648",
+ "dapp": "@stacks/connect 8 request() (SIP-030) + WBIP-004 discovery + @stacks/encryption in a local page, testnet",
+ "dappWhy": "Connect's request() is what Stacks dapps call (the picker modal lists wbip_providers; the page picks Clip's entry instead of clicking the modal)",
+ "levels": {
+ "connect": {
+ "status": "pass",
+ "why": "account ST1EC4TTQPQX24TT3VY2H6YYW99N0HRQ5VMRB2M1T",
+ "detail": {
+ "address": "ST1EC4TTQPQX24TT3VY2H6YYW99N0HRQ5VMRB2M1T",
+ "publicKey": "02eeedf4fbb22fb0a2d49fdcea998350db0533f76719fafde4c17d0484a0dd8b39",
+ "providerId": "clipwallet.stacks"
+ }
+ },
+ "sign": {
+ "status": "pass",
+ "why": "@stacks/encryption verifyMessageSignatureRsv (\\x17Stacks Signed Message prefix) + getAddressFromPublicKey === connected address",
+ "detail": {
+ "valid": true,
+ "how": "@stacks/encryption verifyMessageSignatureRsv (\\x17Stacks Signed Message prefix) + getAddressFromPublicKey === connected address",
+ "approval": {
+ "title": "Sign a message for matrix-dapp.example",
+ "network": "Stacks Testnet",
+ "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet)",
+ "details": "1 Sign a message for matrix-dapp.example If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ }
+ },
+ "approval": {
+ "status": "pass",
+ "why": "Send 0.000001 STX to ST000\u2026W42H",
+ "detail": {
+ "title": "Send 0.000001 STX to ST000\u2026W42H",
+ "network": "Stacks Testnet",
+ "rows": "From Your balance Fee 0.000387 STX Ready in about 10 seconds To ST000000000000000000002AMW42H Amount 0.000001 STX Memo clip matrix Network fee 0.000387 STX Nonce 0",
+ "details": "1 Network fee 2 Send 0.000001 STX to ST000\u2026W42H \u22120.000001 STX If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ },
+ "send": {
+ "status": "pass",
+ "why": "https://explorer.hiro.so/txid/0xdcb787f4291329664e5b5d8df9dac80770639b8df2a45394259153a435b2d069?chain=testnet",
+ "detail": {
+ "id": "dcb787f4291329664e5b5d8df9dac80770639b8df2a45394259153a435b2d069",
+ "publicKey": "02eeedf4fbb22fb0a2d49fdcea998350db0533f76719fafde4c17d0484a0dd8b39"
+ }
+ }
+ },
+ "shots": [
+ "stacks-sign.png",
+ "stacks-approval.png"
+ ],
+ "at": "2026-10-06T12:16:54.327Z"
+ },
+ "fuel": {
+ "target": "fuel",
+ "network": "fuel:0",
+ "dapp": "fuels-ts Fuel connector manager (FuelConnector event discovery) + fuels Provider in a local page, Fuel testnet",
+ "dappWhy": "fuels-ts Fuel is the connector API every Fuel dapp (and @fuels/react) uses; a local page avoids a dapp's own build and its WalletConnect setup",
+ "levels": {
+ "connect": {
+ "status": "pass",
+ "why": "account 0x1Cc3e8E1B340846d9Eb275D51435189deDe13f20801bbc3693B2D04c1B322A75",
+ "detail": {
+ "address": "0x1Cc3e8E1B340846d9Eb275D51435189deDe13f20801bbc3693B2D04c1B322A75",
+ "connector": "Clip Wallet",
+ "accounts": [
+ "0x1Cc3e8E1B340846d9Eb275D51435189deDe13f20801bbc3693B2D04c1B322A75"
+ ],
+ "chainId": 0
+ }
+ },
+ "sign": {
+ "status": "pass",
+ "why": "fuels-ts Signer.recoverAddress(hashMessage(message), signature)",
+ "detail": {
+ "valid": true,
+ "how": "fuels-ts Signer.recoverAddress(hashMessage(message), signature)",
+ "signature": "0x37441bc6e6fd214cfdec31ed174524898ba4b4f4d11cfc4c5f1c98bf915c4734babb7814aeab15e704f85743b58be752fd8acb58a186ea4cc100ecd431168082",
+ "approval": {
+ "title": "Sign a message for matrix-dapp.example",
+ "network": "Fuel Testnet",
+ "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet)",
+ "details": "1 Sign a message for matrix-dapp.example If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ }
+ },
+ "approval": {
+ "status": "skip",
+ "why": "needs funds first: the Fuel testnet account has no ETH (faucet-testnet.fuel.network)"
+ },
+ "send": {
+ "status": "skip",
+ "why": "needs 0.0000002 ETH on Fuel Testnet at 0x1Cc3e8E1B340846d9Eb275D51435189deDe13f20801bbc3693B2D04c1B322A75; has 0",
+ "detail": {
+ "declinedAs": "NeedsFunds"
+ }
+ }
+ },
+ "shots": [
+ "fuel-sign.png"
+ ],
+ "at": "2026-10-06T12:17:27.393Z"
+ },
+ "chainflip": {
+ "target": "chainflip",
+ "network": "polkadot:7a5d4db858ada1d20ed6ded4933c3331",
+ "dapp": "@polkadot/extension-dapp + @polkadot/api in a local page, Chainflip Perseverance",
+ "dappWhy": "what lp.chainflip.io uses to reach Polkadot-style wallets; the portal itself needs a funded LP account",
+ "levels": {
+ "connect": {
+ "status": "pass",
+ "why": "account cFNXs2hYYRhGp1A4AfXaTbK3vQSgnSLvtvJrvJrCbNvhtTKc1",
+ "detail": {
+ "address": "cFNXs2hYYRhGp1A4AfXaTbK3vQSgnSLvtvJrvJrCbNvhtTKc1",
+ "source": "clip-wallet"
+ }
+ },
+ "sign": {
+ "status": "pass",
+ "why": "@polkadot/util-crypto signatureVerify over -wrapped message",
+ "detail": {
+ "valid": true,
+ "how": "@polkadot/util-crypto signatureVerify over -wrapped message",
+ "approval": {
+ "title": "Sign a message for matrix-dapp.example",
+ "network": "Westend",
+ "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet)",
+ "details": "1 Sign a message for matrix-dapp.example If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ }
+ },
+ "approval": {
+ "status": "pass",
+ "why": "Register as a Chainflip liquidity provider",
+ "detail": {
+ "title": "Register as a Chainflip liquidity provider",
+ "network": "Chainflip Perseverance",
+ "rows": "Fee 0.000006 FLIP Ready in about 10 seconds Network fee 0.000006079018 FLIP Valid for about 6 minutes Sent by matrix-dapp.example (it gets your signature)",
+ "details": "1 Network fee 2 Register as a Chainflip liquidity provider Settles once delivery is proven. If it doesn't arrive in time, the money comes back to you. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "Paying on this network from another one isn't available yet. Today we can pay on Hedera.",
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": false,
+ "overflowing": [],
+ "declinedAs": "ProviderRpcError 4001"
+ }
+ },
+ "send": {
+ "status": "skip",
+ "why": "needs 0.1 FLIP on Chainflip Perseverance at cFNXs2hYYRhGp1A4AfXaTbK3vQSgnSLvtvJrvJrCbNvhtTKc1; has 0",
+ "detail": {
+ "declinedAs": "ProviderRpcError 4001"
+ }
+ }
+ },
+ "shots": [
+ "chainflip-sign.png",
+ "chainflip-approval.png"
+ ],
+ "at": "2026-10-06T12:17:43.985Z"
+ },
+ "multiversx": {
+ "target": "multiversx",
+ "network": "mvx:D",
+ "dapp": "@multiversx/sdk-dapp 5.7.3 initApp + ProviderFactory (custom provider from window.multiversx.providers), native auth, devnet",
+ "dappWhy": "sdk-dapp is how MultiversX dapps connect wallets; it has no wallet discovery, so Clip lists itself through the undocumented window.multiversx.providers hook",
+ "levels": {
+ "connect": {
+ "status": "pass",
+ "why": "account erd1qhkcc53w80nppnsprtzs7k2wh7xwvt54c8u88d0cd6pn3n6ldudshmqgfg",
+ "detail": {
+ "address": "erd1qhkcc53w80nppnsprtzs7k2wh7xwvt54c8u88d0cd6pn3n6ldudshmqgfg",
+ "name": "Clip Wallet"
+ }
+ },
+ "sign": {
+ "status": "pass",
+ "why": "sdk-dapp verifyMessage (MessageComputer + UserVerifier)",
+ "detail": {
+ "valid": true,
+ "how": "sdk-dapp verifyMessage (MessageComputer + UserVerifier)",
+ "approval": {
+ "title": "Sign a message for matrix-dapp.example",
+ "network": "MultiversX Devnet",
+ "rows": "Message Clip Wallet dapp matrix: sign-in check (testnet)",
+ "details": "1 Sign a message for matrix-dapp.example If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ }
+ },
+ "approval": {
+ "status": "skip",
+ "why": "needs funds first: the account has no devnet EGLD for the fee (devnet wallet faucet)"
+ },
+ "send": {
+ "status": "skip",
+ "why": "needs 0.01 EGLD on MultiversX Devnet at erd1qhkcc53w80nppnsprtzs7k2wh7xwvt54c8u88d0cd6pn3n6ldudshmqgfg; has 0",
+ "detail": {
+ "declinedAs": "NeedsFunds"
+ }
+ }
+ },
+ "shots": [
+ "multiversx-sign.png"
+ ],
+ "at": "2026-10-06T12:25:02.345Z"
+ },
+ "xrpl": {
+ "target": "xrpl",
+ "network": "xrpl:1",
+ "dapp": "XLS-72d Wallet Standard discovery (@wallet-standard/app + the @xrpl-wallet-standard/app filter) in a local page, testnet",
+ "dappWhy": "@xrpl-wallet-standard/app only wraps getWallets() with that filter but pulls in xrpl.js and an alpha Xahau fork; the page runs the same filter",
+ "levels": {
+ "connect": {
+ "status": "pass",
+ "why": "account rDvWLtQL3Furv3sVzERai1MXvKFTQjZgrM",
+ "detail": {
+ "address": "rDvWLtQL3Furv3sVzERai1MXvKFTQjZgrM",
+ "chains": [
+ "xrpl:1",
+ "xrpl:2"
+ ]
+ }
+ },
+ "sign": {
+ "status": "n/a",
+ "why": "XLS-72d (the XRPL browser wallet standard) has no message signing."
+ },
+ "approval": {
+ "status": "pass",
+ "why": "Change your account settings",
+ "detail": {
+ "title": "Change your account settings",
+ "network": "XRP Ledger Testnet",
+ "rows": "Fee 0.00001 XRP Ready in about 5 seconds Memo clip dapp matrix Network fee 0.00001 XRP",
+ "details": "1 Network fee 2 Change your account settings If it fails, nothing leaves your balance. These changes are estimated; this network can't preview them.",
+ "notices": [
+ "matrix-dapp.example isn't a site Clip Wallet recognises. Only continue if you opened it yourself."
+ ],
+ "approveEnabled": true,
+ "overflowing": []
+ }
+ },
+ "send": {
+ "status": "pass",
+ "why": "https://testnet.xrpl.org/transactions/601CC50503CD39B6783D609863C218159DC44EE1C7E34FB4DC51EA13BD8D8E32",
+ "detail": {
+ "id": "601CC50503CD39B6783D609863C218159DC44EE1C7E34FB4DC51EA13BD8D8E32",
+ "validated": true
+ }
+ }
+ },
+ "shots": [
+ "xrpl-approval.png"
+ ],
+ "at": "2026-10-06T12:25:22.497Z"
}
}
diff --git a/apps/extension/e2e/shots/matrix/stacks-approval.png b/apps/extension/e2e/shots/matrix/stacks-approval.png
new file mode 100644
index 00000000..efcd671b
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/stacks-approval.png differ
diff --git a/apps/extension/e2e/shots/matrix/stacks-sign.png b/apps/extension/e2e/shots/matrix/stacks-sign.png
new file mode 100644
index 00000000..2d34398b
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/stacks-sign.png differ
diff --git a/apps/extension/e2e/shots/matrix/tron-sign.png b/apps/extension/e2e/shots/matrix/tron-sign.png
new file mode 100644
index 00000000..c70b5a48
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/tron-sign.png differ
diff --git a/apps/extension/e2e/shots/matrix/xrpl-approval.png b/apps/extension/e2e/shots/matrix/xrpl-approval.png
new file mode 100644
index 00000000..ccc262f7
Binary files /dev/null and b/apps/extension/e2e/shots/matrix/xrpl-approval.png differ
diff --git a/apps/extension/package.json b/apps/extension/package.json
index c174be2d..2fef3397 100644
--- a/apps/extension/package.json
+++ b/apps/extension/package.json
@@ -41,25 +41,41 @@
"@aptos-labs/wallet-standard": "^2.0.0",
"@cardano-foundation/cardano-verify-datasignature": "1.0.11",
"@clip-wallet/chains-algorand": "workspace:*",
+ "@clip-wallet/chains-antelope": "workspace:*",
"@clip-wallet/chains-aptos": "workspace:*",
"@clip-wallet/chains-bitcoin": "workspace:*",
+ "@clip-wallet/chains-bitcoincash": "workspace:*",
"@clip-wallet/chains-cardano": "workspace:*",
+ "@clip-wallet/chains-cosmos": "workspace:*",
"@clip-wallet/chains-evm": "workspace:*",
+ "@clip-wallet/chains-fuel": "workspace:*",
"@clip-wallet/chains-hedera": "workspace:*",
+ "@clip-wallet/chains-icp": "workspace:*",
+ "@clip-wallet/chains-multiversx": "workspace:*",
"@clip-wallet/chains-near": "workspace:*",
"@clip-wallet/chains-solana": "workspace:*",
+ "@clip-wallet/chains-stacks": "workspace:*",
"@clip-wallet/chains-starknet": "workspace:*",
"@clip-wallet/chains-stellar": "workspace:*",
"@clip-wallet/chains-substrate": "workspace:*",
"@clip-wallet/chains-sui": "workspace:*",
"@clip-wallet/chains-tezos": "workspace:*",
"@clip-wallet/chains-ton": "workspace:*",
+ "@clip-wallet/chains-tron": "workspace:*",
+ "@clip-wallet/chains-xrpl": "workspace:*",
"@clip-wallet/connect": "workspace:*",
"@clip-wallet/core": "workspace:*",
"@clip-wallet/kit-modules": "workspace:*",
"@clip-wallet/ui": "workspace:*",
+ "@cosmjs/amino": "^0.39.0",
+ "@cosmjs/crypto": "^0.39.0",
+ "@cosmjs/encoding": "^0.39.0",
+ "@cosmjs/stargate": "^0.39.0",
"@hashgraph/hedera-wallet-connect": "2.1.3",
"@hiero-ledger/sdk": "^2.89.1",
+ "@multiversx/sdk-core": "^15.4.1",
+ "@multiversx/sdk-dapp": "5.7.3",
+ "@multiversx/sdk-dapp-utils": "^3.1.0",
"@mysten/sui": "^2.34.0",
"@mysten/wallet-standard": "^0.21.32",
"@near-js/crypto": "^2.5.1",
@@ -78,6 +94,9 @@
"@solana/wallet-standard-util": "^1.1.4",
"@solana/wallet-standard-wallet-adapter-base": "^1.1.6",
"@solana/web3.js": "^1.99.0",
+ "@stacks/connect": "8.2.7",
+ "@stacks/encryption": "7.6.0",
+ "@stacks/transactions": "7.6.0",
"@starknet-io/get-starknet-core": "4.0.8",
"@stellar/stellar-base": "^15.0.0",
"@taquito/taquito": "25.0.0",
@@ -92,12 +111,17 @@
"@wallet-standard/app": "^1.1.1",
"@wxt-dev/module-react": "^1.2.2",
"algosdk": "^3.8.0",
+ "axios": "^1.20.0",
+ "bignumber.js": "^9.3.1",
"bip322-js": "4.0.0",
"buffer": "6.0.3",
"cborg": "^6.1.3",
"esbuild": "^0.28.2",
"events": "3.3.0",
+ "fuels": "0.103.0",
+ "protobufjs": "^7.6.6",
"starknet": "^10.8.0",
+ "tronweb": "^6.5.1",
"viem": "^2.57.1",
"wagmi": "^3.7.7",
"wxt": "^0.21.4"
diff --git a/apps/extension/screenshots/home-dark.png b/apps/extension/screenshots/home-dark.png
index 8455ce3c..77fa0ecb 100644
Binary files a/apps/extension/screenshots/home-dark.png and b/apps/extension/screenshots/home-dark.png differ
diff --git a/apps/extension/screenshots/receive-all-families.png b/apps/extension/screenshots/receive-all-families.png
index fc1020b6..7b62c9cf 100644
Binary files a/apps/extension/screenshots/receive-all-families.png and b/apps/extension/screenshots/receive-all-families.png differ
diff --git a/apps/extension/screenshots/receive-osmo.png b/apps/extension/screenshots/receive-osmo.png
new file mode 100644
index 00000000..6a5d510f
Binary files /dev/null and b/apps/extension/screenshots/receive-osmo.png differ
diff --git a/apps/mobile/clip.config.ts b/apps/mobile/clip.config.ts
index ff3bd72f..896bc14d 100644
--- a/apps/mobile/clip.config.ts
+++ b/apps/mobile/clip.config.ts
@@ -19,7 +19,7 @@ export default defineConfig({
icon: "./assets/icon.svg",
theme: { accent: "#FF3C00", accentText: "#FFFFFF", font: "Inter", radius: 14 },
// All 14 families, testnets only (same as the extension).
- networks: ["evm:*", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand"],
+ networks: ["evm:*", "hedera", "solana", "bitcoin", "sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand", "cosmos", "provenance", "thorchain", "initia", "tron", "xrpl", "antelope", "multiversx", "icp", "stacks", "fuel", "bitcoincash"],
passkeys: { enabled: true },
walletConnect: {},
services: MAINNET ? {} : TESTNET_SERVICES,
diff --git a/docs/r1/dapp-matrix-funding.md b/docs/r1/dapp-matrix-funding.md
index a646e06b..63cff53b 100644
--- a/docs/r1/dapp-matrix-funding.md
+++ b/docs/r1/dapp-matrix-funding.md
@@ -81,3 +81,29 @@ Both Hedera rows are this matrix's wallet, on Hedera testnet:
account, as they would with MetaMask. Send to it as an EVM address (the portal faucet accepts both).
10 HBAR each is plenty; 2 HBAR each is the floor the matrix checks for.
+
+## networks87 accounts (2026-10-06)
+
+Account 0 of the new families, derived from the same matrix phrase (public keys in `addresses.json`; `node
+scripts/dapp-matrix-balances.mjs` re-derives every address with the family's own chain module: all "ok"). Addresses
+are spelled for the testnet. I used only plain public APIs with no captcha, account or terms (Hiro's STX faucet API,
+the XRPL testnet faucet API); everything else, and every account creation, is for you.
+
+| Target | Testnet | Receive address | Faucet | What the faucet needs | Matrix minimum | Status |
+| --- | --- | --- | --- | --- | --- | --- |
+| Stacks | testnet | `ST1EC4TTQPQX24TT3VY2H6YYW99N0HRQ5VMRB2M1T` | `POST https://api.testnet.hiro.so/extended/v1/faucets/stx?address=…` | **API-only** | 0.01 STX | **Funded by me:** 500 STX, tx `0x5fa11ca9fd59c0900a9d6bf45589ca310993d20c1999f9d596d04fa6ab151cd3`; L3 passed |
+| XRP Ledger | testnet | `rDvWLtQL3Furv3sVzERai1MXvKFTQjZgrM` | `POST https://faucet.altnet.rippletest.net/accounts {"destination":"r…"}` | **API-only** | 1.1 XRP (1 XRP reserve + fees) | **Funded by me:** 100 XRP, tx `7207EF46614D51E4134F93EFAC19E4FF8E1CCAF2789102933D82307631D976EB`; L3 passed |
+| Cosmos (Osmosis) | osmo-test-5 | `osmo1re86ed5ck45dnyex8ajvytrae4c5ct7xjm6edd` | [faucet.testnet.osmosis.zone](https://faucet.testnet.osmosis.zone) | Cloudflare Turnstile captcha | 0.1 OSMO | **You fund this** |
+| TRON | Nile | `TGZxYSGcQHaZMcSB2c7ZB1n97yMk2UPz41` | [nileex.io/join/getJoinPage](https://nileex.io/join/getJoinPage) | Turnstile captcha | 2 TRX | **You fund this** |
+| Fuel | testnet | `0x1Cc3e8E1B340846d9Eb275D51435189deDe13f20801bbc3693B2D04c1B322A75` | [faucet-testnet.fuel.network](https://faucet-testnet.fuel.network) | Cloudflare challenge | any ETH (0.0000002) | **You fund this** |
+| Chainflip | Perseverance | `cFNXs2hYYRhGp1A4AfXaTbK3vQSgnSLvtvJrvJrCbNvhtTKc1` | tFLIP (ERC-20 on Sepolia `0xdC27c60956cB065D19F08bb69a707E37b36d8086`) from the Chainflip Discord, then fund the State Chain account at [auctions.perseverance.chainflip.io](https://auctions.perseverance.chainflip.io) from the matrix EVM account (it has Sepolia ETH) | Discord account | 0.1 FLIP | **You fund this** |
+| MultiversX | devnet | `erd1qhkcc53w80nppnsprtzs7k2wh7xwvt54c8u88d0cd6pn3n6ldudshmqgfg` | [devnet-wallet.multiversx.com](https://devnet-wallet.multiversx.com) faucet (r3d4.fr/faucet untested) | Logged-in wallet + reCAPTCHA | 0.01 EGLD | **You fund this** |
+| Initia | initiation-2 | `init1qkkdq25wrrqnpkgzldejhd4dytdy7fch0qn2v7` | [app.testnet.initia.xyz/faucet](https://app.testnet.initia.xyz/faucet) | Turnstile captcha | 0.1 INIT | balance only (no matrix page) |
+| Provenance | pio-testnet-1 | `tp16f3hw77j3vwa3chtnplx3qs4sn2z4ww0cdzgzd` | none: the explorer faucet reached end of life | n/a | 1 HASH | no public faucet found |
+| ICP | `icp:test` (DFINITY test ledgers on mainnet) | principal `gm6de-vncl6-w4cya-5z35g-cincb-f2zqe-6jhln-cnvjw-gvqf6-3shog-6ae` | [faucet.internetcomputer.org](https://faucet.internetcomputer.org) (10 TESTICP) | Web form (no login or captcha seen) | 0.001 TESTICP | **You fund this** (balance only) |
+| Bitcoin Cash | chipnet | `bchtest:qq5jt8xqr6gckvpnn4dr4ajfd023mf7fzcj5tplmu2` | [tbch.googol.cash](https://tbch.googol.cash) | Image captcha | 5,000 sats | **You fund this** (balance only) |
+| Antelope | Jungle4 | key `PUB_K1_7VxUN24cMwi94AHSbLkSnbKNfwoN8wjV4Fv1FA8W7bXQdTVzro` (no account yet) | account: `POST https://jungle4.greymass.com/account/create` (no captcha) or [monitor.jungletestnet.io](https://monitor.jungletestnet.io); tokens: Jungle4 faucet (reCAPTCHA). Telos testnet: `POST https://api-dev.telos.net/v1/testnet/account` then `/faucet/`; XPR testnet needs an e-mail code | Creating an account is yours to do | an account + 1 EOS + CPU | **You create the account** (balance only) |
+| dYdX, ZIGChain | dydx-testnet-4, zig-test-2 | `dydx1re86ed5ck45dnyex8ajvytrae4c5ct7xne8dmg`, `zig1re86ed5ck45dnyex8ajvytrae4c5ct7xm2vmeh` | dYdX `POST faucet.v4testnet.dydx.exchange/faucet/native-token` (unverified); faucet.zigchain.com (Cloudflare challenge) | | | not matrix targets (same provider as Osmosis) |
+| THORChain | none | `thor13pajudku86vd59c8wkr4ctwcxdy8qpnap6afpx` (mainnet) | no public testnet (stagenet uses real funds) | | | not testable without mainnet funds |
+
+Then: `pnpm --filter @clip-wallet/extension matrix -- -g "matrix: (cosmos|tron|fuel|chainflip|multiversx)"`.
diff --git a/docs/r1/dapp-matrix.md b/docs/r1/dapp-matrix.md
index fb65bec7..70973a59 100644
--- a/docs/r1/dapp-matrix.md
+++ b/docs/r1/dapp-matrix.md
@@ -76,6 +76,31 @@ Tezos's first operation (reveal + transfer)
Screenshots: `apps/extension/e2e/shots/matrix/` (`-sign.png`, `-approval.png`,
`evm-live-connected.png`). Raw results: `apps/extension/e2e/shots/matrix/results.json`.
+## networks87 rows (2026-10-06)
+
+The families added for the remaining CLPR networks ([networks87.md](networks87.md)), same levels and rules, run with
+`pnpm --filter @clip-wallet/extension matrix -- -g "matrix: (cosmos|tron|stacks|fuel|xrpl|chainflip|multiversx)"`.
+The existing rows were re-run in the same session with no change (all previous passes still pass).
+
+| Family | Testnet | Dapp library / discovery | L1 connect | L2 sign | L3 send (tx) | L4 approval |
+| --- | --- | --- | --- | --- | --- | --- |
+| Cosmos SDK (Osmosis) | osmo-test-5 | cosmjs `SigningStargateClient` over Clip's Keplr-compatible `window.clipwallet.cosmos` | pass | pass (ADR-36, `@cosmjs/crypto` `Secp256k1.verifySignature`) | skip: needs 0.1 OSMO | skip: "the account doesn't exist on osmo-test-5 yet" |
+| TRON | Nile | raw TIP-6963 discovery + TIP-1193 (no tronwallet adapter accepts a third-party TIP-6963 wallet), TronWeb | pass | pass (TronWeb `Trx.verifyMessageV2`) | skip: needs 2 TRX | skip: "the account doesn't exist on Nile yet" |
+| Stacks | testnet | `@stacks/connect` 8 `request()` through WBIP-004 `window.wbip_providers` | pass | pass (`verifyMessageSignatureRsv`) | **pass** [0xdcb787f4…d069](https://explorer.hiro.so/txid/0xdcb787f4291329664e5b5d8df9dac80770639b8df2a45394259153a435b2d069?chain=testnet) | pass: "Send 0.000001 STX to ST000…W42H" |
+| Fuel | testnet | fuels-ts `Fuel` connectors list → Clip's FuelConnector | pass | pass (`Signer.recoverAddress(hashMessage(…))`) | skip: needs testnet ETH | skip: needs funds first |
+| XRP Ledger | testnet | `@wallet-standard/app` with the `@xrpl-wallet-standard/app` feature filter (XLS-72d) | pass | n/a (XLS-72d has no message signing) | **pass** [601CC505…8E32](https://testnet.xrpl.org/transactions/601CC50503CD39B6783D609863C218159DC44EE1C7E34FB4DC51EA13BD8D8E32) (no-op AccountSet with a memo: rippled refuses a payment to yourself) | pass: "Change your account settings", memo shown |
+| Chainflip (substrate) | Perseverance | `@polkadot/extension-dapp` + `@polkadot/api`, as lp.chainflip.io | pass (cF… address) | pass (`signatureVerify`) | skip: needs FLIP (no plain FLIP transfer; L3 is `liquidityProvider.registerLpAccount`) | pass: "Register as a Chainflip liquidity provider" |
+| MultiversX | devnet | `@multiversx/sdk-dapp` 5.7.3 `initApp` + `ProviderFactory` (custom provider from the **undocumented** `window.multiversx.providers` hook) | pass | pass (sdk-dapp `verifyMessage`) | skip: needs devnet EGLD | skip: needs funds first |
+
+No L1/L2 rows: Antelope, ICP (no wallet-side standard Clip can answer as itself), Bitcoin Cash (WalletConnect only;
+needs a project id), Provenance, Initia (same Keplr-compatible provider as Osmosis, no separate page), THORChain (no
+public testnet). Screenshots: `apps/extension/e2e/shots/matrix/{cosmos,tron,stacks,fuel,chainflip,multiversx}-sign.png`,
+`stacks-approval.png`, `xrpl-approval.png`, `chainflip-approval.png`.
+
+Fixes the run found: the XRPL page and chains-xrpl now use `testnet.xrpl-labs.com` first (rippled's own testnet host
+answers no CORS preflight, so a page's fetch failed); the MultiversX page connects without native auth (with it,
+`login()` asks for a second, sign-in approval inside L1).
+
## Which dapp each row uses, and why
Live public dapps come first in the brief. In practice:
diff --git a/docs/r1/networks87.md b/docs/r1/networks87.md
new file mode 100644
index 00000000..3b439d15
--- /dev/null
+++ b/docs/r1/networks87.md
@@ -0,0 +1,200 @@
+# networks87: Clip Wallet on every CLPR network
+
+The 87 networks are `NETWORKS` in coldai-clpr-landing `artifacts/coldai-web/src/data/clpr-data.ts` (the networks with
+CLPR verifier code in ColdAI's pull requests to LFDT-CLPR). The coverage below is computed from the wallet's own
+catalogue, not written by hand: `packages/engine/test/networks87.test.ts` maps each CLPR network to the mainnet(s) in
+`walletNetworks()` with every family on, fails if anything but the two networks below is missing, and prints the table
+(`pnpm --filter @clip-wallet/engine exec vitest run test/networks87.test.ts`).
+
+## Result
+
+- **85 of the 87 CLPR networks are supported on mainnet.** Before this work it was 67 (Arc was in the list only as a
+ testnet, and 19 networks were missing).
+- **Not supported: Canton and Mixin.** Neither has a model in which a self-custodial key wallet can hold and move funds
+ on its own (details below).
+- **Supported mainnets, by the landing page's counting rule** (EVM `testnet: false` entries without "Hedera (EVM)", plus
+ every other family's mainnets, Polkadot SDK networks included): **93**, up from 74. 60 EVM + Hedera, Solana,
+ Bitcoin, Sui, Aptos, Cardano, Starknet, TON, NEAR, Stellar, Tezos, Algorand (1 each) + 5 Polkadot SDK (Polkadot,
+ Kusama, both Asset Hubs, Chainflip) + Osmosis, dYdX, ZIGChain + Provenance, THORChain, Initia + TRON, XRP Ledger,
+ MultiversX, Internet Computer, Stacks, Fuel, Bitcoin Cash + Vaulta, Telos (native), XPR Network.
+- 93 counts networks the wallet ships, CLPR or not (Polkadot, Kusama and the Asset Hubs aren't CLPR networks; Telos
+ counts once as an EVM and once natively).
+
+**Wording that is true:** "Clip Wallet supports 85 of the 87 CLPR networks (all except Canton and Mixin, which have no
+self-custodial wallet model) and 93 mainnets in total." **"All 87 CLPR networks" is not true** and can't be made true by
+wallet code: see Canton and Mixin.
+
+## Gap table
+
+"already" = covered before this branch; "**new**" = added here. Network ids are CAIP-2 where a namespace exists
+(sources in each package README). Dapp connectivity is per family; every family also sends and receives in the wallet
+itself.
+
+| CLPR network | Status | Module and mainnet network id | Dapp connectivity | Notes |
+| --- | --- | --- | --- | --- |
+| Abstract | already | `chains-evm` `eip155:2741` | EIP-6963 / EIP-1193 | |
+| Algorand | already | `chains-algorand` `algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73k` | existing provider | |
+| Anubis | already | `chains-evm` `eip155:6714` | EIP-6963 / EIP-1193 | |
+| Arbitrum Nova | already | `chains-evm` `eip155:42170` | EIP-6963 / EIP-1193 | |
+| Arbitrum One | already | `chains-evm` `eip155:42161` | EIP-6963 / EIP-1193 | |
+| Arc | **new** | `chains-evm` `eip155:5042` | EIP-6963 / EIP-1193 | Arc mainnet (5042) went live 2026-09-16; before, only the testnet was in the list |
+| Aurora | already | `chains-evm` `eip155:1313161554` | EIP-6963 / EIP-1193 | |
+| Avalanche C-Chain | already | `chains-evm` `eip155:43114` | EIP-6963 / EIP-1193 | |
+| BNB Smart Chain | already | `chains-evm` `eip155:56` | EIP-6963 / EIP-1193 | |
+| BOB | already | `chains-evm` `eip155:60808` | EIP-6963 / EIP-1193 | |
+| BOT Chain | already | `chains-evm` `eip155:677` | EIP-6963 / EIP-1193 | |
+| Base | already | `chains-evm` `eip155:8453` | EIP-6963 / EIP-1193 | |
+| Bifrost Network | already | `chains-evm` `eip155:3068` | EIP-6963 / EIP-1193 | EVM layer |
+| Bitcoin | already | `chains-bitcoin` `bip122:000000000019d6689c085ae165831e93` | existing provider | |
+| Bitcoin Cash | **new** | `chains-bitcoincash` `bip122:000000000000000000651ef99cb9fcbe` | WalletConnect wc2-bch-bcr (no injected standard) | |
+| Bittensor | already | `chains-evm` `eip155:964` | EIP-6963 / EIP-1193 | EVM layer |
+| Blast | already | `chains-evm` `eip155:81457` | EIP-6963 / EIP-1193 | |
+| Canton | missing | **not supported** | n/a | see below |
+| Cardano | already | `chains-cardano` `cip34:1-764824073` | existing provider | |
+| Celo | already | `chains-evm` `eip155:42220` | EIP-6963 / EIP-1193 | |
+| Chainflip | **new** | `chains-substrate` `polkadot:8b8c140b0af9db70686583e3f6bf2a59` | window.injectedWeb3 (polkadot extension-dapp) | State Chain FLIP from Flip.Account; no FLIP transfer exists (redeem to Ethereum); LP-portal calls decoded |
+| Conflux | already | `chains-evm` `eip155:1030` | EIP-6963 / EIP-1193 | Conflux eSpace (EVM); Core Space not supported |
+| Core | already | `chains-evm` `eip155:1116` | EIP-6963 / EIP-1193 | |
+| Cronos | already | `chains-evm` `eip155:25` | EIP-6963 / EIP-1193 | EVM layer |
+| Ethereum | already | `chains-evm` `eip155:1` | EIP-6963 / EIP-1193 | |
+| Flare | already | `chains-evm` `eip155:14` | EIP-6963 / EIP-1193 | |
+| Fraxtal | already | `chains-evm` `eip155:252` | EIP-6963 / EIP-1193 | |
+| GRX Chain | already | `chains-evm` `eip155:1110` | EIP-6963 / EIP-1193 | |
+| Gnosis Chain | already | `chains-evm` `eip155:100` | EIP-6963 / EIP-1193 | |
+| Hydration | already | `chains-evm` `eip155:222222` | EIP-6963 / EIP-1193 | EVM layer |
+| Hyperliquid | already | `chains-evm` `eip155:999` | EIP-6963 / EIP-1193 | HyperEVM |
+| Immutable zkEVM | already | `chains-evm` `eip155:13371` | EIP-6963 / EIP-1193 | |
+| Injective | already | `chains-evm` `eip155:1776` | EIP-6963 / EIP-1193 | EVM layer |
+| Ink | already | `chains-evm` `eip155:57073` | EIP-6963 / EIP-1193 | |
+| KUB Chain | already | `chains-evm` `eip155:96` | EIP-6963 / EIP-1193 | |
+| Kaia | already | `chains-evm` `eip155:8217` | EIP-6963 / EIP-1193 | Kaia is EVM (8217) |
+| Katana | already | `chains-evm` `eip155:747474` | EIP-6963 / EIP-1193 | |
+| Kava | already | `chains-evm` `eip155:2222` | EIP-6963 / EIP-1193 | EVM layer |
+| MANTRA | already | `chains-evm` `eip155:5888` | EIP-6963 / EIP-1193 | EVM layer |
+| Mantle | already | `chains-evm` `eip155:5000` | EIP-6963 / EIP-1193 | |
+| MegaETH | already | `chains-evm` `eip155:4326` | EIP-6963 / EIP-1193 | |
+| Mezo | already | `chains-evm` `eip155:31612` | EIP-6963 / EIP-1193 | EVM layer |
+| Mixin | missing | **not supported** | n/a | see below |
+| Monad | already | `chains-evm` `eip155:143` | EIP-6963 / EIP-1193 | |
+| NEAR | already | `chains-near` `near:mainnet` | existing provider | |
+| OP Mainnet | already | `chains-evm` `eip155:10` | EIP-6963 / EIP-1193 | |
+| Osmosis | **new** | `chains-cosmos` `cosmos:osmosis-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 118 |
+| Plasma | already | `chains-evm` `eip155:9745` | EIP-6963 / EIP-1193 | |
+| Plume | already | `chains-evm` `eip155:98866` | EIP-6963 / EIP-1193 | |
+| Polygon PoS | already | `chains-evm` `eip155:137` | EIP-6963 / EIP-1193 | |
+| Provenance | **new** | `chains-cosmos` `cosmos:pio-mainnet-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 505 (Keplr's testnet uses 1: different key there) |
+| PulseChain | already | `chains-evm` `eip155:369` | EIP-6963 / EIP-1193 | |
+| RISE | already | `chains-evm` `eip155:4153` | EIP-6963 / EIP-1193 | |
+| Reya | already | `chains-evm` `eip155:1729` | EIP-6963 / EIP-1193 | |
+| Robinhood Chain | already | `chains-evm` `eip155:4663` | EIP-6963 / EIP-1193 | |
+| Ronin | already | `chains-evm` `eip155:2020` | EIP-6963 / EIP-1193 | |
+| Rootstock | already | `chains-evm` `eip155:30` | EIP-6963 / EIP-1193 | |
+| STRATO | **new** | `chains-evm` `eip155:123354377739506` | EIP-6963 / EIP-1193 | SolidVM, but nodes take signed legacy Ethereum transactions; flat 0.01 USDST fee per transaction |
+| Sei | already | `chains-evm` `eip155:1329` | EIP-6963 / EIP-1193 | EVM layer |
+| Solana | already | `chains-solana` `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | existing provider | |
+| Soneium | already | `chains-evm` `eip155:1868` | EIP-6963 / EIP-1193 | |
+| Stable | already | `chains-evm` `eip155:988` | EIP-6963 / EIP-1193 | EVM layer |
+| Stacks | **new** | `chains-stacks` `stacks:1` | SIP-030 + WBIP-004 | |
+| Starknet | already | `chains-starknet` `starknet:SN_MAIN` | existing provider | |
+| Stellar | already | `chains-stellar` `stellar:pubnet` | existing provider | |
+| THORChain | **new** | `chains-cosmos` `cosmos:thorchain-1` | Keplr-compatible API at window.clipwallet.cosmos | mainnet only: stagenet runs on real funds |
+| TON | already | `chains-ton` `ton:-239` | existing provider | |
+| TRON | **new** | `chains-tron` `tron:0x2b6653dc` | TIP-1193 + TIP-6963 | |
+| Telos | already | `chains-evm` `eip155:40` | EIP-6963 / EIP-1193 | Telos EVM (and Telos native in chains-antelope) |
+| Unichain | already | `chains-evm` `eip155:130` | EIP-6963 / EIP-1193 | |
+| Vaulta | **new** | `chains-antelope` `antelope:aca376f206b8fc25a6ed44dbdc66547c` | none: send/receive only | accounts are names created on chain; receive needs one for this key |
+| World Chain | already | `chains-evm` `eip155:480` | EIP-6963 / EIP-1193 | |
+| X Layer | already | `chains-evm` `eip155:196` | EIP-6963 / EIP-1193 | |
+| XPR Network | **new** | `chains-antelope` `antelope:384da888112027f0321850a169f737c3` | none: send/receive only | accounts are names created on chain |
+| XRP Ledger | **new** | `chains-xrpl` `xrpl:0` | XLS-72d Wallet Standard (no message signing) | |
+| ZIGChain | **new** | `chains-cosmos` `cosmos:zigchain-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 118 |
+| ZKsync Era | already | `chains-evm` `eip155:324` | EIP-6963 / EIP-1193 | |
+| dYdX | **new** | `chains-cosmos` `cosmos:dydx-mainnet-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 118 |
+| Linea | already | `chains-evm` `eip155:59144` | EIP-6963 / EIP-1193 | |
+| Scroll | already | `chains-evm` `eip155:534352` | EIP-6963 / EIP-1193 | |
+| Morph | already | `chains-evm` `eip155:2818` | EIP-6963 / EIP-1193 | |
+| ICP | **new** | `chains-icp` `icp:737ba355e855bd4b61279056603e0550` | none: send/receive only (ICRC-94 discovery still a draft) | no public transfer testnet: icp:test = mainnet limited to DFINITY test ledgers; replies not certificate-verified |
+| MultiversX | **new** | `chains-multiversx` `mvx:1` | best effort: sdk-dapp's undocumented window.multiversx.providers hook | |
+| Initia | **new** | `chains-cosmos` `cosmos:interwoven-1` | Keplr-compatible API at window.clipwallet.cosmos | coin type 60, ethsecp256k1: the EVM key |
+| Fuel | **new** | `chains-fuel` `fuel:9889` | FuelConnector event | |
+| Tezos | already | `chains-tezos` `tezos:NetXdQprcVkpaWU` | existing provider | |
+| Etherlink | already | `chains-evm` `eip155:42793` | EIP-6963 / EIP-1193 | EVM layer of Tezos smart rollups |
+
+## What was added
+
+| Family (vault) | Path (account `i`) | Package | Mainnets / testnets | Dapp standard in 1Mask |
+| --- | --- | --- | --- | --- |
+| `cosmos` | `m/44'/118'/0'/0/i` | `chains-cosmos` | Osmosis, dYdX, ZIGChain / osmo-test-5, dydx-testnet-4, zig-test-2 | Keplr-compatible API (`enable`, `getKey`, `signDirect`, `signAmino`, `signArbitrary`, `verifyArbitrary`, `sendTx`, offline signers) at `window.clipwallet.cosmos`; never `window.keplr` |
+| `provenance` | `m/44'/505'/0'/0/i` | `chains-cosmos` | Provenance / pio-testnet-1 | same |
+| `thorchain` | `m/44'/931'/0'/0/i` | `chains-cosmos` | THORChain / none (stagenet uses real funds) | same |
+| `initia` | `m/44'/60'/0'/0/i` (ethsecp256k1) | `chains-cosmos` | Initia / initiation-2 | same |
+| `tron` | `m/44'/195'/0'/0/i` | `chains-tron` | TRON / Nile, Shasta | TIP-1193 provider announced with TIP-6963; TronWeb-shaped signing subset (`trx.sign`, `signMessageV2`) |
+| `xrpl` | `m/44'/144'/i'/0/0` | `chains-xrpl` | XRP Ledger / testnet, devnet | XLS-72d (Wallet Standard `xrpl:signTransaction`, `xrpl:signAndSubmitTransaction`); no message signing in the standard |
+| `antelope` | `m/44'/194'/0'/0/i` (K1, canonical signatures) | `chains-antelope` | Vaulta, Telos, XPR Network / Jungle4, Telos testnet, XPR testnet | none: send/receive only (WharfKit wallet plugins live in the dapp; Anchor Link/ESR and Scatter injection would mean posing as Anchor or Scatter) |
+| `multiversx` | `m/44'/508'/0'/0'/i'` | `chains-multiversx` | MultiversX / devnet, testnet | best effort: an entry on sdk-dapp's `window.multiversx.providers` custom-provider hook under Clip's own name. **Undocumented and subject to change**; it only works on dapps that don't reset `window.multiversx` (MultiversX's own template dapp does). WalletConnect `mvx_*` methods are handled by the module |
+| `icp` | `m/44'/223'/0'/0/i` | `chains-icp` | Internet Computer / `icp:test` (mainnet limited to DFINITY test ledgers) | none: ICRC-94 (extension discovery) is still a draft; Plug's `window.ic.plug` is proprietary |
+| `stacks` | `m/44'/5757'/0'/0/i` | `chains-stacks` | Stacks / testnet | SIP-030 `request` on `window.clipwallet.stacks`, registered on `window.wbip_providers` (WBIP-004) |
+| `fuel` | `m/44'/1179993420'/i'/0/0` | `chains-fuel` | Fuel Ignition / testnet | FuelConnector announced with the `FuelConnector` event, dependency-free |
+| `bitcoincash` | `m/44'/145'/0'/0/i` | `chains-bitcoincash` | Bitcoin Cash / chipnet, testnet4 | WalletConnect `bch` namespace (wc2-bch-bcr, as Cashonize/Paytaca); no injected standard |
+| `substrate` (existing) | existing | `chains-substrate` | + Chainflip / Perseverance | existing `window.injectedWeb3` (what lp.chainflip.io uses) |
+| `evm` (existing) | existing | `chains-evm` | + STRATO, Arc / STRATO Helium | existing EIP-6963 |
+
+All derivations are in `packages/vault` (the only package that touches keys) and are cross-checked for the public
+"abandon … about" phrase against each ecosystem's SDK (`packages/vault/test/families87.test.ts`: cosmjs, xrpl.js,
+TronWeb, WharfKit, MultiversX sdk-core, @dfinity/identity-secp256k1, @stacks/wallet-sdk, fuels-ts, libauth), and every
+module's paths and addresses are checked against the vault. Chain modules serialize transactions by hand
+(protobuf, XRPL binary codec, Antelope ABI, SIP-005, Fuel tx format, CBOR/Candid, CashAddr/BIP-143 with FORKID) and
+are cross-checked byte for byte against the official SDKs, which are devDependencies only; each module's runtime
+dependencies are `@clip-wallet/core`, `@noble/*` and `@scure/base`.
+
+## Networks without a self-custodial wallet model
+
+### Canton
+
+Canton is a network of participant (validator) nodes that host parties; the CLPR verifier for it is t-of-n operator
+attestation and was run only against a local sandbox (clpr-smart-contracts-canton `docs/chains/canton.md`). Canton 3.x
+does have "external parties" whose key is held outside the node, and CIP-0103 defines a dApp API for wallets. But
+every read of a party's contracts and every submission (prepare → sign hash → execute) goes through the Ledger API of a
+participant that hosts the party, behind that operator's authentication, and there is no public endpoint. A wallet can
+hold the key, but it can't see or move anything without an operator relationship. **Closest honest support:** none in
+Clip as shipped. A kit-built wallet whose operator runs (or contracts) a validator could add a family that talks to
+its own participant through a CIP-0103 wallet gateway; that is an operator product, not a phrase-derived wallet.
+
+### Mixin
+
+Mixin's kernel is a UTXO DAG with CryptoNote-style one-time output keys. Its public RPC (`kernel.mixin.dev`;
+methods in MixinNetwork/mixin `rpc/internal/server/http.go`: getinfo, gettransaction, getutxo, listsnapshots,
+sendrawtransaction, …) has no address or key index, so a wallet can't find its own outputs without scanning every
+snapshot with the view key, and there is no BIP-39 derivation standard for kernel keys. User funds live in Mixin's
+account system (Mixin Messenger / Safe, registered accounts and TIP-derived keys behind api.mixin.one). The CLPR
+verifier reads the kernel's collective signatures, which is a different problem from holding funds.
+**Closest honest support:** none in Clip. XIN as an ERC-20 on Ethereum is an asset, not the network.
+
+## Other honest limits
+
+- **STRATO** works through its Ethereum JSON-RPC: legacy (EIP-155) transactions only, gas unpriced, a flat
+ 0.01 USDST (or voucher) per transaction that the approval screen shows as its own line. Contract calls are matched to
+ SolidVM functions by selector; apps that need STRATO's EIP-712 "function call" transactions are out of scope.
+- **Arc** mainnet launched on 2026-09-16; native gas is USDC (18 decimals on the EVM side).
+- **Antelope** accounts must be created on chain by someone (they cost RAM). Clip finds the accounts that use its key
+ (`get_accounts_by_authorizers`, Hyperion fallback) and says plainly when there is none yet.
+- **ICP** replies aren't certificate-verified (no BLS check); nothing security-relevant relies on them. An ICP
+ transfer must be approved within 4 minutes (ingress expiry).
+- **THORChain** has no public testnet. Swaps (deposit memos) decode as blind.
+- **Provenance and Initia testnets:** Keplr derives pio-testnet-1 at coin type 1 and initiation-2 at 118; Clip uses
+ the mainnet coin types (505, 60) everywhere, so those testnet accounts differ from Keplr's.
+- **Chainflip** has no FLIP transfer between State Chain accounts; LP order calls are shown as the raw call with a
+ caution.
+- **Public test accounts:** the "abandon … about" accounts are used by strangers on several testnets (XRPL account 0
+ has its master key disabled; the TRON Nile account's owner permission was moved). Unit tests use fixtures; the dapp
+ matrix uses its own wallet.
+
+## Live checks done while building (testnets, public test vector account unless said)
+
+- Fuel testnet: one 1-unit self-transfer through fuels-ts `Fuel` → Clip's connector → the module (0x5717…9db6), paid
+ from a balance the public test account already held.
+- XRPL testnet: a payment and an autofilled AccountSet validated `tesSUCCESS` (account index 7, faucet-funded).
+- Jungle4: an Antelope transfer accepted up to CPU billing (`tx_cpu_usage_exceeded`), proving serialization and
+ signature.
+- Cosmos (osmo-test-5, dydx-testnet-4, zig-test-2), MultiversX devnet, ICP, Chainflip (both networks): transactions
+ accepted by simulate/validation endpoints without broadcasting (ICP: "insufficient funds" from the ledger).
diff --git a/llms.txt b/llms.txt
index 7d636fe2..f4b6aa75 100644
--- a/llms.txt
+++ b/llms.txt
@@ -1,6 +1,6 @@
# Clip Wallet
-> Non-custodial multi-chain wallet for every CLPR network (14 families), and a kit for building your own on every platform: @clip-wallet/* packages, @clip-wallet/extension-kit, desktop-kit and mobile-kit, create-clip-wallet and a Scaffold-HBAR template. Pre-release: test networks only. pnpm monorepo, TypeScript, Apache-2.0.
+> Non-custodial multi-chain wallet for the CLPR networks (85 of 87, 26 families), and a kit for building your own on every platform: @clip-wallet/* packages, @clip-wallet/extension-kit, desktop-kit and mobile-kit, create-clip-wallet and a Scaffold-HBAR template. Pre-release: test networks only. pnpm monorepo, TypeScript, Apache-2.0.
Read first: AGENTS.md (rules + recipes), .harness/spec.md (product), .harness/prd.md (user stories), packages/core/src/index.ts (the contract), packages/config/src/index.ts (clip.config.ts schema).
Developer docs: https://coldai.org/clip/docs/ (sources: apps/docs/src, plain Markdown; every code sample is a typechecked file in apps/docs/src/snippets). Index of the pages agents need most: "## Docs" below.
@@ -35,7 +35,7 @@ Developer docs: https://coldai.org/clip/docs/ (sources: apps/docs/src, plain Mar
## Packages (all published from packages/*, one version, npm provenance)
- core: shared types (the contract). Additive changes only.
- config: clip.config.ts schema (zod): name, description, rdns, homepage, icon, extension.key, theme, networks, route {mode, filters, settleOnHedera}, compatibilityMode, hardware, walletConnect, passkeys, services {backupUrl, mediaProxyUrl, linkRelayUrl, clipHandles}, mainnet. defineConfig, validateConfig, mainnetProblems, walletKey, MAINNET_ACKNOWLEDGEMENT.
-- vault: phrase, derivation (14 families), encrypted storage, approval-bound signing, passkey unlock, passkey backup blob. KATs in packages/vault/test.
+- vault: phrase, derivation (26 families), encrypted storage, approval-bound signing, passkey unlock, passkey backup blob. KATs in packages/vault/test.
- chains-evm, -hedera, -solana, -bitcoin, -sui, -aptos, -cardano, -substrate, -starknet, -ton, -near, -stellar, -tezos, -algorand: ChainModule per family.
- 1mask: connectors (EIP-1193/6963, Wallet Standard, AIP-62, CIP-30, injectedWeb3, get-starknet, TON Connect, NEAR/Stellar/Algorand globals, Beacon relay, WalletConnect v2) announcing the wallet's identity.
- engine: approvals, permissions, portfolio, catalog (engine/catalog), wiring shared by extension and mobile.
diff --git a/packages/1mask/src/background/bitcoincash.ts b/packages/1mask/src/background/bitcoincash.ts
new file mode 100644
index 00000000..dab098b4
--- /dev/null
+++ b/packages/1mask/src/background/bitcoincash.ts
@@ -0,0 +1,46 @@
+import type { Family, Network } from "@clip-wallet/core";
+import { rpcError } from "../shared/errors.js";
+import { BCH_SIGNING_METHODS, BCH_WC, bchAddressOn, bchNetworkForWcChain } from "../shared/bitcoincash.js";
+import type { P2RouterInternals } from "./p2-families.js";
+
+/**
+ * Background side of Bitcoin Cash over WalletConnect (wc2-bch-bcr). There is no injected BCH standard to implement
+ * (no discovery mechanism wallets share; Paytaca's extension injects its own `window.paytaca`, which another wallet
+ * mustn't imitate), so Clip appears to BCH dapps through WalletConnect under its own metadata.
+ *
+ * The WalletConnect session layer (walletconnect/namespaces.ts, wallet.ts) maps the "bch" namespace and its
+ * "bch:bitcoincash" / "bch:bchtest" chains onto the registry's bip122 ids with `bchNetworkForWcChain` and answers
+ * bch_getAddresses from the session itself. This dispatcher (for a future injected path) answers bch_getAddresses with the account's CashAddr spelled for the session's network and
+ * hands signing methods to chains-bitcoincash unchanged.
+ */
+export const BCH_METHODS_ALLOWED = { local: [BCH_WC.getAddresses], signing: BCH_SIGNING_METHODS } as const;
+
+const FAMILY: Family = "bitcoincash";
+
+export function createBitcoinCashDispatcher(r: Pick) {
+ async function addresses(origin: string, net: Network): Promise {
+ const out: string[] = [];
+ for (const a of await r.accounts(origin, FAMILY)) {
+ const s = bchAddressOn(a.address, net.id);
+ if (s) out.push(s);
+ }
+ return out;
+ }
+
+ /** `chain` may be a wc2-bch-bcr chain ("bch:bchtest") or a CAIP-2 id from the registry. */
+ async function dispatch(origin: string, family: Family, method: string, params: unknown, chain: string | undefined): Promise {
+ if (family !== FAMILY) throw rpcError.unsupportedMethod(method);
+ const net = r.requireNetwork(FAMILY, origin, bchNetworkForWcChain(chain) ?? chain);
+ if (method === BCH_WC.getAddresses) {
+ await r.requirePermission(origin, FAMILY);
+ return addresses(origin, net);
+ }
+ if (BCH_SIGNING_METHODS.includes(method)) {
+ await r.requirePermission(origin, FAMILY);
+ return r.approve(r.makeReq(origin, FAMILY, net, method, params ?? {}));
+ }
+ throw rpcError.unsupportedMethod(method);
+ }
+
+ return { dispatch };
+}
diff --git a/packages/1mask/src/background/cosmos.ts b/packages/1mask/src/background/cosmos.ts
new file mode 100644
index 00000000..0dbc1249
--- /dev/null
+++ b/packages/1mask/src/background/cosmos.ts
@@ -0,0 +1,160 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { rpcError } from "../shared/errors.js";
+import { COSMOS_FAMILIES, COSMOS_INJECTED, type CosmosKeyWire, cosmosChainOf } from "../shared/cosmos.js";
+import { bech32Decode, bech32Encode, bytesOfHex, hexOf } from "../shared/cosmos-bech32.js";
+import type { ExposedAccount } from "../shared/protocol.js";
+
+/**
+ * Background side of the Keplr-compatible provider (inpage/cosmos.ts) for the four Cosmos key families: "cosmos"
+ * (coin 118: Osmosis, dYdX, ZIGChain), "provenance" (505), "thorchain" (931), "initia" (60, ethsecp256k1).
+ * The router keeps the policy (rate limits, approvals, timeouts); this file knows the method list, which account a
+ * request names, and how an account is spelled on each chain. Wiring (router.ts / methods.ts, by the coordinator):
+ *
+ * const cosmos = createCosmosDispatcher({ permitted, requirePermission, accounts, connect, approve, makeReq,
+ * requireNetwork, revoke, read: (req) => withTimeout(opts.handle(req), readMs, req.id) });
+ * if (COSMOS_DISPATCH_FAMILIES.has(family)) return cosmos.dispatch(origin, family, method, params, chain);
+ * injectedAllowlist(family) ∪= cosmosInjectedAllowlist(family)
+ *
+ * and the engine/service answers `cosmos_sendTx` / `cosmos_verifyArbitrary` through chains-cosmos `read()` without
+ * an approval, like Cardano's read methods.
+ */
+
+export const COSMOS_DISPATCH_FAMILIES: ReadonlySet = new Set(COSMOS_FAMILIES);
+
+export const COSMOS_METHODS_ALLOWED = {
+ /** Silent account read and disconnect. */
+ local: [COSMOS_INJECTED.accounts, COSMOS_INJECTED.disable],
+ connect: [COSMOS_INJECTED.enable],
+ /** Permission needed, no approval. */
+ permitted: [COSMOS_INJECTED.getKey],
+ /** Permission needed, answered by the chain module's read() (no approval). */
+ readOnly: [COSMOS_INJECTED.sendTx, COSMOS_INJECTED.verifyArbitrary],
+ signing: [COSMOS_INJECTED.signDirect, COSMOS_INJECTED.signAmino, COSMOS_INJECTED.signArbitrary],
+} as const;
+
+export function cosmosInjectedAllowlist(family: Family): ReadonlySet {
+ return COSMOS_DISPATCH_FAMILIES.has(family) ? new Set(Object.values(COSMOS_METHODS_ALLOWED).flat()) : new Set();
+}
+
+export interface CosmosRouterInternals {
+ permitted(origin: string, family: Family): Promise;
+ requirePermission(origin: string, family: Family): Promise;
+ accounts(origin: string, family: Family): Promise;
+ connect(origin: string, family: Family, net: Network, method: string, params: unknown): Promise;
+ approve(req: DappRequest): Promise;
+ makeReq(origin: string, family: Family, net: Network, method: string, params: unknown): DappRequest;
+ requireNetwork(family: Family, origin: string, chain: string | undefined): Network;
+ revoke(origin: string, family: Family): Promise;
+ /** opts.handle with the read timeout and no approval. Absent: sendTx / verifyArbitrary answer 4200. */
+ read?(req: DappRequest): Promise;
+}
+
+const obj = (p: unknown): Record => (p && typeof p === "object" && !Array.isArray(p) ? (p as Record) : {});
+
+/** The account's 20 address bytes, from its address (any bech32 spelling) or, failing that, nothing. */
+function addressBytesOf(a: ExposedAccount): Uint8Array | null {
+ const d = bech32Decode(a.address);
+ return d && d.data.length === 20 ? d.data : null;
+}
+
+/** Keplr Key for `chainId`, as JSON (bytes hex; the page turns them into Uint8Arrays). */
+export function cosmosKeyFor(account: ExposedAccount, chainId: string): CosmosKeyWire {
+ const chain = cosmosChainOf(chainId);
+ const bytes = addressBytesOf(account);
+ if (!chain || !bytes || !account.publicKey) throw rpcError.internal("This account can't be used on that chain.");
+ return {
+ algo: chain.algo,
+ pubKey: account.publicKey,
+ address: hexOf(bytes),
+ bech32Address: bech32Encode(chain.prefix, bytes),
+ // Lower-case hex (a valid EIP-55 spelling). Keplr checksums it; that needs keccak, which the page bundle doesn't carry.
+ ethereumHexAddress: `0x${hexOf(bytes)}`,
+ };
+}
+
+export function createCosmosDispatcher(r: CosmosRouterInternals) {
+ /** The connected account (first) for the family, or 4100. */
+ const mine = async (origin: string, family: Family): Promise => {
+ const list = await r.accounts(origin, family);
+ const a = list[0];
+ if (!a) throw rpcError.unauthorized();
+ return a;
+ };
+
+ /** The signer the page named must be one of this site's accounts, spelled with this chain's prefix. */
+ const requireOwnSigner = async (origin: string, family: Family, chainId: string, signer: unknown) => {
+ const chain = cosmosChainOf(chainId);
+ const d = typeof signer === "string" ? bech32Decode(signer) : null;
+ if (!chain || !d || d.prefix !== chain.prefix) throw rpcError.unauthorized("Signer mismatched");
+ const list = await r.accounts(origin, family);
+ if (!list.some((a) => {
+ const b = addressBytesOf(a);
+ return !!b && hexOf(b) === hexOf(d.data);
+ })) {
+ throw rpcError.unauthorized("Signer mismatched");
+ }
+ };
+
+ async function dispatch(origin: string, family: Family, method: string, params: unknown, chain: string | undefined): Promise {
+ if (!COSMOS_DISPATCH_FAMILIES.has(family)) throw rpcError.unsupportedMethod(method);
+
+ if (method === COSMOS_INJECTED.accounts) return (await r.permitted(origin, family)) ? r.accounts(origin, family) : [];
+ if (method === COSMOS_INJECTED.disable) {
+ await r.revoke(origin, family);
+ return null;
+ }
+
+ const net = r.requireNetwork(family, origin, chain);
+ if (net.family !== family || !net.id.startsWith("cosmos:")) throw rpcError.chainDisconnected();
+ const chainId = net.id.slice("cosmos:".length);
+ if (!cosmosChainOf(chainId)) throw rpcError.chainDisconnected();
+
+ if (method === COSMOS_INJECTED.enable) {
+ if (await r.permitted(origin, family)) return r.accounts(origin, family);
+ const ids = obj(params).chainIds;
+ return r.connect(origin, family, net, method, { chainIds: Array.isArray(ids) ? ids.filter((x) => typeof x === "string").slice(0, 32) : [chainId] });
+ }
+
+ if (method === COSMOS_INJECTED.getKey) {
+ await r.requirePermission(origin, family);
+ return cosmosKeyFor(await mine(origin, family), chainId);
+ }
+
+ if ((COSMOS_METHODS_ALLOWED.readOnly as readonly string[]).includes(method)) {
+ await r.requirePermission(origin, family);
+ if (!r.read) throw rpcError.unsupportedMethod(method);
+ if (method === COSMOS_INJECTED.sendTx) {
+ const p = obj(params);
+ if (typeof p.tx !== "string" || !p.tx) throw rpcError.invalidParams("Expected { tx: base64, mode }.");
+ if (p.mode !== "sync" && p.mode !== "async" && p.mode !== "block") throw rpcError.invalidParams("mode must be sync, async or block.");
+ }
+ return r.read(r.makeReq(origin, family, net, method, params));
+ }
+
+ if ((COSMOS_METHODS_ALLOWED.signing as readonly string[]).includes(method)) {
+ await r.requirePermission(origin, family);
+ const p = obj(params);
+ await requireOwnSigner(origin, family, chainId, method === COSMOS_INJECTED.signArbitrary ? p.signer : p.signerAddress);
+ if (method === COSMOS_INJECTED.signDirect) {
+ const doc = obj(p.signDoc);
+ if (typeof doc.bodyBytes !== "string" || typeof doc.authInfoBytes !== "string" || typeof doc.chainId !== "string") {
+ throw rpcError.invalidParams("Expected { signerAddress, signDoc: { bodyBytes, authInfoBytes, chainId, accountNumber } }.");
+ }
+ if (doc.chainId !== chainId) throw rpcError.invalidParams("Unmatched chain id with the sign doc");
+ }
+ if (method === COSMOS_INJECTED.signAmino) {
+ const doc = obj(p.signDoc);
+ // An ADR-36 doc (chain_id "") is a message signature, allowed on any chain; otherwise the doc names this chain.
+ if (typeof doc.chain_id !== "string" || (doc.chain_id !== "" && doc.chain_id !== chainId)) throw rpcError.invalidParams("Unmatched chain id with the sign doc");
+ }
+ if (method === COSMOS_INJECTED.signArbitrary && (typeof p.data !== "string" || !p.data)) throw rpcError.invalidParams("Expected { signer, data: base64 }.");
+ return r.approve(r.makeReq(origin, family, net, method, params));
+ }
+
+ throw rpcError.unsupportedMethod(method);
+ }
+
+ return { dispatch };
+}
+
+export { bytesOfHex };
diff --git a/packages/1mask/src/background/fuel.ts b/packages/1mask/src/background/fuel.ts
new file mode 100644
index 00000000..53bc375e
--- /dev/null
+++ b/packages/1mask/src/background/fuel.ts
@@ -0,0 +1,108 @@
+import type { DappRequest, Family, Network, NetworkId } from "@clip-wallet/core";
+import { rpcError } from "../shared/errors.js";
+import { FUEL_INJECTED, fuelChainId, fuelConnectorNetwork } from "../shared/fuel.js";
+import type { ExposedAccount, OneMaskEvent } from "../shared/protocol.js";
+
+/**
+ * Background side of the Fuel connector (inpage/fuel.ts). The router keeps the policy (permissions, approvals, rate
+ * limits, per-site networks) and lends its closures as `FuelRouterInternals` (the same shape as StarknetTonHelpers);
+ * this file knows the connector's method list, how its params name the account, and Fuel networks
+ * ({ url, chainId }, fuels-ts connectors/types `Network`). Wire it into router.ts for `family === "fuel"`, and
+ * `fuelInjectedAllowlist()` into methods.ts.
+ */
+
+export const FUEL_METHODS_ALLOWED = {
+ /** Answered here, no prompt. */
+ local: [FUEL_INJECTED.accounts, FUEL_INJECTED.disconnect, FUEL_INJECTED.currentNetwork, FUEL_INJECTED.networks, FUEL_INJECTED.selectNetwork],
+ connect: [FUEL_INJECTED.connect],
+ signing: [FUEL_INJECTED.sendTransaction, FUEL_INJECTED.signTransaction, FUEL_INJECTED.signMessage],
+} as const;
+
+export function fuelInjectedAllowlist(): ReadonlySet {
+ return new Set([...FUEL_METHODS_ALLOWED.local, ...FUEL_METHODS_ALLOWED.connect, ...FUEL_METHODS_ALLOWED.signing]);
+}
+
+export interface FuelRouterInternals {
+ permitted(origin: string, family: Family): Promise;
+ accounts(origin: string, family: Family): Promise;
+ /** Connect approval + permission grant; resolves with the accounts the site now sees. */
+ connect(origin: string, family: Family, net: Network, method: string, params: unknown): Promise;
+ approve(req: DappRequest): Promise;
+ makeReq(origin: string, family: Family, net: Network, method: string, params: unknown): DappRequest;
+ selectedNetwork(origin: string, family: Family): Network | undefined;
+ setSelected(origin: string, family: Family, id: NetworkId): void;
+ candidates(family: Family): Network[];
+ emit(origin: string, family: Family, event: OneMaskEvent, data?: unknown): void;
+ revoke(origin: string, family: Family, o?: { silent?: boolean }): Promise;
+}
+
+const obj = (p: unknown): Record => (p && typeof p === "object" && !Array.isArray(p) ? (p as Record) : {});
+
+const sameUrl = (a: string, b: string) => {
+ try {
+ const x = new URL(a);
+ const y = new URL(b);
+ return x.host === y.host && x.pathname.replace(/\/+$/, "") === y.pathname.replace(/\/+$/, "");
+ } catch {
+ return false;
+ }
+};
+
+/** Fuel addresses are 0x-hex in any case (checksummed or not): compare lower-case. */
+const sameAddress = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
+
+export function createFuelDispatcher(h: FuelRouterInternals) {
+ const FAMILY: Family = "fuel";
+
+ const selected = (origin: string): Network => {
+ const net = h.selectedNetwork(origin, FAMILY);
+ if (!net) throw rpcError.chainDisconnected("No Fuel network is available.");
+ return net;
+ };
+
+ async function dispatch(origin: string, method: string, params: unknown): Promise {
+ switch (method) {
+ case FUEL_INJECTED.accounts:
+ return (await h.permitted(origin, FAMILY)) ? h.accounts(origin, FAMILY) : [];
+ case FUEL_INJECTED.connect:
+ if (await h.permitted(origin, FAMILY)) return h.accounts(origin, FAMILY);
+ return h.connect(origin, FAMILY, selected(origin), method, {});
+ case FUEL_INJECTED.disconnect:
+ await h.revoke(origin, FAMILY);
+ return null;
+ case FUEL_INJECTED.currentNetwork:
+ // FuelConnector.currentNetwork: answered "even if the connection is not established".
+ return fuelConnectorNetwork(selected(origin));
+ case FUEL_INJECTED.networks:
+ return h.candidates(FAMILY).map(fuelConnectorNetwork);
+ case FUEL_INJECTED.selectNetwork: {
+ const p = obj(params);
+ const net = h.candidates(FAMILY).find(
+ (n) => (typeof p.chainId === "number" && fuelChainId(n.id) === p.chainId) || (typeof p.url === "string" && n.rpcUrls.some((u) => sameUrl(u, p.url as string))),
+ );
+ // Never add networks: only the ones that ship with the wallet.
+ if (!net) throw rpcError.unrecognizedChain(String(p.chainId ?? p.url ?? ""));
+ const before = h.selectedNetwork(origin, FAMILY);
+ h.setSelected(origin, FAMILY, net.id);
+ if (before?.id !== net.id) h.emit(origin, FAMILY, "chainChanged", fuelConnectorNetwork(net));
+ return true;
+ }
+ }
+
+ if ((FUEL_METHODS_ALLOWED.signing as readonly string[]).includes(method)) {
+ if (!(await h.permitted(origin, FAMILY))) throw rpcError.unauthorized();
+ const p = obj(params);
+ if (typeof p.address !== "string") throw rpcError.invalidParams("Expected { address, … }.");
+ const list = await h.accounts(origin, FAMILY);
+ if (!list.some((a) => sameAddress(a.address, p.address as string))) throw rpcError.unauthorized("That account is not connected to this site.");
+ if (method === FUEL_INJECTED.signMessage ? p.message === undefined : p.transaction === undefined) {
+ throw rpcError.invalidParams(method === FUEL_INJECTED.signMessage ? "Expected { address, message }." : "Expected { address, transaction }.");
+ }
+ return h.approve(h.makeReq(origin, FAMILY, selected(origin), method, params));
+ }
+
+ throw rpcError.unsupportedMethod(method);
+ }
+
+ return { dispatch };
+}
diff --git a/packages/1mask/src/background/index.ts b/packages/1mask/src/background/index.ts
index 8d824509..30d8fa58 100644
--- a/packages/1mask/src/background/index.ts
+++ b/packages/1mask/src/background/index.ts
@@ -39,3 +39,17 @@ export {
} from "./starknet-ton.js";
export { createCallsDispatch, type CallsRouterHelpers } from "./eip5792.js";
export * from "../shared/calls.js";
+/* networks87: dispatchers live in router.ts; hosts need the connect and read-only method names. */
+export { COSMOS_CONNECT_METHODS, COSMOS_INJECTED, COSMOS_FAMILIES } from "../shared/cosmos.js";
+export { TRON_CONNECT_METHODS } from "./tron.js";
+export { STACKS_INJECTED } from "../shared/stacks.js";
+export { FUEL_CONNECT_METHODS } from "../shared/fuel.js";
+import { COSMOS_CONNECT_METHODS as _COSMOS_CONNECT, COSMOS_INJECTED as _COSMOS } from "../shared/cosmos.js";
+import { TRON_CONNECT_METHODS as _TRON_CONNECT } from "./tron.js";
+import { STACKS_INJECTED as _STACKS } from "../shared/stacks.js";
+import { FUEL_CONNECT_METHODS as _FUEL_CONNECT } from "../shared/fuel.js";
+import { MULTIVERSX_CONNECT_METHODS as _MVX_CONNECT } from "./multiversx.js";
+/** Methods the host treats as a connect approval (Keplr enable, TIP-1193 accounts, SIP-030 addresses, FuelConnector connect, MultiversX provider login). */
+export const N87_CONNECT_METHODS: readonly string[] = [..._COSMOS_CONNECT, ..._TRON_CONNECT, _STACKS.connect, ..._FUEL_CONNECT, ..._MVX_CONNECT];
+/** Read-only chain calls answered by a chain module without an approval (Keplr sendTx of an already-signed tx, verifyArbitrary). */
+export const N87_CHAIN_READ: readonly string[] = [_COSMOS.sendTx, _COSMOS.verifyArbitrary];
diff --git a/packages/1mask/src/background/methods.ts b/packages/1mask/src/background/methods.ts
index 301f9b5d..e903e263 100644
--- a/packages/1mask/src/background/methods.ts
+++ b/packages/1mask/src/background/methods.ts
@@ -2,6 +2,12 @@ import type { Family } from "@clip-wallet/core";
import { METHOD_PROVIDER_STATE, METHOD_WS_STATE } from "../shared/protocol.js";
import { APTOS_CONNECT_METHODS, APTOS_LOCAL_METHODS, APTOS_SIGNING_METHODS, SUI_SIGNING_METHODS } from "../shared/move-methods.js";
import { p2InjectedAllowlist } from "./p2-families.js";
+import { cosmosInjectedAllowlist } from "./cosmos.js";
+import { tronInjectedAllowlist } from "./tron.js";
+import { stacksInjectedAllowlist } from "./stacks.js";
+import { fuelInjectedAllowlist } from "./fuel.js";
+import { xrplAllowlist } from "../shared/xrpl.js";
+import { multiversxInjectedAllowlist } from "./multiversx.js";
import { cardanoSubstrateAllowlist } from "./cardano-substrate.js";
import { starknetTonAllowlist } from "./starknet-ton.js";
import { HEDERA_INJECTED_METHODS } from "../shared/hedera.js";
@@ -126,8 +132,23 @@ export function injectedAllowlist(family: Family): ReadonlySet {
case "starknet":
case "ton":
return starknetTonAllowlist(family);
+ case "cosmos":
+ case "provenance":
+ case "thorchain":
+ case "initia":
+ return cosmosInjectedAllowlist(family);
+ case "tron":
+ return tronInjectedAllowlist();
+ case "stacks":
+ return stacksInjectedAllowlist();
+ case "fuel":
+ return fuelInjectedAllowlist();
+ case "xrpl":
+ return xrplAllowlist();
+ case "multiversx":
+ return multiversxInjectedAllowlist();
default:
- // Phase 2 families register their injected methods here as their connectors land.
+ // Families without an injected provider (antelope, icp; bitcoincash over WalletConnect): send and receive only.
return new Set();
}
}
diff --git a/packages/1mask/src/background/multiversx.ts b/packages/1mask/src/background/multiversx.ts
new file mode 100644
index 00000000..bb7e61e6
--- /dev/null
+++ b/packages/1mask/src/background/multiversx.ts
@@ -0,0 +1,85 @@
+import { rpcError } from "../shared/errors.js";
+import { MULTIVERSX_INJECTED, nativeAuthOrigin } from "../shared/multiversx-methods.js";
+import type { P2RouterInternals } from "./p2-families.js";
+
+/**
+ * Background side of the MultiversX provider (inpage/multiversx.ts). Same shape as createTronDispatcher: the router
+ * keeps the policy (permissions, approvals, rate limits, timeouts) and lends its closures; this file only knows the
+ * method list and which account a request names. Signing goes to chains-multiversx unchanged (mvx_signTransactions /
+ * mvx_signMessage), which checks the sender and chain again and decodes in plain words.
+ *
+ * Wiring (router.ts / methods.ts, by the coordinator):
+ * const multiversx = createMultiversXDispatcher({ permitted, requirePermission, accounts, connect, approve, makeReq, requireNetwork, revoke });
+ * if (family === "multiversx") return multiversx.dispatch(origin, method, params, chain);
+ * injectedAllowlist("multiversx") = multiversxInjectedAllowlist()
+ */
+
+export const MULTIVERSX_METHODS_ALLOWED = {
+ local: [MULTIVERSX_INJECTED.accounts, MULTIVERSX_INJECTED.disconnect],
+ connect: [MULTIVERSX_INJECTED.connect],
+ signing: [MULTIVERSX_INJECTED.signTransactions, MULTIVERSX_INJECTED.signMessage],
+} as const;
+
+export const MULTIVERSX_CONNECT_METHODS = [MULTIVERSX_INJECTED.connect] as const;
+
+/** For methods.ts `injectedAllowlist`. */
+export function multiversxInjectedAllowlist(): ReadonlySet {
+ const a = MULTIVERSX_METHODS_ALLOWED;
+ return new Set([...a.local, ...a.connect, ...a.signing]);
+}
+
+const MAX_TXS = 20;
+const obj = (p: unknown): Record => (p && typeof p === "object" && !Array.isArray(p) ? (p as Record) : {});
+
+function originOf(url: string): string | null {
+ try {
+ return new URL(url).origin;
+ } catch {
+ return null;
+ }
+}
+
+export function createMultiversXDispatcher(r: P2RouterInternals) {
+ async function dispatch(origin: string, method: string, params: unknown, chain: string | undefined): Promise {
+ // Silent account read (no prompt).
+ if (method === MULTIVERSX_INJECTED.accounts) return (await r.permitted(origin, "multiversx")) ? r.accounts(origin, "multiversx") : [];
+
+ if (method === MULTIVERSX_INJECTED.disconnect) {
+ await r.revoke(origin, "multiversx");
+ return null;
+ }
+
+ if (method === MULTIVERSX_INJECTED.connect) {
+ if (await r.permitted(origin, "multiversx")) return r.accounts(origin, "multiversx");
+ return r.connect(origin, "multiversx", r.requireNetwork("multiversx", origin, chain), method, {});
+ }
+
+ if ((MULTIVERSX_METHODS_ALLOWED.signing as readonly string[]).includes(method)) {
+ await r.requirePermission(origin, "multiversx");
+ const p = obj(params);
+ const mine = new Set((await r.accounts(origin, "multiversx")).map((a) => a.address));
+ const own = (address: unknown) => {
+ if (typeof address !== "string" || !mine.has(address)) throw rpcError.unauthorized("That account is not connected to this site.");
+ };
+ if (p.address !== undefined) own(p.address);
+ if (method === MULTIVERSX_INJECTED.signTransactions) {
+ const txs = p.transactions;
+ if (!Array.isArray(txs) || txs.length === 0 || txs.length > MAX_TXS) throw rpcError.invalidParams(`Expected { transactions: 1 to ${MAX_TXS} plain transactions }.`);
+ for (const t of txs) own(obj(t).sender);
+ } else {
+ if (typeof p.message !== "string" || typeof p.address !== "string") throw rpcError.invalidParams("Expected { message, address }.");
+ // A native-auth login token names the site it signs in to. Another site's token is refused outright: signing
+ // it would hand this site a login to that one.
+ const token = p.message.startsWith(p.address) ? p.message.slice(p.address.length) : "";
+ const site = token ? nativeAuthOrigin(token) : null;
+ if (site !== null && originOf(site) !== originOf(origin)) throw rpcError.unauthorized("This sign-in is for a different site.");
+ }
+ const net = r.requireNetwork("multiversx", origin, chain);
+ return r.approve(r.makeReq(origin, "multiversx", net, method, params));
+ }
+
+ throw rpcError.unsupportedMethod(method);
+ }
+
+ return { dispatch };
+}
diff --git a/packages/1mask/src/background/router.ts b/packages/1mask/src/background/router.ts
index c72ed424..dbfb1b77 100644
--- a/packages/1mask/src/background/router.ts
+++ b/packages/1mask/src/background/router.ts
@@ -27,6 +27,14 @@ import { HEDERA_WC_PAIR, isWalletConnectPairingUri } from "../shared/hedera.js";
import type { PermissionStore } from "./permissions.js";
import { createStarknetTonDispatch, type StarknetTonOptions } from "./starknet-ton.js";
import { createCallsDispatch } from "./eip5792.js";
+import { COSMOS_DISPATCH_FAMILIES, createCosmosDispatcher } from "./cosmos.js";
+import { createTronDispatcher } from "./tron.js";
+import { createStacksDispatcher } from "./stacks.js";
+import { stacksAddressOn } from "../shared/stacks.js";
+import { bchAddressOn } from "../shared/bitcoincash.js";
+import { createFuelDispatcher } from "./fuel.js";
+import { createXrplDispatcher } from "./xrpl.js";
+import { createMultiversXDispatcher } from "./multiversx.js";
import { isCallsMethod, type CallsHost } from "../shared/calls.js";
/** Background side of a runtime port (chrome.runtime.Port satisfies it). */
@@ -299,10 +307,31 @@ export function createOneMaskRouter(opts: OneMaskRouterOptions): OneMaskRouter {
pendingConnect.delete(key);
}
const list = await accounts(origin, family);
- emit(origin, family, "accountsChanged", family === "evm" ? list.map((a) => a.address) : list);
+ // Stacks / Bitcoin Cash spell the account per network: the network the site connected on is the one its later
+ // accountsChanged events are spelled for (they have no switch-network method of their own).
+ if (family === "stacks" || family === "bitcoincash") setSelected(origin, family, net.id);
+ emit(origin, family, "accountsChanged", await eventAccounts(origin, family, list));
return list;
};
+ /**
+ * What an accountsChanged event carries: EVM addresses; Stacks and Bitcoin Cash accounts spelled for the site's
+ * selected network (Account.address is the vault's mainnet "SP…" / "bitcoincash:" form), the way their dispatchers
+ * answer `accounts`; the account list otherwise.
+ */
+ const eventAccounts = async (origin: string, family: Family, list: ExposedAccount[]): Promise => {
+ if (family === "evm") return list.map((a) => a.address);
+ if (family !== "stacks" && family !== "bitcoincash") return list;
+ const net = selectedNetwork(origin, family);
+ if (!net) return [];
+ const out: ExposedAccount[] = [];
+ for (const a of list) {
+ const address = family === "stacks" ? await stacksAddressOn(a.address, net.id) : bchAddressOn(a.address, net.id);
+ if (address) out.push({ ...a, address });
+ }
+ return out;
+ };
+
const emit = (origin: string, family: Family, event: OneMaskEvent, data?: unknown) => {
const msg: PortEvent = { type: "event", family, event, data };
for (const p of ports.get(origin) ?? []) {
@@ -510,7 +539,7 @@ export function createOneMaskRouter(opts: OneMaskRouterOptions): OneMaskRouter {
const res = await approve(makeReq(origin, family, net, method, { inputs }));
if (!(await permitted(origin, family))) {
await opts.permissions.grant(origin, family);
- emit(origin, family, "accountsChanged", await accounts(origin, family));
+ emit(origin, family, "accountsChanged", await eventAccounts(origin, family, await accounts(origin, family)));
}
return res;
}
@@ -584,6 +613,36 @@ export function createOneMaskRouter(opts: OneMaskRouterOptions): OneMaskRouter {
opts,
);
+ /* ------------------------------------------------------------ networks87: Cosmos SDK, TRON, Stacks, Fuel */
+
+ const n87Internals = {
+ permitted,
+ requirePermission,
+ accounts,
+ connect,
+ approve,
+ makeReq,
+ requireNetwork,
+ revoke: (origin: string, family: Family) => revoke(origin, family),
+ };
+ const cosmos = createCosmosDispatcher({ ...n87Internals, read: (req) => withTimeout(opts.handle(req), readMs, req.id) });
+ const tron = createTronDispatcher(n87Internals);
+ const xrpl = createXrplDispatcher(n87Internals);
+ const multiversx = createMultiversXDispatcher(n87Internals);
+ const stacks = createStacksDispatcher({ ...n87Internals, networks: () => candidates("stacks") });
+ const fuel = createFuelDispatcher({
+ permitted,
+ accounts,
+ connect,
+ approve,
+ makeReq,
+ selectedNetwork,
+ setSelected,
+ candidates,
+ emit,
+ revoke: (origin, family, o) => revoke(origin, family, o),
+ });
+
/* ------------------------------------------------------------ EIP-5792 (opt-in) */
const callsDispatch = opts.calls ? createCallsDispatch({ permitted, accounts, approve, makeReq, candidates }, opts.calls) : undefined;
@@ -614,6 +673,12 @@ export function createOneMaskRouter(opts: OneMaskRouterOptions): OneMaskRouter {
if (family === "cardano" || family === "substrate") return dispatchCardanoSubstrate(cardanoSubstrateHelpers, origin, family, method, params, chain);
if (family === "starknet") return starknetTon.starknet(origin, method, params);
if (family === "ton") return starknetTon.ton(origin, method, params);
+ if (COSMOS_DISPATCH_FAMILIES.has(family)) return cosmos.dispatch(origin, family, method, params, chain);
+ if (family === "tron") return tron.dispatch(origin, method, params, chain);
+ if (family === "stacks") return stacks.dispatch(origin, family, method, params, chain);
+ if (family === "fuel") return fuel.dispatch(origin, method, params);
+ if (family === "xrpl") return xrpl.dispatch(origin, method, params, chain);
+ if (family === "multiversx") return multiversx.dispatch(origin, method, params, chain);
if (family === "hedera" && method === HEDERA_WC_PAIR) {
const uri = (params as { uri?: unknown } | undefined)?.uri;
if (!opts.walletConnectPair) throw rpcError.unsupportedMethod(method);
@@ -680,8 +745,7 @@ export function createOneMaskRouter(opts: OneMaskRouterOptions): OneMaskRouter {
for (const origin of ports.keys()) {
for (const f of family ? [family] : FAMILIES) {
if (!(await permitted(origin, f))) continue;
- const list = await accounts(origin, f);
- emit(origin, f, "accountsChanged", f === "evm" ? list.map((a) => a.address) : list);
+ emit(origin, f, "accountsChanged", await eventAccounts(origin, f, await accounts(origin, f)));
}
}
};
diff --git a/packages/1mask/src/background/stacks.ts b/packages/1mask/src/background/stacks.ts
new file mode 100644
index 00000000..b677d920
--- /dev/null
+++ b/packages/1mask/src/background/stacks.ts
@@ -0,0 +1,92 @@
+import type { Family, Network } from "@clip-wallet/core";
+import { rpcError } from "../shared/errors.js";
+import type { ExposedAccount } from "../shared/protocol.js";
+import { STACKS_CHAINS, STACKS_INJECTED, STACKS_SIGNING_METHODS, stacksAddressOn, stacksNetworkName } from "../shared/stacks.js";
+import type { P2RouterInternals } from "./p2-families.js";
+
+/**
+ * Background side of the injected Stacks provider (inpage/stacks.ts, SIP-030 `request`). The router keeps the
+ * policy (permissions, approvals, rate limits, timeouts); this file knows Stacks' method list and that an
+ * account's address is spelled per network (Account.address is the mainnet "SP…" form; the testnet is "ST…").
+ * Wired into router.ts by the integration step, like createP2Dispatcher.
+ */
+
+export const STACKS_METHODS_ALLOWED = {
+ local: [STACKS_INJECTED.accounts, STACKS_INJECTED.disconnect, STACKS_INJECTED.getNetworks],
+ connect: [STACKS_INJECTED.connect],
+ signing: STACKS_SIGNING_METHODS,
+} as const;
+
+/** For methods.ts `injectedAllowlist`. */
+export function stacksInjectedAllowlist(): ReadonlySet {
+ return new Set([...STACKS_METHODS_ALLOWED.local, ...STACKS_METHODS_ALLOWED.connect, ...STACKS_METHODS_ALLOWED.signing]);
+}
+
+export interface StacksRouterInternals extends Pick {
+ /** The wallet's Stacks networks (for stx_getNetworks). Default: only the one the site is on. */
+ networks?(): Network[];
+}
+
+const FAMILY: Family = "stacks";
+const obj = (p: unknown): Record => (p && typeof p === "object" && !Array.isArray(p) ? (p as Record) : {});
+
+export function createStacksDispatcher(r: StacksRouterInternals) {
+ /** The site's accounts, spelled for `net`. */
+ async function spelled(origin: string, net: Network, list?: ExposedAccount[]): Promise {
+ const accounts = list ?? (await r.accounts(origin, FAMILY));
+ const out: ExposedAccount[] = [];
+ for (const a of accounts) {
+ const address = await stacksAddressOn(a.address, net.id);
+ if (address) out.push({ ...a, address });
+ }
+ return out;
+ }
+
+ async function dispatch(origin: string, family: Family, method: string, params: unknown, chain: string | undefined): Promise {
+ if (family !== FAMILY) throw rpcError.unsupportedMethod(method);
+
+ if (method === STACKS_INJECTED.accounts) {
+ if (!(await r.permitted(origin, FAMILY))) return [];
+ return spelled(origin, r.requireNetwork(FAMILY, origin, chain));
+ }
+
+ if (method === STACKS_INJECTED.disconnect) {
+ await r.revoke(origin, FAMILY);
+ return null;
+ }
+
+ if (method === STACKS_INJECTED.connect) {
+ const net = r.requireNetwork(FAMILY, origin, chain);
+ const list = (await r.permitted(origin, FAMILY)) ? await r.accounts(origin, FAMILY) : await r.connect(origin, FAMILY, net, method, params ?? {});
+ return spelled(origin, net, list);
+ }
+
+ if (method === STACKS_INJECTED.getNetworks) {
+ await r.requirePermission(origin, FAMILY);
+ const active = r.requireNetwork(FAMILY, origin, chain);
+ const nets = (r.networks?.() ?? [active]).filter((n) => n.family === FAMILY && stacksNetworkName(n.id));
+ const shape = (n: Network) => {
+ const name = stacksNetworkName(n.id)!;
+ return { id: name, chainId: STACKS_CHAINS[name].chainId, transactionVersion: STACKS_CHAINS[name].transactionVersion };
+ };
+ return { active: stacksNetworkName(active.id), networks: nets.map(shape) };
+ }
+
+ if (STACKS_SIGNING_METHODS.includes(method)) {
+ await r.requirePermission(origin, FAMILY);
+ const net = r.requireNetwork(FAMILY, origin, chain);
+ const address = obj(params).address;
+ if (address !== undefined && address !== null) {
+ const mine = await spelled(origin, net);
+ if (typeof address !== "string" || !mine.some((a) => a.address === address.trim().toUpperCase())) {
+ throw rpcError.unauthorized("That account is not connected to this site.");
+ }
+ }
+ return r.approve(r.makeReq(origin, FAMILY, net, method, params ?? {}));
+ }
+
+ throw rpcError.unsupportedMethod(method);
+ }
+
+ return { dispatch };
+}
diff --git a/packages/1mask/src/background/tron.ts b/packages/1mask/src/background/tron.ts
new file mode 100644
index 00000000..06e80db4
--- /dev/null
+++ b/packages/1mask/src/background/tron.ts
@@ -0,0 +1,62 @@
+import { rpcError } from "../shared/errors.js";
+import { TRON_INJECTED } from "../shared/tron-methods.js";
+import type { P2RouterInternals } from "./p2-families.js";
+
+/**
+ * Background side of the injected TRON provider (inpage/tron.ts). Same shape as createP2Dispatcher: the router keeps
+ * the policy (permissions, approvals, rate limits, timeouts) and lends its closures; this file only knows TRON's
+ * method list and where its params name the signer. eth_chainId / wallet_switchEthereumChain are answered in the page
+ * (the page passes its chain with every call and requireNetwork checks it against the registry).
+ */
+
+export const TRON_METHODS_ALLOWED = {
+ local: [TRON_INJECTED.accounts, TRON_INJECTED.disconnect],
+ connect: [TRON_INJECTED.connect],
+ signing: [TRON_INJECTED.signTransaction, TRON_INJECTED.signMessage],
+} as const;
+
+export const TRON_CONNECT_METHODS = [TRON_INJECTED.connect] as const;
+
+/** For methods.ts `injectedAllowlist`. */
+export function tronInjectedAllowlist(): ReadonlySet {
+ const a = TRON_METHODS_ALLOWED;
+ return new Set([...a.local, ...a.connect, ...a.signing]);
+}
+
+const obj = (p: unknown): Record => (p && typeof p === "object" && !Array.isArray(p) ? (p as Record) : {});
+
+export function createTronDispatcher(r: P2RouterInternals) {
+ async function dispatch(origin: string, method: string, params: unknown, chain: string | undefined): Promise {
+ // Silent account read (no prompt).
+ if (method === TRON_INJECTED.accounts) return (await r.permitted(origin, "tron")) ? r.accounts(origin, "tron") : [];
+
+ if (method === TRON_INJECTED.disconnect) {
+ await r.revoke(origin, "tron");
+ return null;
+ }
+
+ if (method === TRON_INJECTED.connect) {
+ if (await r.permitted(origin, "tron")) return r.accounts(origin, "tron");
+ return r.connect(origin, "tron", r.requireNetwork("tron", origin, chain), method, params ?? {});
+ }
+
+ if ((TRON_METHODS_ALLOWED.signing as readonly string[]).includes(method)) {
+ await r.requirePermission(origin, "tron");
+ const p = obj(params);
+ // The signer is named by `address` (Reown TRON params); chains-tron also checks it against the account.
+ const address = p.address;
+ const list = await r.accounts(origin, "tron");
+ if (address !== undefined && (typeof address !== "string" || !list.some((a) => a.address === address))) {
+ throw rpcError.unauthorized("That account is not connected to this site.");
+ }
+ if (method === TRON_INJECTED.signTransaction && (!p.transaction || typeof p.transaction !== "object")) throw rpcError.invalidParams("Expected { address, transaction }.");
+ if (method === TRON_INJECTED.signMessage && typeof p.message !== "string") throw rpcError.invalidParams("Expected { address, message }.");
+ const net = r.requireNetwork("tron", origin, chain);
+ return r.approve(r.makeReq(origin, "tron", net, method, params));
+ }
+
+ throw rpcError.unsupportedMethod(method);
+ }
+
+ return { dispatch };
+}
diff --git a/packages/1mask/src/background/xrpl.ts b/packages/1mask/src/background/xrpl.ts
new file mode 100644
index 00000000..c6a2e152
--- /dev/null
+++ b/packages/1mask/src/background/xrpl.ts
@@ -0,0 +1,59 @@
+import { rpcError } from "../shared/errors.js";
+import { XRPL_INJECTED, XRPL_METHODS_ALLOWED, xrplAllowlist, xrplChainId } from "../shared/xrpl.js";
+import type { P2RouterInternals } from "./p2-families.js";
+
+/**
+ * Background side of the XLS-72d XRPL wallet (inpage/xrpl.ts). The router keeps the policy (permissions,
+ * approvals, rate limits, timeouts); this file only knows the method list and where XLS-72d params name the account
+ * and the network. To wire it (coordinator): `injectedAllowlist("xrpl")` → `xrplAllowlist()`, and in `dispatchRaw`
+ * `if (family === "xrpl") return xrpl.dispatch(origin, method, params, chain)` with
+ * `const xrpl = createXrplDispatcher({ permitted, requirePermission, accounts, connect, approve, makeReq, requireNetwork, revoke })`.
+ *
+ * DappRequest params for both signing methods: `{ tx_json, account: "r…", network: "xrpl:", options? }` with
+ * `networkId` = that network (chains-xrpl normalize() checks every field again before anything is signed).
+ */
+export { xrplAllowlist, XRPL_METHODS_ALLOWED };
+
+export type XrplRouterInternals = Pick;
+
+const obj = (p: unknown): Record => (p && typeof p === "object" && !Array.isArray(p) ? (p as Record) : {});
+
+export function createXrplDispatcher(r: XrplRouterInternals) {
+ async function dispatch(origin: string, method: string, params: unknown, chain: string | undefined): Promise {
+ if (!xrplAllowlist().has(method)) throw rpcError.unsupportedMethod(method);
+
+ if (method === XRPL_INJECTED.state) return (await r.permitted(origin, "xrpl")) ? r.accounts(origin, "xrpl") : [];
+
+ if (method === XRPL_INJECTED.disconnect) {
+ await r.revoke(origin, "xrpl");
+ return null;
+ }
+
+ if (method === XRPL_INJECTED.connect) {
+ if (await r.permitted(origin, "xrpl")) return r.accounts(origin, "xrpl");
+ return r.connect(origin, "xrpl", r.requireNetwork("xrpl", origin, chain === undefined ? undefined : xrplChainId(chain) ?? chain), method, params ?? {});
+ }
+
+ // xrpl:signTransaction / xrpl:signAndSubmitTransaction
+ await r.requirePermission(origin, "xrpl");
+ const p = obj(params);
+ const tx = obj(p.tx_json);
+ if (!Object.keys(tx).length) throw rpcError.invalidParams("Expected { tx_json, account, network }.");
+ const list = await r.accounts(origin, "xrpl");
+ for (const a of [p.account, tx.Account]) {
+ if (a === undefined) continue;
+ if (typeof a !== "string" || !list.some((x) => x.address === a)) throw rpcError.unauthorized("That account is not connected to this site.");
+ }
+ if (p.account === undefined && tx.Account === undefined) throw rpcError.invalidParams("Expected { tx_json, account, network }.");
+ const fromParams = p.network === undefined ? undefined : xrplChainId(p.network);
+ if (p.network !== undefined && !fromParams) throw rpcError.invalidParams("network must be an XRPL chain such as xrpl:1.");
+ const hint = chain === undefined ? undefined : (xrplChainId(chain) ?? chain);
+ if (fromParams && hint && fromParams !== hint) throw rpcError.invalidParams("The request names two different networks.");
+ const net = r.requireNetwork("xrpl", origin, fromParams ?? hint);
+ const wire: Record = { tx_json: tx, account: p.account ?? tx.Account, network: net.id };
+ if (p.options !== undefined) wire.options = obj(p.options);
+ return r.approve(r.makeReq(origin, "xrpl", net, method, wire));
+ }
+
+ return { dispatch };
+}
diff --git a/packages/1mask/src/inpage/cosmos.ts b/packages/1mask/src/inpage/cosmos.ts
new file mode 100644
index 00000000..c0da98e6
--- /dev/null
+++ b/packages/1mask/src/inpage/cosmos.ts
@@ -0,0 +1,323 @@
+import type { Family, Network } from "@clip-wallet/core";
+import type { WalletIdentity } from "../shared/config.js";
+import { ProviderRpcError, RpcErrorCode } from "../shared/errors.js";
+import {
+ COSMOS_FAMILIES,
+ COSMOS_INJECTED,
+ type CosmosChainInfoWithoutEndpoints,
+ type CosmosFamily,
+ type CosmosKeyWire,
+ chainInfoWithoutEndpoints,
+ cosmosChainOf,
+ b64FromBytes,
+ bytesFromB64,
+} from "../shared/cosmos.js";
+import { bytesOfHex } from "../shared/cosmos-bech32.js";
+import { Emitter } from "./emitter.js";
+import { DEFAULT_GLOBAL_KEY, exposeOnGlobal, type InjectedOptions } from "./injected-base.js";
+import type { InpageTransport } from "./transport.js";
+
+/**
+ * Keplr-compatible Cosmos provider at `window.clipwallet.cosmos` (never `window.keplr`: Clip announces its own
+ * identity). Method names, arguments and results follow Keplr's `Keplr` interface (@keplr-wallet/types 0.13.41
+ * src/wallet/keplr.ts; https://docs.keplr.app/api/guide/enable-connection, get-key, sign-a-message, sign-arbitrary,
+ * broadcast-tx, suggest-chain, https://docs.keplr.app/api/use-with/cosmjs), so a dapp written for Keplr works by
+ * swapping `window.keplr` for this object. Offline signers follow @keplr-wallet/provider 0.13.41 src/cosmjs.ts
+ * (chain-id and signer checks before signing; getAccounts reports algo "secp256k1", as Keplr does).
+ *
+ * Every chain id maps to one Clip family by the wallet's network list ("cosmos:"), so permissions,
+ * accounts and approvals are per family; the background re-checks everything. Holds no secrets.
+ *
+ * Account changes: `on("keystorechange", …)` and a window event `_keystorechange` (Keplr's documented
+ * equivalent is `keplr_keystorechange`, https://docs.keplr.app/api/guide/custom-event; Clip doesn't fire Keplr's).
+ */
+
+/** Keplr Key (types/src/wallet/keplr.ts L29-42). */
+export interface CosmosKey {
+ readonly name: string;
+ readonly algo: string;
+ readonly pubKey: Uint8Array;
+ readonly address: Uint8Array;
+ readonly bech32Address: string;
+ readonly ethereumHexAddress: string;
+ readonly isNanoLedger: boolean;
+ readonly isKeystone: boolean;
+}
+
+export interface KeplrSignOptions {
+ readonly preferNoSetFee?: boolean;
+ readonly preferNoSetMemo?: boolean;
+ readonly disableBalanceCheck?: boolean;
+}
+
+export interface StdSignature {
+ readonly pub_key: { readonly type: string; readonly value: string };
+ readonly signature: string;
+}
+
+/** cosmjs StdSignDoc (amino JSON). */
+export interface StdSignDoc {
+ readonly chain_id: string;
+ readonly account_number: string;
+ readonly sequence: string;
+ readonly timeout_height?: string;
+ readonly fee: { readonly amount: readonly { denom: string; amount: string }[]; readonly gas: string; readonly payer?: string; readonly granter?: string };
+ readonly msgs: readonly { type: string; value: unknown }[];
+ readonly memo: string;
+}
+
+/** Account number as Keplr passes it (a `long` Long), a bigint (cosmjs ≥ 0.32 / cosmjs-types), a number or a string. */
+type AccountNumber = { toString(): string } | bigint | number | string | null | undefined;
+
+export interface DirectSignDoc {
+ bodyBytes?: Uint8Array | null;
+ authInfoBytes?: Uint8Array | null;
+ chainId?: string | null;
+ accountNumber?: AccountNumber;
+}
+
+export interface AccountData {
+ readonly address: string;
+ readonly algo: "secp256k1";
+ readonly pubkey: Uint8Array;
+}
+
+export type BroadcastMode = "block" | "sync" | "async";
+
+const enc = new TextEncoder();
+
+function plainError(message: string, code: number = RpcErrorCode.InvalidParams): ProviderRpcError {
+ return new ProviderRpcError(code, message);
+}
+
+export class ClipCosmosProvider {
+ readonly isClipWallet = true;
+ readonly name: string;
+ readonly icon: string;
+ /** Keplr fields: version, mode, defaultOptions (pages may change defaultOptions, as with Keplr). */
+ readonly version = "0.12.0";
+ readonly mode = "extension" as const;
+ defaultOptions: { sign?: KeplrSignOptions } = {};
+
+ readonly #identity: WalletIdentity;
+ readonly #networks: Network[];
+ readonly #transport: InpageTransport;
+ readonly #events = new Emitter();
+ readonly #keys = new Map();
+ readonly #fire: () => void;
+
+ constructor(identity: WalletIdentity, networks: Network[], transport: InpageTransport, fire: () => void) {
+ this.#identity = identity;
+ this.name = identity.name;
+ this.icon = identity.icon;
+ this.#networks = networks.filter((n) => (COSMOS_FAMILIES as readonly string[]).includes(n.family));
+ this.#transport = transport;
+ this.#fire = fire;
+ transport.onEvent((family, event) => {
+ if (!(COSMOS_FAMILIES as readonly Family[]).includes(family)) return;
+ if (event !== "accountsChanged" && event !== "disconnect") return;
+ for (const chainId of [...this.#keys.keys()]) if (this.#familyOf(chainId) === family) this.#keys.delete(chainId);
+ this.#events.emit("keystorechange");
+ this.#fire();
+ });
+ }
+
+ #familyOf(chainId: string): CosmosFamily {
+ if (typeof chainId !== "string" || !chainId) throw plainError("chain id not set");
+ const net = this.#networks.find((n) => n.id === `cosmos:${chainId}`);
+ if (!net || !cosmosChainOf(chainId)) throw plainError(`There is no chain info for ${chainId}`, RpcErrorCode.UnrecognizedChain);
+ return net.family as CosmosFamily;
+ }
+
+ #request(chainId: string, method: string, params?: unknown): Promise {
+ return this.#transport.request(this.#familyOf(chainId), method, params, `cosmos:${chainId}`);
+ }
+
+ /** Keplr enable(chainIds): one connect approval per family not yet connected. */
+ async enable(chainIds: string | string[]): Promise {
+ const ids = typeof chainIds === "string" ? [chainIds] : chainIds;
+ if (!Array.isArray(ids) || ids.length === 0) throw plainError("chain id not set");
+ const byFamily = new Map();
+ for (const id of ids) {
+ const f = this.#familyOf(id);
+ byFamily.set(f, [...(byFamily.get(f) ?? []), id]);
+ }
+ for (const [, list] of byFamily) {
+ const first = list[0]!;
+ const have = (await this.#request(first, COSMOS_INJECTED.accounts)) as unknown[];
+ if (!Array.isArray(have) || have.length === 0) await this.#request(first, COSMOS_INJECTED.enable, { chainIds: list });
+ }
+ }
+
+ /** Keplr disable(chainIds?): forgets this site's permission for those chains' families (all when omitted). */
+ async disable(chainIds?: string | string[]): Promise {
+ const ids = chainIds === undefined ? undefined : typeof chainIds === "string" ? [chainIds] : chainIds;
+ const families = new Set();
+ if (ids) for (const id of ids) families.add(this.#familyOf(id));
+ else for (const n of this.#networks) families.add(n.family as CosmosFamily);
+ for (const f of families) {
+ const chainId = this.#networks.find((n) => n.family === f)!.id.slice("cosmos:".length);
+ await this.#request(chainId, COSMOS_INJECTED.disable);
+ }
+ this.#keys.clear();
+ }
+
+ async getKey(chainId: string): Promise {
+ const k = (await this.#request(chainId, COSMOS_INJECTED.getKey)) as CosmosKeyWire;
+ const key: CosmosKey = Object.freeze({
+ name: this.#identity.name,
+ algo: k.algo,
+ pubKey: bytesOfHex(k.pubKey),
+ address: bytesOfHex(k.address),
+ bech32Address: k.bech32Address,
+ ethereumHexAddress: k.ethereumHexAddress,
+ isNanoLedger: false,
+ isKeystone: false,
+ });
+ this.#keys.set(chainId, key);
+ return key;
+ }
+
+ async signDirect(chainId: string, signer: string, signDoc: DirectSignDoc, _signOptions?: KeplrSignOptions): Promise<{ signed: DirectSignDoc & { bodyBytes: Uint8Array; authInfoBytes: Uint8Array; chainId: string }; signature: StdSignature }> {
+ if (!signDoc || typeof signDoc !== "object") throw plainError("Expected a sign doc.");
+ const accountNumber = signDoc.accountNumber === null || signDoc.accountNumber === undefined ? "0" : String(signDoc.accountNumber);
+ const wire = {
+ bodyBytes: b64FromBytes(signDoc.bodyBytes ?? new Uint8Array()),
+ authInfoBytes: b64FromBytes(signDoc.authInfoBytes ?? new Uint8Array()),
+ chainId: signDoc.chainId ?? "",
+ accountNumber,
+ };
+ const res = (await this.#request(chainId, COSMOS_INJECTED.signDirect, { signerAddress: signer, signDoc: wire })) as {
+ signed: { bodyBytes: string; authInfoBytes: string; chainId: string; accountNumber: string };
+ signature: StdSignature;
+ };
+ // The account number keeps the caller's own type (Long, bigint…): Clip never changes the doc it signs.
+ return {
+ signed: { ...signDoc, bodyBytes: bytesFromB64(res.signed.bodyBytes), authInfoBytes: bytesFromB64(res.signed.authInfoBytes), chainId: res.signed.chainId },
+ signature: res.signature,
+ };
+ }
+
+ async signAmino(chainId: string, signer: string, signDoc: StdSignDoc, _signOptions?: KeplrSignOptions): Promise<{ signed: StdSignDoc; signature: StdSignature }> {
+ return (await this.#request(chainId, COSMOS_INJECTED.signAmino, { signerAddress: signer, signDoc })) as { signed: StdSignDoc; signature: StdSignature };
+ }
+
+ /** ADR-36 (Keplr signArbitrary): a string is signed as its UTF-8 bytes. */
+ async signArbitrary(chainId: string, signer: string, data: string | Uint8Array): Promise {
+ const bytes = typeof data === "string" ? enc.encode(data) : data;
+ if (!(bytes instanceof Uint8Array)) throw plainError("Expected a string or bytes.");
+ return (await this.#request(chainId, COSMOS_INJECTED.signArbitrary, { signer, data: b64FromBytes(bytes), isString: typeof data === "string" })) as StdSignature;
+ }
+
+ async verifyArbitrary(chainId: string, signer: string, data: string | Uint8Array, signature: StdSignature): Promise {
+ const bytes = typeof data === "string" ? enc.encode(data) : data;
+ return (await this.#request(chainId, COSMOS_INJECTED.verifyArbitrary, { signer, data: b64FromBytes(bytes), signature })) === true;
+ }
+
+ /** Keplr sendTx: broadcasts signed tx bytes; resolves to the tx hash bytes. */
+ async sendTx(chainId: string, tx: Uint8Array, mode: BroadcastMode): Promise {
+ if (!(tx instanceof Uint8Array)) throw plainError("Expected the signed transaction bytes.");
+ const res = (await this.#request(chainId, COSMOS_INJECTED.sendTx, { tx: b64FromBytes(tx), mode })) as { txhash: string };
+ return bytesOfHex(res.txhash);
+ }
+
+ getOfflineSigner(chainId: string, signOptions?: KeplrSignOptions) {
+ return new ClipOfflineSigner(this, chainId, signOptions);
+ }
+
+ getOfflineSignerOnlyAmino(chainId: string, signOptions?: KeplrSignOptions) {
+ return new ClipOfflineSignerOnlyAmino(this, chainId, signOptions);
+ }
+
+ /** Clip has no Ledger accounts here, so this is always the direct + amino signer (Keplr returns it unless isNanoLedger). */
+ async getOfflineSignerAuto(chainId: string, signOptions?: KeplrSignOptions) {
+ await this.getKey(chainId);
+ return new ClipOfflineSigner(this, chainId, signOptions);
+ }
+
+ /** Chains this wallet ships, without endpoints (Keplr ChainInfoWithoutEndpoints). */
+ async getChainInfosWithoutEndpoints(): Promise {
+ return this.#networks.flatMap((n) => {
+ const info = chainInfoWithoutEndpoints(n.id.slice("cosmos:".length));
+ return info ? [info] : [];
+ });
+ }
+
+ async getChainInfoWithoutEndpoints(chainId: string): Promise {
+ this.#familyOf(chainId);
+ return chainInfoWithoutEndpoints(chainId)!;
+ }
+
+ /** Clip only works with the chains it ships: a known chain resolves, anything else is refused in plain words. */
+ async experimentalSuggestChain(chainInfo: { chainId?: unknown }): Promise {
+ const id = chainInfo && typeof chainInfo.chainId === "string" ? chainInfo.chainId : "";
+ if (id && this.#networks.some((n) => n.id === `cosmos:${id}`)) return;
+ throw plainError(`${this.#identity.name} doesn't support ${id || "this chain"} and can't add chains from websites.`, RpcErrorCode.UnsupportedMethod);
+ }
+
+ on(event: "keystorechange", listener: () => void): () => void {
+ this.#events.on(event, listener);
+ return () => this.#events.removeListener(event, listener);
+ }
+
+ off(event: "keystorechange", listener: () => void): void {
+ this.#events.removeListener(event, listener);
+ }
+}
+
+/** cosmjs OfflineAminoSigner over the provider (Keplr CosmJSOfflineSignerOnlyAmino). */
+export class ClipOfflineSignerOnlyAmino {
+ constructor(
+ protected readonly provider: ClipCosmosProvider,
+ readonly chainId: string,
+ protected readonly signOptions?: KeplrSignOptions,
+ ) {}
+
+ async getAccounts(): Promise {
+ const key = await this.provider.getKey(this.chainId);
+ return [{ address: key.bech32Address, algo: "secp256k1", pubkey: key.pubKey }];
+ }
+
+ async signAmino(signerAddress: string, signDoc: StdSignDoc) {
+ if (this.chainId !== signDoc.chain_id) throw new Error("Unmatched chain id with the offline signer");
+ const key = await this.provider.getKey(signDoc.chain_id);
+ if (key.bech32Address !== signerAddress) throw new Error("Unknown signer address");
+ return this.provider.signAmino(this.chainId, signerAddress, signDoc, this.signOptions);
+ }
+
+ /** Legacy cosmjs name. */
+ sign(signerAddress: string, signDoc: StdSignDoc) {
+ return this.signAmino(signerAddress, signDoc);
+ }
+}
+
+/** cosmjs OfflineDirectSigner & OfflineAminoSigner (Keplr CosmJSOfflineSigner). */
+export class ClipOfflineSigner extends ClipOfflineSignerOnlyAmino {
+ async signDirect(signerAddress: string, signDoc: DirectSignDoc & { chainId: string }) {
+ if (this.chainId !== signDoc.chainId) throw new Error("Unmatched chain id with the offline signer");
+ const key = await this.provider.getKey(signDoc.chainId);
+ if (key.bech32Address !== signerAddress) throw new Error("Unknown signer address");
+ return this.provider.signDirect(this.chainId, signerAddress, signDoc, this.signOptions);
+ }
+}
+
+export function installCosmosProvider(
+ win: Window,
+ identity: WalletIdentity,
+ networks: Network[],
+ transport: InpageTransport,
+ opts: InjectedOptions = {},
+): { provider: ClipCosmosProvider; stop(): void } {
+ const globalKey = opts.globalKey ?? DEFAULT_GLOBAL_KEY;
+ const fire = () => {
+ try {
+ const E = (win as unknown as { Event?: typeof Event }).Event ?? Event;
+ win.dispatchEvent(new E(`${globalKey}_keystorechange`));
+ } catch {
+ /* no Event constructor (tests) */
+ }
+ };
+ const provider = new ClipCosmosProvider(identity, networks, transport, fire);
+ const stop = exposeOnGlobal(win, globalKey, "cosmos", provider, identity);
+ return { provider, stop };
+}
diff --git a/packages/1mask/src/inpage/fuel.ts b/packages/1mask/src/inpage/fuel.ts
new file mode 100644
index 00000000..1afb5615
--- /dev/null
+++ b/packages/1mask/src/inpage/fuel.ts
@@ -0,0 +1,326 @@
+import type { WalletIdentity } from "../shared/config.js";
+import { RpcErrorCode, rpcError } from "../shared/errors.js";
+import { FUEL_INJECTED, type FuelConnectorNetwork, type FuelWireMessage } from "../shared/fuel.js";
+import type { ExposedAccount } from "../shared/protocol.js";
+import { Emitter } from "./emitter.js";
+import { DEFAULT_GLOBAL_KEY, exposeOnGlobal, type InjectedOptions } from "./injected-base.js";
+import type { InpageTransport } from "./transport.js";
+
+/**
+ * Fuel connector (the Fuel connector standard: fuels-ts `FuelConnector`, packages/account/src/connectors/
+ * fuel-connector.ts and fuel.ts in github.com/FuelLabs/fuels-ts, v0.103). Dependency-free: fuels-ts is never bundled
+ * into the page; transactions cross to the background as plain JSON.
+ *
+ * Discovery (fuels-ts connectors/utils/dispatch-fuel-connector-event.ts and `Fuel.setupConnectorListener`): a wallet
+ * announces itself with `window.dispatchEvent(new CustomEvent("FuelConnector", { detail: connector }))`; every `Fuel`
+ * instance listening on window adds it (`Fuel.addConnector`), pings it, and selects it if none is selected. Fuel Wallet's
+ * own connector (FuelLabs/fuel-connectors packages/fuel-wallet, FuelWalletConnector.setupConnector) dispatches the same
+ * event once its ping succeeds. Since a `Fuel` created later misses an early event, this connector announces on
+ * install, on DOMContentLoaded and on load, and is also at `window.clipwallet.fuel` so a dapp can pass it in
+ * `new Fuel({ connectors: [window.clipwallet.fuel] })`. It announces the wallet's own name and icon (AGENTS rule 8),
+ * never "Fuel Wallet".
+ *
+ * What `Fuel` and `Account` use from a connector (checked in fuels-ts source): name, metadata, installed/connected
+ * (Fuel writes them, so the object stays extensible), on/off (Fuel.setupConnectorEvents), ping, isConnected, the
+ * FuelConnectorMethods it proxies, and `sendTransaction(address, request, params)` whose string result Account turns
+ * into a TransactionResponse with provider.getTransactionResponse(id). The request is a fuels-ts TransactionRequest
+ * instance: `JSON.stringify` gives the same JSON Fuel Wallet's connector sends (BN → 0x-hex, bytes → 0x-hex).
+ */
+
+/** fuels-ts FuelConnectorEventTypes (connectors/types/connector-types.ts). */
+export const FUEL_CONNECTOR_EVENTS = {
+ connectors: "connectors",
+ currentConnector: "currentConnector",
+ connection: "connection",
+ accounts: "accounts",
+ currentAccount: "currentAccount",
+ networks: "networks",
+ currentNetwork: "currentNetwork",
+ assets: "assets",
+ abis: "abis",
+ consolidateCoins: "consolidateCoins",
+} as const;
+
+/** The window event wallets announce with (fuels-ts `FuelConnectorEventType`). */
+export const FUEL_CONNECTOR_EVENT = "FuelConnector";
+
+type Hashable = string | { personalSign: string | Uint8Array | ArrayLike };
+
+const toHex = (b: ArrayLike) => `0x${Array.from(b, (x) => (x & 0xff).toString(16).padStart(2, "0")).join("")}`;
+
+/** HashableMessage (fuels-ts @fuel-ts/hasher) → the wire form. Bytes can't cross JSON, so they go as hex. */
+export function fuelWireMessage(m: unknown): FuelWireMessage {
+ if (typeof m === "string") {
+ if (!m.trim()) throw rpcError.invalidParams("Message is required.");
+ return { text: m };
+ }
+ const p = m && typeof m === "object" ? (m as { personalSign?: unknown }).personalSign : undefined;
+ if (typeof p === "string") {
+ if (!p) throw rpcError.invalidParams("Message is required.");
+ return { personalSign: p };
+ }
+ if (p instanceof Uint8Array || (Array.isArray(p) && p.every((x) => Number.isInteger(x) && x >= 0 && x < 256))) {
+ if (!(p as ArrayLike).length) throw rpcError.invalidParams("Message is required.");
+ return { personalSignHex: toHex(p as ArrayLike) };
+ }
+ throw rpcError.invalidParams("Expected a message string or { personalSign }.");
+}
+
+/** A TransactionRequest (instance or JSON) → plain JSON, as Fuel Wallet's connector sends it. */
+function plainTransaction(tx: unknown): unknown {
+ if (!tx || (typeof tx !== "object" && typeof tx !== "string")) throw rpcError.invalidParams("Transaction is required.");
+ if (typeof tx === "string") return tx;
+ return JSON.parse(JSON.stringify(tx));
+}
+
+interface SendTxParams {
+ onBeforeSend?: (req: unknown) => Promise;
+ provider?: { url?: string };
+}
+
+export interface FuelConnectorOptions extends InjectedOptions {
+ /** metadata.install.link: where people get this wallet (shown by pickers only when it isn't installed). */
+ installLink?: string;
+ /** Announce on DOMContentLoaded and load too (default true). */
+ reannounce?: boolean;
+}
+
+export class ClipFuelConnector {
+ readonly isClipWallet = true;
+ name: string;
+ metadata: { image: string; install: { action: string; link: string; description: string } };
+ connected = false;
+ installed = true;
+ external = true;
+ events = FUEL_CONNECTOR_EVENTS;
+ readonly #events = new Emitter();
+ readonly #transport: InpageTransport;
+ #accounts: string[] = [];
+
+ constructor(identity: WalletIdentity, transport: InpageTransport, opts: FuelConnectorOptions = {}) {
+ this.name = identity.name;
+ this.metadata = {
+ image: identity.icon,
+ install: { action: "Install", link: opts.installLink ?? "", description: `Install ${identity.name} to connect it.` },
+ };
+ this.#transport = transport;
+ transport.onEvent((family, event, data) => {
+ if (family !== "fuel") return;
+ if (event === "accountsChanged") this.#setAccounts(Array.isArray(data) ? (data as ExposedAccount[]) : []);
+ else if (event === "disconnect") this.#setAccounts([]);
+ else if (event === "chainChanged" && data && typeof data === "object") this.#emit(FUEL_CONNECTOR_EVENTS.currentNetwork, data);
+ });
+ }
+
+ #request(method: string, params?: unknown): Promise {
+ return this.#transport.request("fuel", method, params);
+ }
+
+ #emit(event: string, ...args: unknown[]) {
+ try {
+ this.#events.emit(event, ...args);
+ } catch {
+ /* a dapp listener throwing must not break the connector */
+ }
+ }
+
+ #setAccounts(list: ExposedAccount[]) {
+ const next = list.filter((a) => a && typeof a.address === "string").map((a) => a.address);
+ const changed = next.length !== this.#accounts.length || next.some((a, i) => a !== this.#accounts[i]);
+ const wasConnected = this.connected;
+ this.#accounts = next;
+ this.connected = next.length > 0;
+ if (changed) {
+ this.#emit(FUEL_CONNECTOR_EVENTS.accounts, [...next]);
+ this.#emit(FUEL_CONNECTOR_EVENTS.currentAccount, next[0] ?? null);
+ }
+ if (wasConnected !== this.connected) this.#emit(FUEL_CONNECTOR_EVENTS.connection, this.connected);
+ }
+
+ async #silentAccounts(): Promise {
+ const list = (await this.#request(FUEL_INJECTED.accounts)) as ExposedAccount[];
+ this.#setAccounts(Array.isArray(list) ? list : []);
+ return [...this.#accounts];
+ }
+
+ /* ---------------------------------------------------------------- EventEmitter surface (Fuel uses on/off) */
+
+ on(event: string, listener: (...args: any[]) => void): this {
+ this.#events.on(event, listener);
+ return this;
+ }
+ addListener(event: string, listener: (...args: any[]) => void): this {
+ return this.on(event, listener);
+ }
+ once(event: string, listener: (...args: any[]) => void): this {
+ this.#events.once(event, listener);
+ return this;
+ }
+ off(event: string, listener: (...args: any[]) => void): this {
+ this.#events.removeListener(event, listener);
+ return this;
+ }
+ removeListener(event: string, listener: (...args: any[]) => void): this {
+ return this.off(event, listener);
+ }
+ removeAllListeners(event?: string): this {
+ this.#events.removeAllListeners(event);
+ return this;
+ }
+ listenerCount(event: string): number {
+ return this.#events.listenerCount(event);
+ }
+ emit(event: string, ...args: unknown[]): boolean {
+ this.#emit(event, ...args);
+ return true;
+ }
+ setMaxListeners(_n: number): this {
+ return this;
+ }
+
+ /* ---------------------------------------------------------------- FuelConnector methods */
+
+ /** Answered in the page: the connector is here, so it's alive. */
+ async ping(): Promise {
+ return true;
+ }
+
+ async version(): Promise<{ app: string; network: string }> {
+ return { app: "1.0.0", network: ">=0.48.0" };
+ }
+
+ async isConnected(): Promise {
+ try {
+ return (await this.#silentAccounts()).length > 0;
+ } catch {
+ return false;
+ }
+ }
+
+ async accounts(): Promise {
+ return this.#silentAccounts();
+ }
+
+ async currentAccount(): Promise {
+ return (await this.#silentAccounts())[0] ?? null;
+ }
+
+ /** True once the person approves; false when they decline (FuelConnector.connect contract). */
+ async connect(): Promise {
+ try {
+ const list = (await this.#request(FUEL_INJECTED.connect, {})) as ExposedAccount[];
+ this.#setAccounts(Array.isArray(list) ? list : []);
+ return this.connected;
+ } catch (e) {
+ if ((e as { code?: number })?.code === RpcErrorCode.UserRejected) return false;
+ throw e;
+ }
+ }
+
+ /** Returns the connection status after disconnecting (false). */
+ async disconnect(): Promise {
+ await this.#request(FUEL_INJECTED.disconnect);
+ this.#setAccounts([]);
+ return false;
+ }
+
+ async signMessage(address: string, message: Hashable): Promise {
+ return (await this.#request(FUEL_INJECTED.signMessage, { address, message: fuelWireMessage(message) })) as string;
+ }
+
+ async #tx(method: string, address: string, transaction: unknown, params?: SendTxParams): Promise {
+ let tx = transaction;
+ if (params?.onBeforeSend) tx = await params.onBeforeSend(tx);
+ const url = params?.provider?.url;
+ return this.#request(method, { address, transaction: plainTransaction(tx), ...(typeof url === "string" ? { provider: { url } } : {}) });
+ }
+
+ /** Signs and submits; resolves with the transaction id (Account turns it into a TransactionResponse). */
+ async sendTransaction(address: string, transaction: unknown, params?: SendTxParams): Promise {
+ return (await this.#tx(FUEL_INJECTED.sendTransaction, address, transaction, params)) as string;
+ }
+
+ /** Signs only; resolves with the signed TransactionRequest as JSON (`transactionRequestify` reads it), like Fuel Wallet. */
+ async signTransaction(address: string, transaction: unknown, params?: SendTxParams): Promise {
+ return this.#tx(FUEL_INJECTED.signTransaction, address, transaction, params);
+ }
+
+ async currentNetwork(): Promise {
+ return (await this.#request(FUEL_INJECTED.currentNetwork)) as FuelConnectorNetwork;
+ }
+
+ async networks(): Promise {
+ return (await this.#request(FUEL_INJECTED.networks)) as FuelConnectorNetwork[];
+ }
+
+ async selectNetwork(network: { chainId?: number; url?: string }): Promise {
+ return (await this.#request(FUEL_INJECTED.selectNetwork, { chainId: network?.chainId, url: network?.url })) as boolean;
+ }
+
+ /** Only the networks the wallet ships with. */
+ async addNetwork(_networkUrl: string): Promise {
+ throw rpcError.userRejected(`${this.name} only connects to the networks it ships with.`);
+ }
+
+ /** The wallet's token list is curated; a site can't add to it. */
+ async assets(): Promise {
+ return [];
+ }
+ async addAsset(_asset: unknown): Promise {
+ return false;
+ }
+ async addAssets(_assets: unknown[]): Promise {
+ return false;
+ }
+
+ /** ABIs aren't used: transactions are explained from their coin flows and a dry run. */
+ async addABI(_contractId: string, _abi: unknown): Promise {
+ return false;
+ }
+ async getABI(_contractId: string): Promise {
+ return null;
+ }
+ async hasABI(_contractId: string): Promise {
+ return false;
+ }
+
+ async startConsolidation(_opts: unknown): Promise {
+ throw rpcError.unsupportedMethod("startConsolidation");
+ }
+}
+
+/** Installs the connector at window[globalKey].fuel and announces it with the FuelConnector event. */
+export function installFuelConnector(
+ win: Window,
+ identity: WalletIdentity,
+ transport: InpageTransport,
+ opts: FuelConnectorOptions = {},
+): { connector: ClipFuelConnector; announce(): void; stop(): void } {
+ const connector = new ClipFuelConnector(identity, transport, opts);
+ const unexpose = exposeOnGlobal(win, opts.globalKey ?? DEFAULT_GLOBAL_KEY, "fuel", connector, identity);
+ const announce = () => {
+ try {
+ win.dispatchEvent(new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(FUEL_CONNECTOR_EVENT, { detail: connector }));
+ } catch {
+ /* no CustomEvent: the global is still there */
+ }
+ };
+ announce();
+ const again = () => announce();
+ const reannounce = opts.reannounce !== false;
+ if (reannounce) {
+ win.addEventListener("DOMContentLoaded", again, { once: true });
+ win.addEventListener("load", again, { once: true });
+ }
+ return {
+ connector,
+ announce,
+ stop: () => {
+ unexpose();
+ if (reannounce) {
+ win.removeEventListener("DOMContentLoaded", again);
+ win.removeEventListener("load", again);
+ }
+ },
+ };
+}
diff --git a/packages/1mask/src/inpage/index.ts b/packages/1mask/src/inpage/index.ts
index 49548acf..cfee508d 100644
--- a/packages/1mask/src/inpage/index.ts
+++ b/packages/1mask/src/inpage/index.ts
@@ -13,6 +13,7 @@ import { resolveChannel, resolveIdentity, type InpageConfig, type WalletIdentity
import { ClipBitcoinWallet } from "./bitcoin.js";
import { ClipEthereumProvider, announceEip6963, claimWindowEthereum, type EIP6963ProviderDetail } from "./evm.js";
import { installP2Providers, type InstalledP2 } from "./p2.js";
+import { installN87Providers, type InstalledN87 } from "./n87.js";
import { ClipSolanaWallet } from "./solana.js";
import { ClipSuiWallet } from "./sui.js";
import { ClipAptosWallet } from "./aptos.js";
@@ -37,6 +38,8 @@ export interface InstalledOneMask {
ton?: ClipTonConnectBridge;
/** NEAR (window.clipwallet.near + NEAR Connect), Stellar (SEP-43), Algorand, Tezos (Beacon relay). */
p2?: InstalledP2;
+ /** networks87: TRON (TIP-6963), Stacks (WBIP-004), Fuel (FuelConnector). */
+ n87?: InstalledN87;
/** Hedera extension discovery (DAppConnector / HashConnect): the id dApps address the wallet by. */
hedera?: { extensionId: string };
destroy(): void;
@@ -114,6 +117,12 @@ export function installOneMask(config: InpageConfig, win: Window = window): Inst
});
stops.push(p2.stop);
out.p2 = p2;
+ const n87 = installN87Providers(win, identity, config.networks, transport, {
+ want: { cosmos: want.cosmos ?? true, tron: want.tron ?? true, stacks: want.stacks ?? true, fuel: want.fuel ?? true, xrpl: want.xrpl ?? true, multiversx: want.multiversx ?? true },
+ ...(config.globalKey ? { globalKey: config.globalKey } : {}),
+ });
+ stops.push(n87.stop);
+ out.n87 = n87;
return out;
}
@@ -127,6 +136,7 @@ export { ClipAptosWallet, APTOS_FEATURES, METHOD_APTOS_NETWORK, aptosChain, toWi
export * from "./bitcoin-features.js";
export { createInpageTransport, type InpageTransport } from "./transport.js";
export * from "./p2.js";
+export * from "./n87.js";
export { ClipCardanoWallet, Cip30Error, CIP30_METHODS, APIErrorCode, TxSignErrorCode, DataSignErrorCode, TxSendErrorCode, cardanoWalletKey, installCardano, toCip30Error } from "./cardano.js";
export { ClipSubstrateProvider, SUBSTRATE_INPAGE_METHODS, installSubstrate, substrateExtensionName, caip2FromGenesis } from "./substrate.js";
export { ClipStarknetWallet, StarknetWalletError, STARKNET_ERRORS, injectStarknet, starknetWalletId, toStarknetError } from "./starknet.js";
diff --git a/packages/1mask/src/inpage/multiversx.ts b/packages/1mask/src/inpage/multiversx.ts
new file mode 100644
index 00000000..448f3808
--- /dev/null
+++ b/packages/1mask/src/inpage/multiversx.ts
@@ -0,0 +1,241 @@
+import type { Network } from "@clip-wallet/core";
+import { hexToBytes } from "../shared/bytes.js";
+import type { WalletIdentity } from "../shared/config.js";
+import { ProviderRpcError, RpcErrorCode, rpcError } from "../shared/errors.js";
+import { MULTIVERSX_INJECTED, multiversxNetworkIdOf } from "../shared/multiversx-methods.js";
+import type { ExposedAccount } from "../shared/protocol.js";
+import { DEFAULT_GLOBAL_KEY, type InjectedOptions, exposeOnGlobal } from "./injected-base.js";
+import type { InpageTransport } from "./transport.js";
+
+/**
+ * MultiversX provider: BEST EFFORT, through an UNDOCUMENTED hook.
+ *
+ * MultiversX has no wallet-discovery standard for browser extensions. @multiversx/sdk-dapp 5.x's `initApp` merges
+ * `window.multiversx?.providers` with the dapp's own `customProviders` (deduplicated by `type`) into
+ * `ProviderFactory.customProviders` (out/methods/initApp/initApp.mjs, 5.7.3), and `ProviderFactory.create({ type })`
+ * calls the matching entry's `constructor({ address, anchor })`, then `provider.init()`
+ * (out/providers/ProviderFactory.mjs). The entry shape is `ICustomProvider` { name, type, iconUrl?, constructor }
+ * (out/providers/types/providerFactory.types.d.ts). sdk-dapp's README presents `window.multiversx.providers` as
+ * something the DAPP sets; nothing documents it as a channel for wallets. So this works only on sdk-dapp dapps
+ * that call initApp after the page script ran and don't replace `window.multiversx` (MultiversX's template dapp does
+ * replace it), and only for as long as sdk-dapp keeps reading it.
+ *
+ * Identity: the entry is listed under the wallet's own name, icon and type (the global key, "clipwallet"), never as
+ * "extension" or the MultiversX DeFi Wallet. Nothing here answers the DeFi Wallet's `erdw-*` postMessage protocol
+ * or sets `window.elrondWallet` / `window.multiversxWallet`.
+ *
+ * The provider implements sdk-dapp's `IProvider` (init, login, logout, getType, getAddress, plus sdk-dapp-utils 3.1
+ * IDAppProviderBase: getAccount, setAccount, isInitialized, isConnected, signTransaction(s), signMessage).
+ * - Transactions arrive as the dapp's sdk-core `Transaction` objects. They're read duck-typed (`toPlainObject()`),
+ * sent as plain JSON, and the same objects come back with `signature` set (guardian fields untouched).
+ * mx-sdk-js-extension-provider returns `Transaction.newFromPlainObject(...)` copies instead; sdk-dapp only reads
+ * the returned objects (signTransactionsWithProvider), so mutating its own instances is equivalent and needs no
+ * sdk-core in the page.
+ * - `login({ token })` connects, then signs `address + token` as a MultiversX message (sdk-core MessageComputer).
+ * That is what sdk-native-auth-server checks: `signedMessage = \`${decoded.address}${decoded.body}\`` verified over
+ * `MessageComputer.computeBytesForSigning(new Message({ address, data }))`
+ * (mx-sdk-js-native-auth-server src/native.auth.server.ts `validate` / `verifySignature`), with `body` the login
+ * token sdk-dapp passed in (sdk-native-auth-client `getToken(address, token, signature)`). The background refuses a
+ * token whose origin isn't this site.
+ */
+
+export const MULTIVERSX_DEFAULT_TYPE = DEFAULT_GLOBAL_KEY;
+
+/** What sdk-dapp's ProviderFactory reads from `window.multiversx.providers` (ICustomProvider). */
+export interface MultiversXProviderEntry {
+ readonly name: string;
+ readonly type: string;
+ readonly iconUrl?: string;
+ constructor: (options?: { address?: string; anchor?: unknown }) => Promise;
+}
+
+/** The parts of an sdk-core Transaction this provider uses. */
+interface TransactionLike {
+ toPlainObject(): Record;
+ signature?: Uint8Array;
+}
+
+/** The parts of an sdk-core Message this provider uses. */
+interface MessageLike {
+ data: Uint8Array;
+ signature?: Uint8Array;
+ address?: unknown;
+ signer?: string;
+}
+
+const bytesOf = (h: unknown): Uint8Array => {
+ if (typeof h !== "string" || !/^[0-9a-f]{128}$/i.test(h)) throw rpcError.internal("The wallet returned no signature.");
+ return hexToBytes(h);
+};
+
+export class ClipMultiversXProvider {
+ readonly isClipWallet = true;
+ readonly #transport: InpageTransport;
+ readonly #type: string;
+ readonly #name: string;
+ readonly #defaultNetwork: string;
+ #account: { address: string } = { address: "" };
+ #initialized = false;
+
+ constructor(networks: Network[], transport: InpageTransport, type: string, name: string) {
+ this.#transport = transport;
+ this.#type = type;
+ this.#name = name;
+ this.#defaultNetwork = networks.find((n) => n.family === "multiversx")?.id ?? "mvx:D";
+ transport.onEvent((family, event, data) => {
+ if (family !== "multiversx") return;
+ if (event === "disconnect") this.#account = { address: "" };
+ if (event === "accountsChanged") {
+ const list = Array.isArray(data) ? (data as ExposedAccount[]) : [];
+ if (!list.some((a) => a?.address === this.#account.address)) this.#account = { address: list[0]?.address ?? "" };
+ }
+ });
+ }
+
+ #call(method: string, params: unknown, chain = this.#defaultNetwork): Promise {
+ return this.#transport.request("multiversx", method, params, chain);
+ }
+
+ async init(): Promise {
+ this.#initialized = true;
+ return true;
+ }
+
+ isInitialized(): boolean {
+ return this.#initialized;
+ }
+
+ isConnected(): boolean {
+ return !!this.#account.address;
+ }
+
+ getType(): string {
+ return this.#type;
+ }
+
+ async getAddress(): Promise {
+ return this.#account.address || undefined;
+ }
+
+ getAccount(): { address: string } | null {
+ return this.#account.address ? { ...this.#account } : null;
+ }
+
+ setAccount(account: { address?: string } | null | undefined): void {
+ this.#account = { address: typeof account?.address === "string" ? account.address : "" };
+ }
+
+ /** Connects (approval on first use per site); with a native-auth `token`, also signs `address + token`. */
+ async login(options: { token?: string; callbackUrl?: string } = {}): Promise<{ address: string; signature: string }> {
+ let list = (await this.#call(MULTIVERSX_INJECTED.accounts, undefined)) as ExposedAccount[];
+ if (!Array.isArray(list) || !list.length) list = (await this.#call(MULTIVERSX_INJECTED.connect, {})) as ExposedAccount[];
+ const address = Array.isArray(list) ? list[0]?.address : undefined;
+ if (!address) throw new ProviderRpcError(RpcErrorCode.Unauthorized, "No MultiversX account is available.");
+ this.#account = { address };
+ if (!options.token) return { address, signature: "" };
+ const r = (await this.#call(MULTIVERSX_INJECTED.signMessage, { message: `${address}${options.token}`, address })) as { signature?: unknown };
+ bytesOf(r?.signature);
+ return { address, signature: String(r.signature) };
+ }
+
+ async logout(): Promise {
+ try {
+ await this.#call(MULTIVERSX_INJECTED.disconnect, undefined);
+ } finally {
+ this.#account = { address: "" };
+ }
+ return true;
+ }
+
+ cancelLogin(): void {
+ /* the approval window has its own cancel */
+ }
+
+ #ensureConnected(): string {
+ if (!this.#account.address) throw rpcError.unauthorized("Connect first.");
+ return this.#account.address;
+ }
+
+ async signTransaction(transaction: T): Promise {
+ const [signed] = await this.signTransactions([transaction]);
+ if (!signed) throw rpcError.internal("The wallet returned no signature.");
+ return signed;
+ }
+
+ async signTransactions(transactions: T[]): Promise {
+ const address = this.#ensureConnected();
+ if (!Array.isArray(transactions) || !transactions.length) throw rpcError.invalidParams("Expected a list of transactions.");
+ const plain = transactions.map((t) => {
+ if (!t || typeof t.toPlainObject !== "function") throw rpcError.invalidParams("Expected MultiversX Transaction objects.");
+ // JSON round trip: only plain data crosses to the background.
+ return JSON.parse(JSON.stringify(t.toPlainObject())) as Record;
+ });
+ const chains = new Set(plain.map((p) => multiversxNetworkIdOf(p.chainID)));
+ if (chains.size !== 1 || chains.has(null)) throw rpcError.invalidParams("All transactions must name the same MultiversX chain.");
+ const r = (await this.#call(MULTIVERSX_INJECTED.signTransactions, { transactions: plain, address }, [...chains][0]!)) as { signatures?: { signature?: unknown }[] };
+ const sigs = Array.isArray(r?.signatures) ? r.signatures : [];
+ if (sigs.length !== transactions.length) throw rpcError.internal("The wallet returned the wrong number of signatures.");
+ transactions.forEach((t, i) => {
+ t.signature = bytesOf(sigs[i]?.signature);
+ });
+ return transactions;
+ }
+
+ async signMessage(message: M): Promise {
+ const address = this.#ensureConnected();
+ if (!message || !(message.data instanceof Uint8Array)) throw rpcError.invalidParams("Expected a MultiversX Message.");
+ let text: string;
+ try {
+ text = new TextDecoder("utf-8", { fatal: true }).decode(message.data);
+ } catch {
+ throw rpcError.invalidParams("Clip Wallet signs text messages only.");
+ }
+ const r = (await this.#call(MULTIVERSX_INJECTED.signMessage, { message: text, address })) as { signature?: unknown };
+ message.signature = bytesOf(r?.signature);
+ message.signer = this.#name;
+ return message;
+ }
+}
+
+/**
+ * Adds the wallet's entry to `window.multiversx.providers` (creating the object/array only if absent; other wallets'
+ * entries and the array itself are kept) and exposes the provider at `window[globalKey].multiversx`.
+ */
+export function installMultiversXProvider(
+ win: Window,
+ identity: WalletIdentity,
+ networks: Network[],
+ transport: InpageTransport,
+ opts: InjectedOptions = {},
+): { provider: ClipMultiversXProvider; entry: MultiversXProviderEntry; stop(): void } {
+ const type = opts.globalKey ?? DEFAULT_GLOBAL_KEY;
+ const provider = new ClipMultiversXProvider(networks, transport, type, identity.name);
+ const entry: MultiversXProviderEntry = Object.freeze({
+ name: identity.name,
+ type,
+ iconUrl: identity.icon,
+ // sdk-dapp passes the address it stored at login (session restore); the background still checks every signer.
+ constructor: async (options?: { address?: string }) => {
+ if (options?.address && !provider.isConnected()) provider.setAccount({ address: options.address });
+ return provider;
+ },
+ });
+ const w = win as unknown as { multiversx?: { providers?: unknown } };
+ if (!w.multiversx || typeof w.multiversx !== "object") w.multiversx = {};
+ if (!Array.isArray(w.multiversx.providers)) w.multiversx.providers = [];
+ const list = w.multiversx.providers as unknown[];
+ if (!list.some((e) => (e as { type?: unknown })?.type === type)) list.push(entry);
+ const unexpose = exposeOnGlobal(win, type, "multiversx", provider, identity);
+ return {
+ provider,
+ entry,
+ stop: () => {
+ unexpose();
+ const now = (win as unknown as { multiversx?: { providers?: unknown } }).multiversx?.providers;
+ if (Array.isArray(now)) {
+ const i = now.indexOf(entry);
+ if (i >= 0) now.splice(i, 1);
+ }
+ },
+ };
+}
diff --git a/packages/1mask/src/inpage/n87.ts b/packages/1mask/src/inpage/n87.ts
new file mode 100644
index 00000000..3229158c
--- /dev/null
+++ b/packages/1mask/src/inpage/n87.ts
@@ -0,0 +1,88 @@
+/**
+ * networks87 injected sides, each under the wallet's own identity on window[globalKey]. plus the
+ * ecosystem's own discovery: Cosmos SDK chains (a Keplr-compatible API at window[globalKey].cosmos, never window.keplr,
+ * for Osmosis, dYdX, ZIGChain, Provenance, THORChain and Initia), TRON (TIP-1193 provider announced with TIP-6963), Stacks (SIP-030 provider registered on
+ * window.wbip_providers, WBIP-004), Fuel (a FuelConnector announced with the FuelConnector event)
+ * the XRP Ledger (XLS-72d: Wallet Standard with xrpl:signTransaction / xrpl:signAndSubmitTransaction) and, best
+ * effort, MultiversX (an entry on sdk-dapp's UNDOCUMENTED window.multiversx.providers custom-provider hook).
+ * Families without a verifiable third-party wallet standard (ICP, Antelope; Bitcoin Cash is reached over WalletConnect wc2-bch-bcr) are send and
+ * receive only (see docs/r1/networks87.md).
+ */
+import type { Network } from "@clip-wallet/core";
+import type { WalletIdentity } from "../shared/config.js";
+import type { InjectedOptions } from "./injected-base.js";
+import { installCosmosProvider, type ClipCosmosProvider } from "./cosmos.js";
+import { installFuelConnector, type ClipFuelConnector } from "./fuel.js";
+import { installStacksProvider, type ClipStacksProvider } from "./stacks.js";
+import { installTronProvider, type ClipTronProvider } from "./tron.js";
+import { installXrpl, type ClipXrplWallet } from "./xrpl.js";
+import { installMultiversXProvider, type ClipMultiversXProvider } from "./multiversx.js";
+import type { InpageTransport } from "./transport.js";
+
+export interface InstalledN87 {
+ cosmos?: ClipCosmosProvider;
+ tron?: ClipTronProvider;
+ stacks?: ClipStacksProvider;
+ fuel?: ClipFuelConnector;
+ xrpl?: ClipXrplWallet;
+ multiversx?: ClipMultiversXProvider;
+ stop(): void;
+}
+
+export type N87ProviderFamily = "cosmos" | "tron" | "stacks" | "fuel" | "xrpl" | "multiversx";
+
+/** Installs the providers for every networks87 family present in `networks`. */
+export function installN87Providers(
+ win: Window,
+ identity: WalletIdentity,
+ networks: Network[],
+ transport: InpageTransport,
+ opts: InjectedOptions & { want?: Partial> } = {},
+): InstalledN87 {
+ const want: Record = { cosmos: true, tron: true, stacks: true, fuel: true, xrpl: true, multiversx: true, ...opts.want };
+ const has = (f: string) => networks.some((n) => n.family === f);
+ const g = opts.globalKey ? { globalKey: opts.globalKey } : {};
+ const stops: (() => void)[] = [];
+ const out: InstalledN87 = { stop: () => stops.forEach((s) => s()) };
+ if (want.cosmos && ["cosmos", "provenance", "thorchain", "initia"].some(has)) {
+ const r = installCosmosProvider(win, identity, networks, transport, g);
+ out.cosmos = r.provider;
+ stops.push(r.stop);
+ }
+ if (want.tron && has("tron")) {
+ const r = installTronProvider(win, identity, networks, transport, g);
+ out.tron = r.provider;
+ stops.push(r.stop);
+ }
+ if (want.stacks && has("stacks")) {
+ const r = installStacksProvider(win, identity, networks, transport, g);
+ out.stacks = r.provider;
+ stops.push(r.stop);
+ }
+ if (want.fuel && has("fuel")) {
+ const r = installFuelConnector(win, identity, transport, g);
+ out.fuel = r.connector;
+ stops.push(r.stop);
+ }
+ if (want.xrpl && has("xrpl")) {
+ const r = installXrpl(win, identity, networks, transport, g);
+ if (r) {
+ out.xrpl = r.wallet;
+ stops.push(r.stop);
+ }
+ }
+ if (want.multiversx && has("multiversx")) {
+ // Best effort: sdk-dapp's window.multiversx.providers hook is undocumented and only works on dapps that don't reset it.
+ const r = installMultiversXProvider(win, identity, networks, transport, g);
+ out.multiversx = r.provider;
+ stops.push(r.stop);
+ }
+ return out;
+}
+
+export * from "./cosmos.js";
+export * from "./tron.js";
+export * from "./stacks.js";
+export * from "./fuel.js";
+export * from "./xrpl.js";
+export * from "./multiversx.js";
diff --git a/packages/1mask/src/inpage/stacks.ts b/packages/1mask/src/inpage/stacks.ts
new file mode 100644
index 00000000..14687757
--- /dev/null
+++ b/packages/1mask/src/inpage/stacks.ts
@@ -0,0 +1,170 @@
+import type { Network } from "@clip-wallet/core";
+import type { WalletIdentity } from "../shared/config.js";
+import { RpcErrorCode, toRpcErrorShape } from "../shared/errors.js";
+import type { ExposedAccount } from "../shared/protocol.js";
+import { STACKS_INJECTED, STACKS_SIGNING_METHODS, stacksChainHint } from "../shared/stacks.js";
+import { Emitter } from "./emitter.js";
+import { DEFAULT_GLOBAL_KEY, exposeOnGlobal, type InjectedOptions } from "./injected-base.js";
+import type { InpageTransport } from "./transport.js";
+
+/**
+ * Injected Stacks provider at `window.clipwallet.stacks` (or `window[globalKey].stacks`), implementing SIP-030
+ * (https://github.com/stacksgov/sips/blob/main/sips/sip-030/sip-030-wallet-interface.md): `request(method, params)`
+ * answers with a JSON-RPC 2.0 response `{ jsonrpc, id, result }` and rejects with `{ jsonrpc, id, error }` using the
+ * SIP-030 codes (-32000 user rejection, -32002 access denied, -32601 method not found, -32602 invalid params).
+ *
+ * Discovery is WBIP-004 (https://wbips.netlify.app/wbips/WBIP004): the wallet PUSHES `{ id, name, icon, methods }`
+ * onto `window.wbip_providers`, where `id` is the provider's path on `window`. @stacks/connect v8 reads that array
+ * for its wallet list (connect-ui `getRegisteredProviders`) and resolves the chosen id with
+ * `id.split(".").reduce((o, k) => o?.[k], window)` (`getProviderFromId`), so the id "clipwallet.stacks" finds this
+ * object. https://github.com/stx-labs/connect/blob/main/packages/connect-ui/src/providers.ts
+ *
+ * SIP-030 discourages a shared global ("Using a shared global object between multiple wallets is discouraged"), so
+ * this never sets `window.StacksProvider`, `window.LeatherProvider` or `window.XverseProviders`. Holds no secrets.
+ */
+
+export type StacksJsonRpcResponse = { jsonrpc: "2.0"; id: string | number | null } & ({ result: unknown } | { error: { code: number; message: string; data?: unknown } });
+
+/** SIP-030 error codes. */
+export const SIP030_ERRORS = { userRejection: -32000, addressMismatch: -32001, accessDenied: -32002, methodNotFound: -32601, invalidParams: -32602, internal: -32603 } as const;
+
+export function toSip030Error(err: unknown): { code: number; message: string } {
+ const e = toRpcErrorShape(err);
+ const code =
+ e.code === RpcErrorCode.UserRejected
+ ? SIP030_ERRORS.userRejection
+ : e.code === RpcErrorCode.Unauthorized
+ ? SIP030_ERRORS.accessDenied
+ : e.code === RpcErrorCode.UnsupportedMethod || e.code === RpcErrorCode.MethodNotFound
+ ? SIP030_ERRORS.methodNotFound
+ : e.code === RpcErrorCode.InvalidParams || e.code === RpcErrorCode.ChainDisconnected || e.code === RpcErrorCode.UnrecognizedChain
+ ? SIP030_ERRORS.invalidParams
+ : SIP030_ERRORS.internal;
+ return { code, message: e.message };
+}
+
+/** WBIP-004 provider entry. */
+export interface WbipProvider {
+ id: string;
+ name: string;
+ icon: string;
+ webUrl?: string;
+ methods?: string[];
+}
+
+const ADDRESS_METHODS = new Set(["getAddresses", "stx_getAddresses", "stx_requestAccounts"]);
+export const STACKS_PROVIDER_METHODS = ["getAddresses", "stx_getAddresses", "stx_getNetworks", ...STACKS_SIGNING_METHODS];
+
+export class ClipStacksProvider {
+ readonly isClipWallet = true;
+ readonly name: string;
+ readonly icon: string;
+ readonly #events = new Emitter();
+ readonly #transport: InpageTransport;
+ #accounts: ExposedAccount[] = [];
+ #nextId = 1;
+
+ constructor(identity: WalletIdentity, _networks: Network[], transport: InpageTransport) {
+ this.name = identity.name;
+ this.icon = identity.icon;
+ this.#transport = transport;
+ transport.onEvent((f, event, data) => {
+ if (f !== "stacks") return;
+ if (event === "accountsChanged") this.#setAccounts(Array.isArray(data) ? (data as ExposedAccount[]) : []);
+ if (event === "disconnect") {
+ this.#setAccounts([]);
+ this.#events.emit("disconnect");
+ }
+ });
+ }
+
+ #setAccounts(next: ExposedAccount[]): void {
+ const clean = next.filter((a) => a && typeof a.address === "string");
+ const changed = clean.length !== this.#accounts.length || clean.some((a, i) => a.address !== this.#accounts[i]?.address);
+ this.#accounts = clean;
+ if (changed) this.#events.emit("accountsChanged", this.#accounts.map((a) => ({ address: a.address, publicKey: a.publicKey ?? "" })));
+ }
+
+ #send(method: string, params?: unknown, chain?: string): Promise {
+ return this.#transport.request("stacks", method, params, chain);
+ }
+
+ async #call(method: string, params: unknown): Promise {
+ const p = params && typeof params === "object" && !Array.isArray(params) ? (params as Record) : {};
+ const chain = stacksChainHint(p.network);
+ if (p.network !== undefined && !chain) {
+ throw Object.assign(new Error(`Clip Wallet doesn't support the Stacks network “${String(p.network)}”.`), { code: RpcErrorCode.InvalidParams });
+ }
+ if (ADDRESS_METHODS.has(method)) {
+ const list = (await this.#send(STACKS_INJECTED.connect, p, chain)) as ExposedAccount[];
+ this.#setAccounts(Array.isArray(list) ? list : []);
+ return { addresses: this.#accounts.map((a) => ({ symbol: "STX", address: a.address, publicKey: a.publicKey ?? "" })) };
+ }
+ if (method === STACKS_INJECTED.getNetworks) return this.#send(method, undefined, chain);
+ if (STACKS_SIGNING_METHODS.includes(method)) return this.#send(method, params ?? {}, chain);
+ throw Object.assign(new Error(`Clip Wallet does not support ${method}.`), { code: RpcErrorCode.MethodNotFound });
+ }
+
+ /** SIP-030 request. Resolves `{ jsonrpc, id, result }`; rejects with `{ jsonrpc, id, error }`. */
+ async request(method: string, params?: unknown): Promise {
+ const id = this.#nextId++;
+ if (typeof method !== "string") throw { jsonrpc: "2.0", id, error: { code: SIP030_ERRORS.invalidParams, message: "Expected a method name." } };
+ try {
+ return { jsonrpc: "2.0", id, result: await this.#call(method, params) };
+ } catch (err) {
+ throw { jsonrpc: "2.0", id, error: toSip030Error(err) };
+ }
+ }
+
+ /** SIP-030 listeners: "stx_accountChange" (accounts), also "accountsChanged" and "disconnect". Returns unlisten. */
+ listen(event: string, listener: (...args: any[]) => void): () => void {
+ const name = event === "stx_accountChange" ? "accountsChanged" : event;
+ this.#events.on(name, listener);
+ return () => this.#events.removeListener(name, listener);
+ }
+
+ /** Accounts this site sees right now (silent, no prompt). */
+ async accounts(): Promise<{ address: string; publicKey: string }[]> {
+ const list = (await this.#send(STACKS_INJECTED.accounts)) as ExposedAccount[];
+ this.#setAccounts(Array.isArray(list) ? list : []);
+ return this.#accounts.map((a) => ({ address: a.address, publicKey: a.publicKey ?? "" }));
+ }
+
+ async disconnect(): Promise {
+ await this.#send(STACKS_INJECTED.disconnect);
+ this.#setAccounts([]);
+ }
+}
+
+export function installStacksProvider(
+ win: Window,
+ identity: WalletIdentity,
+ networks: Network[],
+ transport: InpageTransport,
+ opts: InjectedOptions & { webUrl?: string } = {},
+): { provider: ClipStacksProvider; entry: WbipProvider; stop(): void } {
+ const provider = new ClipStacksProvider(identity, networks, transport);
+ const key = opts.globalKey ?? DEFAULT_GLOBAL_KEY;
+ const unexpose = exposeOnGlobal(win, key, "stacks", provider, identity);
+ const entry: WbipProvider = Object.freeze({
+ id: `${key}.stacks`,
+ name: identity.name,
+ icon: identity.icon,
+ ...(opts.webUrl ? { webUrl: opts.webUrl } : {}),
+ methods: [...STACKS_PROVIDER_METHODS],
+ });
+ // WBIP-004: make sure the array exists, then push; never replace it (other wallets' entries stay).
+ const w = win as unknown as { wbip_providers?: unknown };
+ if (!Array.isArray(w.wbip_providers)) w.wbip_providers = [];
+ const list = w.wbip_providers as WbipProvider[];
+ if (!list.some((p) => p && p.id === entry.id)) list.push(entry);
+ return {
+ provider,
+ entry,
+ stop: () => {
+ unexpose();
+ const i = list.indexOf(entry);
+ if (i >= 0) list.splice(i, 1);
+ },
+ };
+}
diff --git a/packages/1mask/src/inpage/tron.ts b/packages/1mask/src/inpage/tron.ts
new file mode 100644
index 00000000..f0c2a6f3
--- /dev/null
+++ b/packages/1mask/src/inpage/tron.ts
@@ -0,0 +1,314 @@
+import type { Network } from "@clip-wallet/core";
+import { uuidV4 } from "../shared/bytes.js";
+import type { WalletIdentity } from "../shared/config.js";
+import { ProviderRpcError, RpcErrorCode, rpcError } from "../shared/errors.js";
+import type { ExposedAccount } from "../shared/protocol.js";
+import { TRON_INJECTED, tronChainIdOf, tronNetworkIdOf } from "../shared/tron-methods.js";
+import { Emitter } from "./emitter.js";
+import { DEFAULT_GLOBAL_KEY, type InjectedOptions, exposeOnGlobal } from "./injected-base.js";
+import type { InpageTransport } from "./transport.js";
+
+/**
+ * Injected TRON provider at `window.clipwallet.tron`, announced with TIP-6963 under the wallet's own identity.
+ *
+ * - TIP-1193 "TRON Provider JavaScript API" (Final): `request({ method, params })`, events `connect` /
+ * `disconnect` / `chainChanged` / `accountsChanged` via `on` / `removeListener` (Node EventEmitter API, return
+ * `this`), errors 4001 / 4100 / 4200 / 4900 / 4901. https://github.com/tronprotocol/tips/blob/master/tip-1193.md
+ * - TIP-1102 "Opt-in account exposure" (Final): `eth_requestAccounts` → `[address]`.
+ * https://github.com/tronprotocol/tips/blob/master/tip-1102.md
+ * - TIP-3326 "Wallet Switch TRON Chain Method" (Final): `wallet_switchEthereumChain` `[{ chainId }]` → null, 4902 for
+ * an unknown chain. https://github.com/tronprotocol/tips/blob/master/tip-3326.md
+ * - `eth_chainId`: TIP-1193 / TIP-3326 take the chain id from "TRON's eth_chainId" ("0x" + last 4 bytes of the genesis
+ * block id, e.g. 0x2b6653dc); answered from the page's current chain.
+ * - TIP-6963 "Multi Injected Provider Discovery" (Final): `TIP6963:announceProvider` with a frozen
+ * `{ info: { uuid, name, icon, rdns }, provider }`, re-announced on `TIP6963:requestProvider`.
+ * https://github.com/tronprotocol/tips/blob/master/tip-6963.md
+ *
+ * TIP-1193 defines no signing methods: it says the provider exposes an instantiated TronWeb as `tron.tronWeb` and
+ * dapps sign through it (`tronWeb.trx.sign`, `tronWeb.trx.signMessageV2`; that's also what @tronweb3/tronwallet-adapters
+ * calls). Clip doesn't ship TronWeb in the page; `tronWeb` here is only the documented subset dapps use for signing:
+ * `defaultAddress`, `ready`, `fullNode/solidityNode/eventServer.host`, `trx.sign(transaction)` and
+ * `trx.signMessageV2(message)`. Everything else on TronWeb (building transactions, reading the chain) a dapp does with
+ * its own TronWeb instance. Legacy `trx.sign(hexString)` message signing, `multiSign` and `_signTypedData` are refused
+ * with 4200. The provider never takes a private key, never sets `isTronLink`, and never touches `window.tronLink`,
+ * `window.tronWeb` or (unless `claimWindowTron` and nothing owns it) `window.tron`.
+ */
+
+export interface TronRequestArguments {
+ readonly method: string;
+ readonly params?: readonly unknown[] | object;
+}
+
+export interface TIP6963ProviderInfo {
+ uuid: string;
+ name: string;
+ icon: string;
+ rdns: string;
+}
+
+export interface TIP6963ProviderDetail {
+ info: TIP6963ProviderInfo;
+ provider: ClipTronProvider;
+}
+
+const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+
+/** "41…" hex of a base58 TRON address (checksum not re-checked: the address comes from the wallet itself). */
+function hex41(address: string): string | false {
+ let n = 0n;
+ for (const c of address) {
+ const i = B58.indexOf(c);
+ if (i < 0) return false;
+ n = n * 58n + BigInt(i);
+ }
+ const h = n.toString(16).padStart(50, "0");
+ return h.startsWith("41") ? h.slice(0, 42) : false;
+}
+
+const toHex = (b: ArrayLike) => Array.from(b, (x) => (x & 0xff).toString(16).padStart(2, "0")).join("");
+
+export class ClipTronProvider {
+ readonly isClipWallet = true;
+ /** The documented signing subset of TronWeb (see the file comment). */
+ readonly tronWeb: ClipTronWeb;
+ readonly #events = new Emitter();
+ readonly #transport: InpageTransport;
+ readonly #networks: Network[];
+ #accounts: string[] = [];
+ #chainId: string;
+ #connected = false;
+
+ constructor(networks: Network[], transport: InpageTransport) {
+ this.#transport = transport;
+ this.#networks = networks.filter((n) => n.family === "tron" && tronChainIdOf(n.id));
+ this.#chainId = tronChainIdOf(this.#networks[0]?.id ?? "") ?? "0x00000000";
+ this.tronWeb = new ClipTronWeb(this);
+ transport.onEvent((family, event, data) => {
+ if (family !== "tron") return;
+ if (event === "accountsChanged") this.#setAccounts(Array.isArray(data) ? (data as ExposedAccount[]) : []);
+ if (event === "disconnect") {
+ this.#setAccounts([]);
+ if (this.#connected) {
+ this.#connected = false;
+ this.#events.emit("disconnect", rpcError.disconnected());
+ }
+ }
+ });
+ }
+
+ get chainId(): string {
+ return this.#chainId;
+ }
+
+ get selectedAddress(): string | undefined {
+ return this.#accounts[0];
+ }
+
+ /** The current network's CAIP-2 id (what the background validates). */
+ get networkId(): string {
+ return `tron:${this.#chainId}`;
+ }
+
+ get rpcUrl(): string {
+ return this.#networks.find((n) => n.id === this.networkId)?.rpcUrls[0] ?? "";
+ }
+
+ #setAccounts(list: ExposedAccount[]): void {
+ const next = list.filter((a) => a && typeof a.address === "string").map((a) => a.address);
+ const changed = next.length !== this.#accounts.length || next.some((a, i) => a !== this.#accounts[i]);
+ this.#accounts = next;
+ if (changed) this.#events.emit("accountsChanged", [...next]);
+ }
+
+ /** Background call on the current chain. */
+ call(method: string, params?: unknown): Promise {
+ return this.#transport.request("tron", method, params, this.networkId);
+ }
+
+ async request(args: TronRequestArguments): Promise {
+ if (!args || typeof args !== "object" || typeof args.method !== "string") throw rpcError.invalidParams("Expected { method, params }.");
+ switch (args.method) {
+ case "eth_requestAccounts": {
+ let list = (await this.call(TRON_INJECTED.accounts)) as ExposedAccount[];
+ if (!Array.isArray(list) || !list.length) list = (await this.call(TRON_INJECTED.connect, {})) as ExposedAccount[];
+ this.#setAccounts(Array.isArray(list) ? list : []);
+ if (!this.#accounts.length) throw new ProviderRpcError(RpcErrorCode.Unauthorized, "No TRON account is available.");
+ if (!this.#connected) {
+ this.#connected = true;
+ this.#events.emit("connect", { chainId: this.#chainId });
+ }
+ return [...this.#accounts];
+ }
+ case "eth_chainId":
+ return this.#chainId;
+ case "wallet_switchEthereumChain": {
+ const p = Array.isArray(args.params) ? args.params[0] : args.params;
+ const chainId = p && typeof p === "object" ? (p as { chainId?: unknown }).chainId : undefined;
+ if (typeof chainId !== "string") throw rpcError.invalidParams("Expected [{ chainId }].");
+ const id = tronNetworkIdOf(chainId);
+ if (!id || !this.#networks.some((n) => n.id === id)) throw rpcError.unrecognizedChain(chainId);
+ const next = tronChainIdOf(id)!;
+ if (next !== this.#chainId) {
+ this.#chainId = next;
+ this.#events.emit("chainChanged", { chainId: next });
+ }
+ return null;
+ }
+ default:
+ throw rpcError.unsupportedMethod(args.method);
+ }
+ }
+
+ /** Signs a TronWeb transaction object; resolves to it with `signature` added (TronWeb `trx.sign`). */
+ async signTransaction(transaction: unknown): Promise {
+ if (!transaction || typeof transaction !== "object") throw rpcError.invalidParams("Expected a TronWeb transaction object.");
+ const address = this.#requireAccount();
+ return this.call(TRON_INJECTED.signTransaction, { address, transaction });
+ }
+
+ /** TronWeb `trx.signMessageV2`: a string (UTF-8) or bytes; resolves to the "0x…" signature. */
+ async signMessageV2(message: unknown): Promise {
+ const address = this.#requireAccount();
+ let params: { address: string; message: string; encoding?: "hex" };
+ if (typeof message === "string") params = { address, message };
+ else if (message instanceof Uint8Array || (Array.isArray(message) && message.every((x) => Number.isInteger(x) && x >= 0 && x < 256))) params = { address, message: toHex(message as ArrayLike), encoding: "hex" };
+ else throw rpcError.invalidParams("Expected a message string or bytes.");
+ const res = (await this.call(TRON_INJECTED.signMessage, params)) as { signature?: string };
+ if (typeof res?.signature !== "string") throw rpcError.internal();
+ return res.signature;
+ }
+
+ #requireAccount(): string {
+ const a = this.#accounts[0];
+ if (!a) throw rpcError.unauthorized("Connect Clip Wallet to this site first (eth_requestAccounts).");
+ return a;
+ }
+
+ on(event: string, listener: (...args: any[]) => void): this {
+ this.#events.on(event, listener);
+ return this;
+ }
+
+ once(event: string, listener: (...args: any[]) => void): this {
+ this.#events.once(event, listener);
+ return this;
+ }
+
+ removeListener(event: string, listener: (...args: any[]) => void): this {
+ this.#events.removeListener(event, listener);
+ return this;
+ }
+
+ off(event: string, listener: (...args: any[]) => void): this {
+ return this.removeListener(event, listener);
+ }
+
+ /** Not in TIP-1193: forgets this site's connection in the wallet. */
+ async disconnect(): Promise {
+ await this.call(TRON_INJECTED.disconnect);
+ this.#setAccounts([]);
+ }
+}
+
+/** The signing subset of TronWeb a TIP-1193 provider exposes as `tron.tronWeb` (see the file comment). */
+export class ClipTronWeb {
+ readonly #p: ClipTronProvider;
+ readonly trx: {
+ sign(transaction: unknown, privateKey?: unknown, useTronHeader?: unknown, multisig?: unknown): Promise;
+ signMessageV2(message: unknown, privateKey?: unknown): Promise;
+ multiSign(): Promise;
+ _signTypedData(): Promise;
+ };
+
+ constructor(p: ClipTronProvider) {
+ this.#p = p;
+ const noKeys = (k: unknown) => {
+ if (k !== undefined && k !== false && k !== null) throw rpcError.invalidParams("Clip Wallet signs with its own keys; don't pass a private key.");
+ };
+ this.trx = Object.freeze({
+ sign: async (transaction: unknown, privateKey?: unknown, _useTronHeader?: unknown, multisig?: unknown) => {
+ noKeys(privateKey);
+ if (typeof transaction === "string") throw new ProviderRpcError(RpcErrorCode.UnsupportedMethod, "Clip Wallet signs messages with signMessageV2 only.");
+ if (multisig) throw rpcError.unsupportedMethod("multiSign");
+ return p.signTransaction(transaction);
+ },
+ signMessageV2: async (message: unknown, privateKey?: unknown) => {
+ noKeys(privateKey);
+ return p.signMessageV2(message);
+ },
+ multiSign: async () => {
+ throw rpcError.unsupportedMethod("multiSign");
+ },
+ _signTypedData: async () => {
+ throw rpcError.unsupportedMethod("_signTypedData");
+ },
+ });
+ }
+
+ get ready(): boolean {
+ return !!this.#p.selectedAddress;
+ }
+
+ get defaultAddress(): { base58: string | false; hex: string | false } {
+ const a = this.#p.selectedAddress;
+ return { base58: a ?? false, hex: a ? hex41(a) : false };
+ }
+
+ get fullNode(): { host: string } {
+ return { host: this.#p.rpcUrl };
+ }
+
+ get solidityNode(): { host: string } {
+ return { host: this.#p.rpcUrl };
+ }
+
+ get eventServer(): { host: string } {
+ return { host: this.#p.rpcUrl };
+ }
+}
+
+/** TIP-6963: announce now and on every `TIP6963:requestProvider`. One v4 uuid per page session. */
+export function announceTip6963(win: Window, identity: WalletIdentity, provider: ClipTronProvider): { detail: TIP6963ProviderDetail; stop(): void } {
+ const info: TIP6963ProviderInfo = Object.freeze({ uuid: uuidV4(), name: identity.name, icon: identity.icon, rdns: identity.rdns });
+ const detail: TIP6963ProviderDetail = Object.freeze({ info, provider });
+ const announce = () => win.dispatchEvent(new CustomEvent("TIP6963:announceProvider", { detail }));
+ win.addEventListener("TIP6963:requestProvider", announce);
+ announce();
+ return { detail, stop: () => win.removeEventListener("TIP6963:requestProvider", announce) };
+}
+
+export interface TronInjectedOptions extends InjectedOptions {
+ /**
+ * Also set `window.tron` (TIP-1193's suggested global) when nothing owns it. Default false: TIP-6963 discovery and
+ * `window.clipwallet.tron` only. Never overrides another wallet's `window.tron`.
+ */
+ claimWindowTron?: boolean;
+}
+
+export function installTronProvider(
+ win: Window,
+ identity: WalletIdentity,
+ networks: Network[],
+ transport: InpageTransport,
+ opts: TronInjectedOptions = {},
+): { provider: ClipTronProvider; detail: TIP6963ProviderDetail; claimedWindowTron: boolean; stop(): void } {
+ const provider = new ClipTronProvider(networks, transport);
+ const unexpose = exposeOnGlobal(win, opts.globalKey ?? DEFAULT_GLOBAL_KEY, "tron", provider, identity);
+ const { detail, stop } = announceTip6963(win, identity, provider);
+ let claimed = false;
+ const w = win as unknown as { tron?: unknown };
+ if (opts.claimWindowTron && w.tron === undefined) {
+ Object.defineProperty(win, "tron", { value: provider, configurable: true, enumerable: false, writable: false });
+ claimed = true;
+ }
+ return {
+ provider,
+ detail,
+ claimedWindowTron: claimed,
+ stop: () => {
+ unexpose();
+ stop();
+ if (claimed) delete (win as unknown as { tron?: unknown }).tron;
+ },
+ };
+}
diff --git a/packages/1mask/src/inpage/xrpl.ts b/packages/1mask/src/inpage/xrpl.ts
new file mode 100644
index 00000000..35d26413
--- /dev/null
+++ b/packages/1mask/src/inpage/xrpl.ts
@@ -0,0 +1,134 @@
+import type { Network } from "@clip-wallet/core";
+import type { IdentifierArray, Wallet, WalletAccount } from "@wallet-standard/base";
+import {
+ StandardConnect,
+ StandardDisconnect,
+ StandardEvents,
+ type StandardConnectFeature,
+ type StandardConnectInput,
+ type StandardConnectOutput,
+ type StandardDisconnectFeature,
+ type StandardEventsFeature,
+} from "@wallet-standard/features";
+import { registerWallet } from "@wallet-standard/wallet";
+import type { WalletIdentity } from "../shared/config.js";
+import { rpcError } from "../shared/errors.js";
+import type { ExposedAccount } from "../shared/protocol.js";
+import { XRPL_INJECTED, XRPL_SIGN_AND_SUBMIT_TRANSACTION, XRPL_SIGN_TRANSACTION, xrplChainId } from "../shared/xrpl.js";
+import { DEFAULT_GLOBAL_KEY, type InjectedOptions, exposeOnGlobal } from "./injected-base.js";
+import { StandardWalletBase } from "./standard-base.js";
+import type { InpageTransport } from "./transport.js";
+
+/**
+ * XLS-72d XRPL wallet on Wallet Standard (see ../shared/xrpl.ts for the spec links). Types are written out here
+ * from @xrpl-wallet-standard/core 0.1.4 instead of importing it: that package pulls in xrpl.js and an alpha
+ * Xahau fork just for transaction types. The wallet registers under the kit wallet's own name and icon
+ * (`registerWallet`), so dapps list it as itself; it never claims to be GemWallet, Crossmark or Xaman.
+ */
+
+export interface XrplSignTransactionInput {
+ tx_json: Record;
+ account: WalletAccount;
+ /** `xrpl:` or an alias (`xrpl:testnet`). */
+ network: string;
+ options?: { autofill?: boolean; multisig?: boolean };
+}
+export interface XrplSignTransactionOutput {
+ signed_tx_blob: string;
+}
+export interface XrplSignAndSubmitTransactionOutput {
+ tx_hash: string;
+ tx_json: Record;
+}
+
+export type XrplSignTransactionFeature = {
+ [XRPL_SIGN_TRANSACTION]: { version: "1.0.0"; signTransaction(input: XrplSignTransactionInput): Promise };
+};
+export type XrplSignAndSubmitTransactionFeature = {
+ [XRPL_SIGN_AND_SUBMIT_TRANSACTION]: { version: "1.0.0"; signAndSubmitTransaction(input: XrplSignTransactionInput): Promise };
+};
+
+export type ClipXrplFeatures = StandardConnectFeature & StandardDisconnectFeature & StandardEventsFeature & XrplSignTransactionFeature & XrplSignAndSubmitTransactionFeature;
+
+/** @xrpl-wallet-standard/core REQUIRED_FEATURES, plus standard:disconnect. */
+export const XRPL_FEATURES = [StandardConnect, StandardDisconnect, StandardEvents, XRPL_SIGN_TRANSACTION, XRPL_SIGN_AND_SUBMIT_TRANSACTION] as const;
+
+/** The registry's XRPL networks as XLS-72d chains (their CAIP-2 ids already are: "xrpl:0", "xrpl:1", "xrpl:2"). */
+export function xrplChains(networks: Network[]): `xrpl:${string}`[] {
+ return networks.filter((n) => n.family === "xrpl" && /^xrpl:\d+$/.test(n.id)).map((n) => n.id as `xrpl:${string}`);
+}
+
+export class ClipXrplWallet extends StandardWalletBase {
+ protected readonly family = "xrpl" as const;
+ protected readonly accountFeatures: IdentifierArray = [XRPL_SIGN_TRANSACTION, XRPL_SIGN_AND_SUBMIT_TRANSACTION];
+
+ constructor(identity: WalletIdentity, networks: Network[], transport: InpageTransport) {
+ super(identity, xrplChains(networks), transport);
+ this.listen();
+ }
+
+ get features(): ClipXrplFeatures {
+ return {
+ [StandardConnect]: { version: "1.0.0", connect: this.#connect },
+ [StandardDisconnect]: { version: "1.0.0", disconnect: this.#disconnect },
+ [StandardEvents]: { version: "1.0.0", on: this.on },
+ [XRPL_SIGN_TRANSACTION]: { version: "1.0.0", signTransaction: this.#signTransaction },
+ [XRPL_SIGN_AND_SUBMIT_TRANSACTION]: { version: "1.0.0", signAndSubmitTransaction: this.#signAndSubmitTransaction },
+ };
+ }
+
+ #connect = async (input?: StandardConnectInput): Promise => {
+ if (input?.silent) return { accounts: await this.silentAccounts() };
+ const res = await this.transport.request("xrpl", XRPL_INJECTED.connect);
+ return { accounts: this.setAccounts(Array.isArray(res) ? (res as ExposedAccount[]) : []) };
+ };
+
+ #disconnect = async (): Promise => {
+ await this.transport.request("xrpl", XRPL_INJECTED.disconnect);
+ this.setAccounts([]);
+ };
+
+ #wire(input: XrplSignTransactionInput): { params: Record; chain: string } {
+ if (!input || typeof input !== "object" || !input.tx_json || typeof input.tx_json !== "object") throw rpcError.invalidParams("Expected { tx_json, account, network }.");
+ const chain = xrplChainId(input.network);
+ if (!chain || !this.chains.includes(chain as `xrpl:${string}`)) throw rpcError.chainDisconnected(`This wallet does not support ${String(input.network)}.`);
+ const account = this.ownAccount(input.account).address;
+ const params: Record = { tx_json: input.tx_json, account, network: chain };
+ if (input.options && typeof input.options === "object") {
+ params.options = { ...(input.options.autofill !== undefined ? { autofill: !!input.options.autofill } : {}), ...(input.options.multisig !== undefined ? { multisig: !!input.options.multisig } : {}) };
+ }
+ return { params, chain };
+ }
+
+ #signTransaction = async (input: XrplSignTransactionInput): Promise => {
+ const { params, chain } = this.#wire(input);
+ const res = (await this.transport.request("xrpl", XRPL_SIGN_TRANSACTION, params, chain)) as XrplSignTransactionOutput;
+ return { signed_tx_blob: res.signed_tx_blob };
+ };
+
+ #signAndSubmitTransaction = async (input: XrplSignTransactionInput): Promise => {
+ const { params, chain } = this.#wire(input);
+ const res = (await this.transport.request("xrpl", XRPL_SIGN_AND_SUBMIT_TRANSACTION, params, chain)) as XrplSignAndSubmitTransactionOutput;
+ return { tx_hash: res.tx_hash, tx_json: res.tx_json };
+ };
+}
+
+/**
+ * Registers the wallet with Wallet Standard (how XLS-72d dapps find wallets: @wallet-standard/app getWallets(),
+ * @xrpl-wallet-standard/app getRegisterdXRPLWallets()) and exposes it at `window.clipwallet.xrpl` (or the kit
+ * wallet's own global key). Returns undefined when the registry has no XRPL network.
+ */
+export function installXrpl(
+ win: Window,
+ identity: WalletIdentity,
+ networks: Network[],
+ transport: InpageTransport,
+ opts: InjectedOptions & { register?: (w: Wallet) => void } = {},
+): { wallet: ClipXrplWallet; stop(): void } | undefined {
+ if (!xrplChains(networks).length) return undefined;
+ const wallet = new ClipXrplWallet(identity, networks, transport);
+ // Wallet Standard has no unregister for wallets (the other Standard wallets in index.ts don't have one either).
+ (opts.register ?? registerWallet)(wallet);
+ const unexpose = exposeOnGlobal(win, opts.globalKey ?? DEFAULT_GLOBAL_KEY, "xrpl", wallet, identity);
+ return { wallet, stop: unexpose };
+}
diff --git a/packages/1mask/src/shared/bitcoincash.ts b/packages/1mask/src/shared/bitcoincash.ts
new file mode 100644
index 00000000..30d13c8c
--- /dev/null
+++ b/packages/1mask/src/shared/bitcoincash.ts
@@ -0,0 +1,83 @@
+import type { NetworkId } from "@clip-wallet/core";
+
+/**
+ * Bitcoin Cash dapp connectivity. BCH has no injected-provider standard; dapps reach wallets over WalletConnect v2
+ * with the community spec wc2-bch-bcr (https://github.com/mainnet-pat/wc2-bch-bcr, used by Cashonize, Paytaca, Zapit
+ * and apps such as TapSwap): namespace "bch", chains "bch:bitcoincash" / "bch:bchtest", methods bch_getAddresses,
+ * bch_signTransaction, bch_signMessage, event "addressesChanged". The signing methods are chains-bitcoincash's own
+ * DappRequest methods, so the background hands them over unchanged.
+ */
+export const BCH_WC = {
+ namespace: "bch",
+ getAddresses: "bch_getAddresses",
+ signTransaction: "bch_signTransaction",
+ signMessage: "bch_signMessage",
+ events: ["addressesChanged"],
+} as const;
+
+export const BCH_SIGNING_METHODS: readonly string[] = [BCH_WC.signTransaction, BCH_WC.signMessage];
+
+/**
+ * wc2-bch-bcr chain ids ↔ the wallet's CAIP-2 network ids (chains-bitcoincash BCH_NETS: bip122 + first own block).
+ * "bch:bchtest" names any test network; chipnet (where BCH upgrades are tested) is the default one.
+ */
+export const BCH_WC_CHAINS: Record = {
+ "bch:bitcoincash": "bip122:000000000000000000651ef99cb9fcbe",
+ "bch:bchtest": "bip122:00000000040ba9641ba98a37b2e5ceea",
+};
+
+const BCH_NETWORK_PREFIX: Record = {
+ "bip122:000000000000000000651ef99cb9fcbe": "bitcoincash",
+ "bip122:00000000040ba9641ba98a37b2e5ceea": "bchtest",
+ "bip122:000000001dd410c49a788668ce267517": "bchtest",
+};
+
+export function bchNetworkForWcChain(chain: string | undefined): NetworkId | undefined {
+ if (!chain) return undefined;
+ return BCH_WC_CHAINS[chain] ?? (BCH_NETWORK_PREFIX[chain] ? chain : undefined);
+}
+
+export function bchWcChainFor(networkId: NetworkId): string | undefined {
+ const p = BCH_NETWORK_PREFIX[networkId];
+ return p === "bitcoincash" ? "bch:bitcoincash" : p ? "bch:bchtest" : undefined;
+}
+
+/* ------------------------------------------------------------------ CashAddr re-spelling (checksum only, no hashing) */
+
+const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+
+function polymod(values: number[]): bigint {
+ const GEN = [0x98f2bc8e61n, 0x79b76d99e2n, 0xf33e5fb3c4n, 0xae2eabe2a8n, 0x1e4f43e470n];
+ let c = 1n;
+ for (const d of values) {
+ const c0 = c >> 35n;
+ c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
+ for (let i = 0; i < 5; i++) if ((c0 >> BigInt(i)) & 1n) c ^= GEN[i]!;
+ }
+ return c ^ 1n;
+}
+
+const words = (prefix: string) => [...prefix].map((ch) => ch.charCodeAt(0) & 31);
+
+/**
+ * The same account's CashAddr for `networkId` ("bitcoincash:q…" → "bchtest:q…"): the payload stays, only the
+ * prefix-bound checksum changes. Verifies the input checksum; undefined for anything that isn't a CashAddr.
+ */
+export function bchAddressOn(address: string, networkId: NetworkId): string | undefined {
+ const target = BCH_NETWORK_PREFIX[networkId];
+ const a = address.trim().toLowerCase();
+ const i = a.indexOf(":");
+ if (!target || i <= 0) return undefined;
+ const payload: number[] = [];
+ for (const ch of a.slice(i + 1)) {
+ const w = CHARSET.indexOf(ch);
+ if (w < 0) return undefined;
+ payload.push(w);
+ }
+ if (payload.length < 42 || polymod([...words(a.slice(0, i)), 0, ...payload]) !== 0n) return undefined;
+ const data = payload.slice(0, -8);
+ const mod = polymod([...words(target), 0, ...data, 0, 0, 0, 0, 0, 0, 0, 0]);
+ const check: number[] = [];
+ for (let k = 0; k < 8; k++) check.push(Number((mod >> BigInt(5 * (7 - k))) & 31n));
+ return `${target}:${[...data, ...check].map((w) => CHARSET[w]).join("")}`;
+}
diff --git a/packages/1mask/src/shared/config.ts b/packages/1mask/src/shared/config.ts
index f427dff8..3e9b25ae 100644
--- a/packages/1mask/src/shared/config.ts
+++ b/packages/1mask/src/shared/config.ts
@@ -46,6 +46,13 @@ export interface InpageConfig {
stellar?: boolean;
tezos?: boolean;
algorand?: boolean;
+ /** networks87: Cosmos SDK (Keplr-compatible), TRON (TIP-1193/TIP-6963), Stacks (SIP-030/WBIP-004), Fuel (FuelConnector). */
+ cosmos?: boolean;
+ tron?: boolean;
+ stacks?: boolean;
+ fuel?: boolean;
+ xrpl?: boolean;
+ multiversx?: boolean;
cardano?: boolean;
substrate?: boolean;
starknet?: boolean;
diff --git a/packages/1mask/src/shared/cosmos-bech32.ts b/packages/1mask/src/shared/cosmos-bech32.ts
new file mode 100644
index 00000000..a159ee7b
--- /dev/null
+++ b/packages/1mask/src/shared/cosmos-bech32.ts
@@ -0,0 +1,80 @@
+/**
+ * BIP-173 bech32 (not bech32m), dependency-free so the page bundle and the background dispatcher can re-spell a
+ * Cosmos account address with another chain's prefix (the 20 address bytes are the same on every chain of a key
+ * family). Spec and reference: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki
+ */
+
+const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
+
+function polymod(values: number[]): number {
+ let chk = 1;
+ for (const v of values) {
+ const b = chk >>> 25;
+ chk = ((chk & 0x1ffffff) << 5) ^ v;
+ for (let i = 0; i < 5; i++) if ((b >>> i) & 1) chk ^= GEN[i]!;
+ }
+ return chk;
+}
+
+function hrpExpand(hrp: string): number[] {
+ const out: number[] = [];
+ for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >>> 5);
+ out.push(0);
+ for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
+ return out;
+}
+
+function convert(data: ArrayLike, from: number, to: number, pad: boolean): number[] | null {
+ let acc = 0;
+ let bits = 0;
+ const out: number[] = [];
+ const max = (1 << to) - 1;
+ for (let i = 0; i < data.length; i++) {
+ const v = data[i]!;
+ if (v < 0 || v >> from) return null;
+ acc = (acc << from) | v;
+ bits += from;
+ while (bits >= to) {
+ bits -= to;
+ out.push((acc >>> bits) & max);
+ }
+ }
+ if (pad) {
+ if (bits > 0) out.push((acc << (to - bits)) & max);
+ } else if (bits >= from || (acc << (to - bits)) & max) return null;
+ return out;
+}
+
+export function bech32Encode(hrp: string, data: Uint8Array): string {
+ const words = convert(data, 8, 5, true)!;
+ const values = [...hrpExpand(hrp), ...words, 0, 0, 0, 0, 0, 0];
+ const mod = polymod(values) ^ 1;
+ const checksum = Array.from({ length: 6 }, (_, i) => (mod >>> (5 * (5 - i))) & 31);
+ return `${hrp}1${[...words, ...checksum].map((w) => CHARSET[w]).join("")}`;
+}
+
+/** { prefix, data } for a valid lower-case bech32 string, else null. */
+export function bech32Decode(value: string): { prefix: string; data: Uint8Array } | null {
+ if (typeof value !== "string" || value.length > 90 || value !== value.toLowerCase()) return null;
+ const sep = value.lastIndexOf("1");
+ if (sep < 1 || sep + 7 > value.length) return null;
+ const hrp = value.slice(0, sep);
+ const words: number[] = [];
+ for (const ch of value.slice(sep + 1)) {
+ const w = CHARSET.indexOf(ch);
+ if (w < 0) return null;
+ words.push(w);
+ }
+ if (polymod([...hrpExpand(hrp), ...words]) !== 1) return null;
+ const bytes = convert(words.slice(0, -6), 5, 8, false);
+ return bytes ? { prefix: hrp, data: Uint8Array.from(bytes) } : null;
+}
+
+export const hexOf = (b: Uint8Array): string => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
+
+export function bytesOfHex(h: string): Uint8Array {
+ const clean = h.replace(/^0x/, "");
+ if (clean.length % 2 || !/^[0-9a-fA-F]*$/.test(clean)) throw new Error("not hex");
+ return Uint8Array.from(clean.match(/../g) ?? [], (x) => parseInt(x, 16));
+}
diff --git a/packages/1mask/src/shared/cosmos.ts b/packages/1mask/src/shared/cosmos.ts
new file mode 100644
index 00000000..c361faf4
--- /dev/null
+++ b/packages/1mask/src/shared/cosmos.ts
@@ -0,0 +1,151 @@
+import type { Family } from "@clip-wallet/core";
+import { METHOD_WS_STATE } from "./protocol.js";
+
+/**
+ * Wire methods and public chain facts for the Keplr-compatible Cosmos provider (inpage/cosmos.ts,
+ * background/cosmos.ts). The signing method names are chains-cosmos `COSMOS_METHODS`, so the background hands them
+ * to the module unchanged. The chain table repeats what @clip-wallet/chains-cosmos/networks ships (1Mask doesn't
+ * depend on chain packages); chains-cosmos's tests check the two agree.
+ */
+
+export type CosmosFamily = "cosmos" | "provenance" | "thorchain" | "initia";
+export const COSMOS_FAMILIES = ["cosmos", "provenance", "thorchain", "initia"] as const satisfies readonly Family[];
+
+export const COSMOS_INJECTED = {
+ accounts: METHOD_WS_STATE,
+ enable: "cosmos:enable",
+ disable: "cosmos:disable",
+ getKey: "cosmos:getKey",
+ signDirect: "cosmos_signDirect",
+ signAmino: "cosmos_signAmino",
+ signArbitrary: "cosmos_signArbitrary",
+ sendTx: "cosmos_sendTx",
+ verifyArbitrary: "cosmos_verifyArbitrary",
+} as const;
+
+/** Connect method: the extension's connector treats it as a connect approval. */
+export const COSMOS_CONNECT_METHODS = [COSMOS_INJECTED.enable] as const;
+
+/** Keplr Key over the wire (bytes as hex). */
+export interface CosmosKeyWire {
+ algo: string;
+ pubKey: string;
+ address: string;
+ bech32Address: string;
+ ethereumHexAddress: string;
+}
+
+export interface CosmosCurrency {
+ coinDenom: string;
+ coinMinimalDenom: string;
+ coinDecimals: number;
+}
+
+/** Keplr ChainInfoWithoutEndpoints (@keplr-wallet/types src/chain-info.ts): ChainInfo minus rest/rpc/nodeProvider. */
+export interface CosmosChainInfoWithoutEndpoints {
+ readonly rest: undefined;
+ readonly rpc: undefined;
+ readonly nodeProvider: undefined;
+ readonly chainId: string;
+ readonly chainName: string;
+ readonly stakeCurrency?: CosmosCurrency;
+ readonly bip44: { coinType: number };
+ readonly bech32Config: {
+ bech32PrefixAccAddr: string;
+ bech32PrefixAccPub: string;
+ bech32PrefixValAddr: string;
+ bech32PrefixValPub: string;
+ bech32PrefixConsAddr: string;
+ bech32PrefixConsPub: string;
+ };
+ readonly currencies: CosmosCurrency[];
+ readonly feeCurrencies: (CosmosCurrency & { gasPriceStep?: { low: number; average: number; high: number } })[];
+ readonly features: string[];
+ readonly isTestnet: boolean;
+}
+
+export interface CosmosChainFacts {
+ chainId: string;
+ family: CosmosFamily;
+ chainName: string;
+ prefix: string;
+ coinType: number;
+ /** Keplr Key.algo. */
+ algo: "secp256k1" | "ethsecp256k1";
+ native: CosmosCurrency;
+ /** Native gas price step (chain-registry). */
+ gas: { low: number; average: number; high: number };
+ usdc?: string;
+ /** THORChain has no x/staking: no stake currency. */
+ staking: boolean;
+ features: string[];
+ testnet: boolean;
+}
+
+const cur = (coinDenom: string, coinMinimalDenom: string, coinDecimals: number): CosmosCurrency => ({ coinDenom, coinMinimalDenom, coinDecimals });
+const OSMO = cur("OSMO", "uosmo", 6);
+const ZIG = cur("ZIG", "azig", 18);
+const HASH = cur("HASH", "nhash", 9);
+const INIT = cur("INIT", "uinit", 6);
+const DYDX_USDC = "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5";
+
+export const COSMOS_CHAIN_FACTS: readonly CosmosChainFacts[] = [
+ { chainId: "osmo-test-5", family: "cosmos", chainName: "Osmosis Testnet", prefix: "osmo", coinType: 118, algo: "secp256k1", native: OSMO, gas: { low: 0.0025, average: 0.025, high: 0.04 }, usdc: "ibc/DE6792CF9E521F6AD6E9A4BDF6225C9571A3B74ACC0A529F92BC5122A39D2E58", staking: true, features: ["cosmwasm", "osmosis-txfees"], testnet: true },
+ { chainId: "osmosis-1", family: "cosmos", chainName: "Osmosis", prefix: "osmo", coinType: 118, algo: "secp256k1", native: OSMO, gas: { low: 0.03, average: 0.1, high: 0.16 }, usdc: "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", staking: true, features: ["cosmwasm", "osmosis-txfees"], testnet: false },
+ { chainId: "dydx-testnet-4", family: "cosmos", chainName: "dYdX Testnet", prefix: "dydx", coinType: 118, algo: "secp256k1", native: cur("DV4TNT", "adv4tnt", 18), gas: { low: 25000000000, average: 25000000000, high: 50000000000 }, usdc: DYDX_USDC, staking: true, features: [], testnet: true },
+ { chainId: "dydx-mainnet-1", family: "cosmos", chainName: "dYdX", prefix: "dydx", coinType: 118, algo: "secp256k1", native: cur("DYDX", "adydx", 18), gas: { low: 12500000000, average: 12500000000, high: 20000000000 }, usdc: DYDX_USDC, staking: true, features: [], testnet: false },
+ { chainId: "zig-test-2", family: "cosmos", chainName: "ZIGChain Testnet", prefix: "zig", coinType: 118, algo: "secp256k1", native: ZIG, gas: { low: 2500000000, average: 25000000000, high: 50000000000 }, staking: true, features: ["cosmwasm"], testnet: true },
+ { chainId: "zigchain-1", family: "cosmos", chainName: "ZIGChain", prefix: "zig", coinType: 118, algo: "secp256k1", native: ZIG, gas: { low: 2500000000, average: 25000000000, high: 50000000000 }, staking: true, features: ["cosmwasm"], testnet: false },
+ { chainId: "pio-testnet-1", family: "provenance", chainName: "Provenance Testnet", prefix: "tp", coinType: 505, algo: "secp256k1", native: HASH, gas: { low: 1, average: 1, high: 1 }, staking: true, features: ["cosmwasm"], testnet: true },
+ { chainId: "pio-mainnet-1", family: "provenance", chainName: "Provenance", prefix: "pb", coinType: 505, algo: "secp256k1", native: HASH, gas: { low: 1, average: 1, high: 1 }, staking: true, features: ["cosmwasm"], testnet: false },
+ { chainId: "thorchain-1", family: "thorchain", chainName: "THORChain", prefix: "thor", coinType: 931, algo: "secp256k1", native: cur("RUNE", "rune", 8), gas: { low: 0, average: 0, high: 0 }, staking: false, features: [], testnet: false },
+ { chainId: "initiation-2", family: "initia", chainName: "Initia Testnet", prefix: "init", coinType: 60, algo: "ethsecp256k1", native: INIT, gas: { low: 0.015, average: 0.015, high: 0.04 }, staking: true, features: ["eth-address-gen", "eth-key-sign", "eth-secp256k1-initia"], testnet: true },
+ { chainId: "interwoven-1", family: "initia", chainName: "Initia", prefix: "init", coinType: 60, algo: "ethsecp256k1", native: INIT, gas: { low: 0.015, average: 0.015, high: 0.04 }, staking: true, features: ["eth-address-gen", "eth-key-sign", "eth-secp256k1-initia"], testnet: false },
+];
+
+export function cosmosChainOf(chainId: string): CosmosChainFacts | undefined {
+ return COSMOS_CHAIN_FACTS.find((c) => c.chainId === chainId);
+}
+
+export function chainInfoWithoutEndpoints(chainId: string): CosmosChainInfoWithoutEndpoints | undefined {
+ const c = cosmosChainOf(chainId);
+ if (!c) return undefined;
+ const p = c.prefix;
+ const usdc = c.usdc ? [cur("USDC", c.usdc, 6)] : [];
+ return {
+ rest: undefined,
+ rpc: undefined,
+ nodeProvider: undefined,
+ chainId: c.chainId,
+ chainName: c.chainName,
+ ...(c.staking ? { stakeCurrency: c.native } : {}),
+ bip44: { coinType: c.coinType },
+ bech32Config: {
+ bech32PrefixAccAddr: p,
+ bech32PrefixAccPub: `${p}pub`,
+ bech32PrefixValAddr: `${p}valoper`,
+ bech32PrefixValPub: `${p}valoperpub`,
+ bech32PrefixConsAddr: `${p}valcons`,
+ bech32PrefixConsPub: `${p}valconspub`,
+ },
+ currencies: [c.native, ...usdc],
+ feeCurrencies: [{ ...c.native, gasPriceStep: c.gas }],
+ features: [...c.features],
+ isTestnet: c.testnet,
+ };
+}
+
+/* ------------------------------------------------------------------ base64 without Buffer (page bundle) */
+
+export function b64FromBytes(bytes: Uint8Array): string {
+ let s = "";
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
+ return btoa(s);
+}
+
+export function bytesFromB64(b64: string): Uint8Array {
+ const s = atob(b64);
+ const out = new Uint8Array(s.length);
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
+ return out;
+}
diff --git a/packages/1mask/src/shared/fuel.ts b/packages/1mask/src/shared/fuel.ts
new file mode 100644
index 00000000..17ad8324
--- /dev/null
+++ b/packages/1mask/src/shared/fuel.ts
@@ -0,0 +1,40 @@
+import type { Network, NetworkId } from "@clip-wallet/core";
+import { METHOD_WS_STATE } from "./protocol.js";
+
+/**
+ * Wire methods of the Fuel connector (inpage/fuel.ts ↔ background/fuel.ts). The signing names are chains-fuel's own
+ * DappRequest methods (FUEL_METHODS), so the background hands them to the module unchanged.
+ */
+export const FUEL_INJECTED = {
+ /** Silent account read (no prompt). */
+ accounts: METHOD_WS_STATE,
+ connect: "fuel:connect",
+ disconnect: "fuel:disconnect",
+ currentNetwork: "fuel:currentNetwork",
+ networks: "fuel:networks",
+ selectNetwork: "fuel:selectNetwork",
+ sendTransaction: "fuel_sendTransaction",
+ signTransaction: "fuel_signTransaction",
+ signMessage: "fuel_signMessage",
+} as const;
+
+export const FUEL_CONNECT_METHODS = [FUEL_INJECTED.connect] as const;
+
+/** The Fuel connector's network shape (fuels-ts connectors/types `Network`). */
+export interface FuelConnectorNetwork {
+ url: string;
+ chainId: number;
+}
+
+/** "fuel:" (chains-fuel networks) → the connector's numeric chain id. */
+export function fuelChainId(networkId: NetworkId): number | undefined {
+ const m = /^fuel:(\d+)$/.exec(networkId);
+ return m ? Number(m[1]) : undefined;
+}
+
+export function fuelConnectorNetwork(net: Network): FuelConnectorNetwork {
+ return { url: net.rpcUrls[0] ?? "", chainId: fuelChainId(net.id) ?? -1 };
+}
+
+/** Message forms on the wire (chains-fuel `FuelMessage`): a plain string, personalSign text, or personalSign bytes. */
+export type FuelWireMessage = { text: string } | { personalSign: string } | { personalSignHex: string };
diff --git a/packages/1mask/src/shared/multiversx-methods.ts b/packages/1mask/src/shared/multiversx-methods.ts
new file mode 100644
index 00000000..1b3f3812
--- /dev/null
+++ b/packages/1mask/src/shared/multiversx-methods.ts
@@ -0,0 +1,42 @@
+import { METHOD_WS_STATE } from "./protocol.js";
+
+/**
+ * Wire methods of the MultiversX provider (inpage/multiversx.ts ↔ background/multiversx.ts). The signing methods are
+ * chains-multiversx's own DappRequest methods (MultiversX's WalletConnect method names), handed to the module
+ * unchanged:
+ * - mvx_signTransactions { transactions: sdk-core plain objects, address? } → { signatures: [{ signature }], transactions }
+ * - mvx_signMessage { message: UTF-8 text, address } → { signature: hex, address }
+ */
+export const MULTIVERSX_INJECTED = {
+ accounts: METHOD_WS_STATE,
+ connect: "mvx:connect",
+ disconnect: "mvx:disconnect",
+ signTransactions: "mvx_signTransactions",
+ signMessage: "mvx_signMessage",
+} as const;
+
+/** "mvx:" + the transaction chainID ("1", "D", "T"): the ChainAgnostic / WalletConnect id chains-multiversx uses. */
+export function multiversxNetworkIdOf(chainID: unknown): string | null {
+ return typeof chainID === "string" && /^[0-9A-Za-z]{1,8}$/.test(chainID) ? `mvx:${chainID}` : null;
+}
+
+const fromB64Url = (s: string): string | null => {
+ try {
+ const b = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
+ return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(b, (c) => c.charCodeAt(0)));
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * The site a MultiversX native-auth login token is for, or null when `token` isn't one. Token format
+ * (sdk-native-auth-client `initialize`): base64url(origin) "." blockHash "." ttl "." base64url(extraInfo JSON).
+ * https://github.com/multiversx/mx-sdk-js-native-auth-client/blob/main/src/native.auth.client.ts
+ */
+export function nativeAuthOrigin(token: string): string | null {
+ const parts = token.split(".");
+ if (parts.length !== 4 || !/^[0-9a-f]{64}$/i.test(parts[1]!) || !/^\d{1,10}$/.test(parts[2]!)) return null;
+ const origin = fromB64Url(parts[0]!);
+ return origin && origin.length <= 512 ? origin : null;
+}
diff --git a/packages/1mask/src/shared/protocol.ts b/packages/1mask/src/shared/protocol.ts
index e11f91b8..e59d5487 100644
--- a/packages/1mask/src/shared/protocol.ts
+++ b/packages/1mask/src/shared/protocol.ts
@@ -15,6 +15,7 @@ export const DEFAULT_CHANNEL = "clip-wallet-1mask";
export const FAMILIES = [
"evm", "hedera", "solana", "bitcoin",
"sui", "aptos", "cardano", "substrate", "starknet", "ton", "near", "stellar", "tezos", "algorand",
+ "cosmos", "provenance", "thorchain", "initia", "tron", "xrpl", "antelope", "multiversx", "icp", "stacks", "fuel", "bitcoincash",
] as const satisfies readonly Family[];
export const familySchema = z.enum(FAMILIES);
diff --git a/packages/1mask/src/shared/stacks.ts b/packages/1mask/src/shared/stacks.ts
new file mode 100644
index 00000000..2e32db15
--- /dev/null
+++ b/packages/1mask/src/shared/stacks.ts
@@ -0,0 +1,115 @@
+import type { NetworkId } from "@clip-wallet/core";
+import { METHOD_WS_STATE } from "./protocol.js";
+
+/**
+ * Stacks wire methods for 1Mask. The signing methods are SIP-030's own names (= chains-stacks STACKS_METHODS), so
+ * the background hands them to the module unchanged. Sources:
+ * - SIP-030 (Recommended) https://github.com/stacksgov/sips/blob/main/sips/sip-030/sip-030-wallet-interface.md
+ * - @stacks/connect v8 `request()` / `MethodParams` https://github.com/stx-labs/connect/blob/main/packages/connect/src/methods.ts
+ */
+export const STACKS_INJECTED = {
+ accounts: METHOD_WS_STATE,
+ /** "Connect": SIP-030 stx_getAddresses (and @stacks/connect's getAddresses) prompt on first use. */
+ connect: "stacks:connect",
+ disconnect: "stacks:disconnect",
+ /** SIP-030 stx_getNetworks (answered by the background, no prompt). */
+ getNetworks: "stx_getNetworks",
+ transferStx: "stx_transferStx",
+ transferSip10Ft: "stx_transferSip10Ft",
+ transferSip9Nft: "stx_transferSip9Nft",
+ callContract: "stx_callContract",
+ deployContract: "stx_deployContract",
+ signTransaction: "stx_signTransaction",
+ signMessage: "stx_signMessage",
+ signStructuredMessage: "stx_signStructuredMessage",
+} as const;
+
+export const STACKS_SIGNING_METHODS: readonly string[] = [
+ STACKS_INJECTED.transferStx,
+ STACKS_INJECTED.transferSip10Ft,
+ STACKS_INJECTED.transferSip9Nft,
+ STACKS_INJECTED.callContract,
+ STACKS_INJECTED.deployContract,
+ STACKS_INJECTED.signTransaction,
+ STACKS_INJECTED.signMessage,
+ STACKS_INJECTED.signStructuredMessage,
+];
+
+/** ChainAgnostic stacks namespace (CAIP-2 = "stacks:" + chain id) ↔ SIP-030 network names. */
+export const STACKS_CHAINS: Record<"mainnet" | "testnet", { caip2: NetworkId; chainId: number; transactionVersion: number }> = {
+ mainnet: { caip2: "stacks:1", chainId: 1, transactionVersion: 0x00 },
+ testnet: { caip2: "stacks:2147483648", chainId: 0x80000000, transactionVersion: 0x80 },
+};
+
+export function stacksNetworkName(networkId: NetworkId): "mainnet" | "testnet" | undefined {
+ return networkId === STACKS_CHAINS.mainnet.caip2 ? "mainnet" : networkId === STACKS_CHAINS.testnet.caip2 ? "testnet" : undefined;
+}
+
+/** SIP-030 `network` param ("mainnet" | "testnet" | CAIP-2) → CAIP-2 chain hint; undefined for anything else. */
+export function stacksChainHint(network: unknown): NetworkId | undefined {
+ if (typeof network !== "string") return undefined;
+ const n = network.trim().toLowerCase();
+ if (n === "mainnet" || n === STACKS_CHAINS.mainnet.caip2) return STACKS_CHAINS.mainnet.caip2;
+ if (n === "testnet" || n === STACKS_CHAINS.testnet.caip2) return STACKS_CHAINS.testnet.caip2;
+ return undefined;
+}
+
+/* ------------------------------------------------------------------ c32check re-spelling (WebCrypto only) */
+
+const C32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+/** Single-sig versions: 22 "SP" mainnet, 26 "ST" testnet; multisig 20 "SM" / 21 "SN". */
+const SINGLE = { mainnet: 22, testnet: 26 } as const;
+const MULTI = { mainnet: 20, testnet: 21 } as const;
+
+function c32encode(data: Uint8Array): string {
+ let n = 0n;
+ for (const b of data) n = (n << 8n) | BigInt(b);
+ let out = "";
+ while (n > 0n) {
+ out = C32[Number(n & 31n)]! + out;
+ n >>= 5n;
+ }
+ let zeros = 0;
+ while (zeros < data.length && data[zeros] === 0) zeros++;
+ return "0".repeat(zeros) + out;
+}
+
+function c32decode24(text: string): Uint8Array | undefined {
+ let n = 0n;
+ for (const ch of text) {
+ const v = C32.indexOf(ch);
+ if (v < 0) return undefined;
+ n = (n << 5n) | BigInt(v);
+ }
+ const out = new Uint8Array(24);
+ for (let i = 23; i >= 0; i--) {
+ out[i] = Number(n & 0xffn);
+ n >>= 8n;
+ }
+ return n === 0n ? out : undefined;
+}
+
+async function checksum(version: number, h: Uint8Array): Promise {
+ const d1 = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array([version, ...h])));
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", d1)).subarray(0, 4);
+}
+
+/**
+ * The same account's address spelled for `networkId` (Account.address is the mainnet "SP…" form; testnet is
+ * "ST…": same hash160, version 26). Verifies the input's c32check checksum; undefined when it isn't a Stacks address.
+ */
+export async function stacksAddressOn(address: string, networkId: NetworkId): Promise {
+ const net = stacksNetworkName(networkId);
+ const a = address.trim().toUpperCase();
+ if (!net || !/^S[0-9A-Z]{3,42}$/.test(a)) return undefined;
+ const version = C32.indexOf(a[1]!);
+ const body = c32decode24(a.slice(2));
+ if (!body || version < 0) return undefined;
+ const h = body.subarray(0, 20);
+ const sum = await checksum(version, h);
+ if (sum.some((b, i) => b !== body[20 + i])) return undefined;
+ const multi = version === MULTI.mainnet || version === MULTI.testnet;
+ const target = multi ? MULTI[net] : SINGLE[net];
+ if (!multi && version !== SINGLE.mainnet && version !== SINGLE.testnet) return undefined;
+ return "S" + C32[target] + c32encode(new Uint8Array([...h, ...(await checksum(target, h))]));
+}
diff --git a/packages/1mask/src/shared/tron-methods.ts b/packages/1mask/src/shared/tron-methods.ts
new file mode 100644
index 00000000..6b070aba
--- /dev/null
+++ b/packages/1mask/src/shared/tron-methods.ts
@@ -0,0 +1,38 @@
+import type { NetworkId } from "@clip-wallet/core";
+import { METHOD_WS_STATE } from "./protocol.js";
+
+/**
+ * Wire methods of the injected TRON provider (inpage/tron.ts ↔ background/tron.ts). The signing methods are
+ * chains-tron's own DappRequest methods (the WalletConnect / Reown TRON method names), so the background hands them
+ * to the module unchanged:
+ * - tron_signTransaction { address, transaction } → the TronWeb transaction with `signature: [hex]`
+ * - tron_signMessage { address, message, encoding?: "hex" } → { signature: "0x…" } (TronWeb signMessageV2 format)
+ */
+export const TRON_INJECTED = {
+ accounts: METHOD_WS_STATE,
+ connect: "tron:connect",
+ disconnect: "tron:disconnect",
+ signTransaction: "tron_signTransaction",
+ signMessage: "tron_signMessage",
+} as const;
+
+/** TRON chain ids as `eth_chainId` / TIP-1193 report them ("0x" + last 4 bytes of the genesis block id). */
+export const TRON_CHAIN_IDS = {
+ mainnet: "0x2b6653dc",
+ shasta: "0x94a9059e",
+ nile: "0xcd8690dc",
+} as const;
+
+/** CAIP-2 id (WalletConnect / Reown form, "tron:0x…") for a TIP-1193 chain id. */
+export function tronNetworkIdOf(chainId: string): NetworkId | null {
+ const c = chainId.trim().toLowerCase();
+ if (/^0x[0-9a-f]{1,8}$/.test(c)) return `tron:0x${c.slice(2).padStart(8, "0")}`;
+ if (/^\d{1,10}$/.test(c)) return `tron:0x${Number(c).toString(16).padStart(8, "0")}`;
+ return null;
+}
+
+/** TIP-1193 chain id ("0x…") of a CAIP-2 TRON network id. */
+export function tronChainIdOf(networkId: NetworkId): string | null {
+ const m = /^tron:(0x[0-9a-f]{8})$/i.exec(networkId);
+ return m ? m[1]!.toLowerCase() : null;
+}
diff --git a/packages/1mask/src/shared/xrpl.ts b/packages/1mask/src/shared/xrpl.ts
new file mode 100644
index 00000000..ae7953fd
--- /dev/null
+++ b/packages/1mask/src/shared/xrpl.ts
@@ -0,0 +1,51 @@
+/**
+ * XRP Ledger wire names for 1Mask, plain strings so the background never imports the in-page wallet.
+ *
+ * The standard is XLS-72d "Browser Wallet Standard" (https://github.com/XRPLF/XRPL-Standards/discussions/206, by
+ * tequ; the discussion was closed for inactivity in March 2026 without being replaced), built on Wallet Standard
+ * (https://github.com/wallet-standard/wallet-standard). Its reference types are @xrpl-wallet-standard/core 0.1.4
+ * (https://github.com/tequdev/xrpl-wallet-standard, packages/core/src/features/*.ts, networks.ts, utils.ts):
+ * - features `xrpl:signTransaction` 1.0.0 ({ tx_json, account, network, options? } → { signed_tx_blob }) and
+ * `xrpl:signAndSubmitTransaction` 1.0.0 (→ { tx_hash, tx_json }), plus standard:connect / standard:events
+ * (REQUIRED_FEATURES) and standard:disconnect;
+ * - chains `xrpl:` (`xrpl:0` mainnet, `xrpl:1` testnet, `xrpl:2` devnet), with the aliases
+ * `xrpl:mainnet` / `xrpl:testnet` / `xrpl:devnet` accepted as input.
+ * There is no message-signing feature in XLS-72d, so 1Mask has none for the XRPL.
+ */
+import { METHOD_WS_STATE } from "./protocol.js";
+
+export const XRPL_SIGN_TRANSACTION = "xrpl:signTransaction" as const;
+export const XRPL_SIGN_AND_SUBMIT_TRANSACTION = "xrpl:signAndSubmitTransaction" as const;
+
+export const XRPL_INJECTED = {
+ /** Silent account read (no prompt). */
+ state: METHOD_WS_STATE,
+ connect: "standard:connect",
+ disconnect: "standard:disconnect",
+ signTransaction: XRPL_SIGN_TRANSACTION,
+ signAndSubmitTransaction: XRPL_SIGN_AND_SUBMIT_TRANSACTION,
+} as const;
+
+export const XRPL_METHODS_ALLOWED = {
+ local: [XRPL_INJECTED.state, XRPL_INJECTED.disconnect],
+ connect: [XRPL_INJECTED.connect],
+ signing: [XRPL_INJECTED.signTransaction, XRPL_INJECTED.signAndSubmitTransaction],
+} as const;
+
+/** For methods.ts `injectedAllowlist("xrpl")`. */
+export function xrplAllowlist(): ReadonlySet {
+ return new Set([...XRPL_METHODS_ALLOWED.local, ...XRPL_METHODS_ALLOWED.connect, ...XRPL_METHODS_ALLOWED.signing]);
+}
+
+const ALIASES: Record = { "xrpl:mainnet": "xrpl:0", "xrpl:testnet": "xrpl:1", "xrpl:devnet": "xrpl:2" };
+
+/**
+ * An XLS-72d network identifier as the CAIP-2 id the registry uses ("xrpl:testnet" → "xrpl:1"), or undefined when it
+ * isn't an XRPL identifier at all. Xahau's `xrpl:21337` / `xrpl:21338` pass through and are refused later as networks
+ * this wallet doesn't have.
+ */
+export function xrplChainId(network: unknown): string | undefined {
+ if (typeof network !== "string") return undefined;
+ if (ALIASES[network]) return ALIASES[network];
+ return /^xrpl:\d{1,10}$/.test(network) ? network : undefined;
+}
diff --git a/packages/1mask/src/walletconnect/namespaces.ts b/packages/1mask/src/walletconnect/namespaces.ts
index 7ea02bc2..724bca8b 100644
--- a/packages/1mask/src/walletconnect/namespaces.ts
+++ b/packages/1mask/src/walletconnect/namespaces.ts
@@ -1,5 +1,6 @@
import type { Family, Network } from "@clip-wallet/core";
import { HEDERA_METHODS } from "../background/methods.js";
+import { BCH_WC, bchAddressOn, bchNetworkForWcChain } from "../shared/bitcoincash.js";
import { P2_WC_NAMESPACE_FAMILY, P2_WC_SUPPORTED_EVENTS, P2_WC_SUPPORTED_METHODS, type P2WcNamespaceKey } from "./p2-namespaces.js";
/**
@@ -14,13 +15,14 @@ import { P2_WC_NAMESPACE_FAMILY, P2_WC_SUPPORTED_EVENTS, P2_WC_SUPPORTED_METHODS
* - Optional methods/events: intersection with what we support.
*/
-export type WcNamespaceKey = "eip155" | "solana" | "bip122" | "hedera" | P2WcNamespaceKey;
+export type WcNamespaceKey = "eip155" | "solana" | "bip122" | "hedera" | "bch" | P2WcNamespaceKey;
export const WC_NAMESPACE_FAMILY: Record = {
eip155: "evm",
solana: "solana",
bip122: "bitcoin",
hedera: "hedera",
+ bch: "bitcoincash",
...P2_WC_NAMESPACE_FAMILY,
};
@@ -49,6 +51,8 @@ export const WC_SUPPORTED_METHODS: Record = {
bip122: ["getAccountAddresses", "signMessage", "signPsbt", "sendTransfer"],
// hashgraph/hedera-wallet-connect HederaJsonRpcMethod
hedera: HEDERA_METHODS,
+ // wc2-bch-bcr (https://github.com/mainnet-pat/wc2-bch-bcr#pairing): Cashonize, Paytaca, Zapit
+ bch: [BCH_WC.getAddresses, BCH_WC.signTransaction, BCH_WC.signMessage],
...P2_WC_SUPPORTED_METHODS,
};
@@ -58,9 +62,30 @@ export const WC_SUPPORTED_EVENTS: Record = {
bip122: ["bip122_addressesChanged"],
// hashgraph/hedera-wallet-connect HederaSessionEvent
hedera: ["accountsChanged", "chainChanged"],
+ bch: [...BCH_WC.events],
...P2_WC_SUPPORTED_EVENTS,
};
+/**
+ * Chain aliases: a WalletConnect chain id whose wallet network has another id. Only wc2-bch-bcr needs one: its chains
+ * are "bch:bitcoincash" / "bch:bchtest", the wallet's Bitcoin Cash networks are bip122 ids (chains-bitcoincash). Every
+ * other namespace's chain id IS the network id.
+ */
+export function networkIdForWcChain(chain: string): string {
+ return namespaceOf(chain) === "bch" ? (bchNetworkForWcChain(chain) ?? chain) : chain;
+}
+
+/**
+ * CAIP-10 session account for `address` on WC `chain`. wc2-bch-bcr wallets put the CashAddr after "bch:" (Cashonize
+ * src/stores/walletconnectStore.ts: `bch:${addr}` → "bch:bchtest:qq…"), i.e. the chain id plus the CashAddr payload,
+ * spelled for that chain's prefix.
+ */
+export function wcAccount(chain: string, address: string): string {
+ if (namespaceOf(chain) !== "bch") return `${chain}:${address}`;
+ const spelled = bchAddressOn(address, networkIdForWcChain(chain)) ?? address;
+ return `${chain}:${spelled.slice(spelled.indexOf(":") + 1)}`;
+}
+
export interface ProposalNamespace {
chains?: string[];
methods: string[];
@@ -134,8 +159,9 @@ export function mapProposalNamespaces(proposal: ProposalLike, input: NamespaceMa
const registry = new Set(input.networks.map((n) => n.id));
const servable = (chain: string, key: WcNamespaceKey): string[] | undefined => {
- if (!registry.has(chain)) return undefined;
- const addrs = input.addressesFor(chain, WC_NAMESPACE_FAMILY[key]);
+ const networkId = networkIdForWcChain(chain);
+ if (!registry.has(networkId)) return undefined;
+ const addrs = input.addressesFor(networkId, WC_NAMESPACE_FAMILY[key]);
return addrs.length > 0 ? addrs : undefined;
};
@@ -179,7 +205,7 @@ export function mapProposalNamespaces(proposal: ProposalLike, input: NamespaceMa
continue;
}
chains.push(chain);
- for (const a of addrs) accounts.push(`${chain}:${a}`);
+ for (const a of addrs) accounts.push(wcAccount(chain, a));
}
if (chains.length === 0) continue;
diff --git a/packages/1mask/src/walletconnect/wallet.ts b/packages/1mask/src/walletconnect/wallet.ts
index 7c12156e..f1064d9d 100644
--- a/packages/1mask/src/walletconnect/wallet.ts
+++ b/packages/1mask/src/walletconnect/wallet.ts
@@ -8,6 +8,8 @@ import {
isServedMethod,
mapProposalNamespaces,
namespaceOf,
+ networkIdForWcChain,
+ wcAccount,
type NamespaceMapping,
type ProposalNamespace,
type SessionNamespace,
@@ -18,6 +20,7 @@ import { assessVerify, type Verification, type VerifyContextLike } from "./verif
import { namedAccounts, sessionHasAccount } from "./accounts.js";
import { CALLS_METHODS, CallsErrorCode, callsError, chainCapabilities, isCallsMethod, parseSendCalls, type CallsHost, type Hex, type SendCallsParams } from "../shared/calls.js";
import { rpcError } from "../shared/errors.js";
+import { BCH_WC, bchWcChainFor } from "../shared/bitcoincash.js";
/* ------------------------------------------------------------------ SDK error codes */
@@ -220,7 +223,9 @@ export async function createWalletConnectWallet(opts: WalletConnectWalletOptions
metadata: opts.metadata,
...(opts.coreOptions ? { coreOptions: opts.coreOptions } : {}),
});
- const familyFor = opts.familyForChain ?? defaultFamilyForChain;
+ const baseFamilyFor = opts.familyForChain ?? defaultFamilyForChain;
+ /** Chain aliases (wc2-bch-bcr): a Bitcoin Cash bip122 network id is "bitcoincash", never the bip122 namespace's "bitcoin". */
+ const familyFor = (chain: string): Family => (bchWcChainFor(chain) ? "bitcoincash" : baseFamilyFor(chain));
const newId = opts.newId ?? randomId;
const registry = new Set(opts.networks.map((n) => n.id));
const inflight = new Map();
@@ -229,7 +234,7 @@ export async function createWalletConnectWallet(opts: WalletConnectWalletOptions
const mapProposal: WalletConnectWallet["mapProposal"] = (p) =>
mapProposalNamespaces(p, {
networks: opts.networks,
- addressesFor: (chain) => opts.addressesFor(chain, familyFor(chain)),
+ addressesFor: (networkId) => opts.addressesFor(networkId, familyFor(networkId)),
...(extraMethods ? { extraMethods } : {}),
});
@@ -255,7 +260,7 @@ export async function createWalletConnectWallet(opts: WalletConnectWalletOptions
const approvedChains = Object.values(mapping.namespaces).flatMap((n) => n.chains);
// Audit WC-04: the connect screen names one network and address; say plainly when the app also gets the
// user's addresses of other kinds of account (other namespaces have other addresses).
- const names = [...new Set(approvedChains.map((c) => opts.networks.find((n) => n.id === c)?.name ?? c))];
+ const names = [...new Set(approvedChains.map((c) => opts.networks.find((n) => n.id === networkIdForWcChain(c))?.name ?? c))];
const shared: Warning[] =
Object.keys(mapping.namespaces).length > 1
? [{ level: "info", code: "network-matters", message: `This app gets your addresses on ${names.length} networks: ${names.join(", ")}.` }]
@@ -323,6 +328,10 @@ export async function createWalletConnectWallet(opts: WalletConnectWalletOptions
if (namespaceOf(chainId) === "near" && method === "near_getAccounts") {
return { result: sessionAccounts(session, chainId).map((accountId) => ({ accountId })) };
}
+ if (namespaceOf(chainId) === "bch" && method === BCH_WC.getAddresses) {
+ // wc2-bch-bcr: full CashAddrs ("bchtest:qq…"); the session account is "bch:bchtest:qq…".
+ return { result: sessionAccounts(session, chainId).map((payload) => `${chainId.slice("bch:".length)}:${payload}`) };
+ }
return undefined;
};
@@ -389,7 +398,7 @@ export async function createWalletConnectWallet(opts: WalletConnectWalletOptions
if (!session) throw { code: RpcErrorCode.Unauthorized, message: "Unknown session." };
const nsKey = namespaceOf(chainId);
const ns = session.namespaces[nsKey];
- if (!ns || !ns.chains.includes(chainId) || !registry.has(chainId)) {
+ if (!ns || !ns.chains.includes(chainId) || !registry.has(networkIdForWcChain(chainId))) {
throw { code: RpcErrorCode.ChainDisconnected, message: `Clip Wallet is not connected to ${chainId}.` };
}
if (!ns.methods.includes(request.method)) {
@@ -415,7 +424,7 @@ export async function createWalletConnectWallet(opts: WalletConnectWalletOptions
const verify = assessVerify(ev.verifyContext, peer.url, opts.isKnownScam);
let method = request.method;
let reqParams = request.params;
- let networkId = chainId;
+ let networkId = networkIdForWcChain(chainId);
if (opts.calls && nsKey === "eip155" && isCallsMethod(method)) {
const answered = await answerCalls(session, chainId, verify.origin, method, request.params);
if ("result" in answered) return void (await respond(topic, id, answered));
@@ -518,15 +527,17 @@ export async function createWalletConnectWallet(opts: WalletConnectWalletOptions
for (const s of Object.values(kit.getActiveSessions())) {
const namespaces: Record = {};
for (const [key, ns] of Object.entries(s.namespaces)) {
- const accounts = ns.chains.flatMap((c) => opts.addressesFor(c, familyFor(c)).map((a) => `${c}:${a}`));
+ const accounts = ns.chains.flatMap((c) => opts.addressesFor(networkIdForWcChain(c), familyFor(networkIdForWcChain(c))).map((a) => wcAccount(c, a)));
namespaces[key] = { ...ns, accounts };
}
await kit.updateSession({ topic: s.topic, namespaces });
for (const [key, ns] of Object.entries(namespaces)) {
- const event = key === "bip122" ? "bip122_addressesChanged" : "accountsChanged";
+ const event = key === "bip122" ? "bip122_addressesChanged" : key === "bch" ? "addressesChanged" : "accountsChanged";
if (!ns.events.includes(event)) continue;
for (const c of ns.chains) {
- const data = ns.accounts.filter((a) => a.startsWith(`${c}:`)).map((a) => (key === "eip155" ? a.slice(c.length + 1) : a));
+ const data = ns.accounts
+ .filter((a) => a.startsWith(`${c}:`))
+ .map((a) => (key === "eip155" ? a.slice(c.length + 1) : key === "bch" ? `${c.slice("bch:".length)}:${a.slice(c.length + 1)}` : a));
await kit.emitSessionEvent({ topic: s.topic, event: { name: event, data }, chainId: c });
}
}
diff --git a/packages/1mask/test/bitcoincash-wc.test.ts b/packages/1mask/test/bitcoincash-wc.test.ts
new file mode 100644
index 00000000..b2bd74ff
--- /dev/null
+++ b/packages/1mask/test/bitcoincash-wc.test.ts
@@ -0,0 +1,46 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { createBitcoinCashDispatcher } from "../src/background/bitcoincash.js";
+import { rpcError } from "../src/shared/errors.js";
+import { bchAddressOn, bchNetworkForWcChain, bchWcChainFor } from "../src/shared/bitcoincash.js";
+
+/** The public "abandon … about" BCH account 0 (chains-bitcoincash test/signatures.ts). */
+const MAIN = "bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6";
+const TEST = "bchtest:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnqseeszx8x";
+const asset = (networkId: string) => ({ key: "bch", symbol: "BCH", name: "Bitcoin Cash", decimals: 8, networkId });
+const CHIPNET: Network = { id: "bip122:00000000040ba9641ba98a37b2e5ceea", family: "bitcoincash", name: "Chipnet", nativeAsset: asset("x"), testnet: true, rpcUrls: [], explorerUrl: "" };
+const MAINNET: Network = { id: "bip122:000000000000000000651ef99cb9fcbe", family: "bitcoincash", name: "Bitcoin Cash", nativeAsset: asset("y"), testnet: false, rpcUrls: [], explorerUrl: "" };
+
+describe("Bitcoin Cash over WalletConnect (wc2-bch-bcr)", () => {
+ it("re-spells CashAddr per network and maps the spec's chain ids", () => {
+ expect(bchAddressOn(MAIN, CHIPNET.id)).toBe(TEST);
+ expect(bchAddressOn(TEST, MAINNET.id)).toBe(MAIN);
+ expect(bchAddressOn(MAIN.slice(0, -1) + "7", CHIPNET.id)).toBeUndefined();
+ expect(bchNetworkForWcChain("bch:bchtest")).toBe(CHIPNET.id);
+ expect(bchNetworkForWcChain("bch:bitcoincash")).toBe(MAINNET.id);
+ expect(bchNetworkForWcChain("bch:bchreg")).toBeUndefined();
+ expect(bchWcChainFor(CHIPNET.id)).toBe("bch:bchtest");
+ });
+
+ it("answers bch_getAddresses for the session's network and passes signing through after permission", async () => {
+ let permitted = false;
+ const approved: DappRequest[] = [];
+ const d = createBitcoinCashDispatcher({
+ permitted: async () => permitted,
+ requirePermission: async () => {
+ if (!permitted) throw rpcError.unauthorized();
+ },
+ accounts: async () => [{ address: MAIN }],
+ approve: async (req) => (approved.push(req), { signedTransaction: "00", signedTransactionHash: "11" }),
+ makeReq: (origin: string, family: Family, net: Network, method: string, params: unknown) => ({ id: "1", origin, via: "walletconnect" as const, family, networkId: net.id, method, params }),
+ requireNetwork: (_f, _o, chain) => (chain === MAINNET.id ? MAINNET : CHIPNET),
+ });
+ await expect(d.dispatch("https://app", "bitcoincash", "bch_getAddresses", {}, "bch:bchtest")).rejects.toMatchObject({ code: 4100 });
+ permitted = true;
+ expect(await d.dispatch("https://app", "bitcoincash", "bch_getAddresses", {}, "bch:bchtest")).toEqual([TEST]);
+ expect(await d.dispatch("https://app", "bitcoincash", "bch_getAddresses", {}, "bch:bitcoincash")).toEqual([MAIN]);
+ await d.dispatch("https://app", "bitcoincash", "bch_signMessage", { message: "hi" }, "bch:bchtest");
+ expect(approved[0]).toMatchObject({ family: "bitcoincash", networkId: CHIPNET.id, method: "bch_signMessage" });
+ await expect(d.dispatch("https://app", "bitcoincash", "bch_sendTransaction", {}, "bch:bchtest")).rejects.toMatchObject({ code: 4200 });
+ });
+});
diff --git a/packages/1mask/test/content.test.ts b/packages/1mask/test/content.test.ts
index 1b6cf3bd..0ca300a3 100644
--- a/packages/1mask/test/content.test.ts
+++ b/packages/1mask/test/content.test.ts
@@ -79,7 +79,7 @@ describe("content bridge security", () => {
it("rejects schema-invalid requests (bad family, missing method)", async () => {
const s = setup();
- s.win.postMessage(s.req({ family: "cosmos" }), "*");
+ s.win.postMessage(s.req({ family: "dogecoin" }), "*");
s.win.postMessage(s.req({ id: "r2", method: "" }), "*");
await vi.waitFor(() => expect(s.toPage.map((m) => m.error?.code)).toEqual([-32602, -32602]));
expect(s.received).toEqual([]);
diff --git a/packages/1mask/test/cosmos.test.ts b/packages/1mask/test/cosmos.test.ts
new file mode 100644
index 00000000..e38d6e00
--- /dev/null
+++ b/packages/1mask/test/cosmos.test.ts
@@ -0,0 +1,233 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { COSMOS_METHODS_ALLOWED, cosmosInjectedAllowlist, createCosmosDispatcher, type CosmosRouterInternals } from "../src/background/cosmos.js";
+import { installCosmosProvider } from "../src/inpage/cosmos.js";
+import type { InpageTransport } from "../src/inpage/transport.js";
+import { DEFAULT_IDENTITY } from "../src/shared/config.js";
+import { COSMOS_CHAIN_FACTS, COSMOS_INJECTED, chainInfoWithoutEndpoints } from "../src/shared/cosmos.js";
+import { bech32Decode, bech32Encode, hexOf } from "../src/shared/cosmos-bech32.js";
+import { fromRpcErrorShape, toRpcErrorShape } from "../src/shared/errors.js";
+import type { ExposedAccount } from "../src/shared/protocol.js";
+import { newWindow } from "./helpers.js";
+
+/**
+ * The Keplr-compatible provider (window.clipwallet.cosmos) over the background dispatcher, with the router's
+ * internals faked: permissions, accounts, approvals and reads are recorded. Account values are the public
+ * "abandon … about" account 0 of each family (addresses and public keys only).
+ */
+
+const ORIGIN = "https://app.osmosis.example";
+const asset = (networkId: string) => ({ key: "x", symbol: "X", name: "X", decimals: 6, networkId });
+const NETS: Network[] = COSMOS_CHAIN_FACTS.map((c) => ({ id: `cosmos:${c.chainId}`, family: c.family, name: c.chainName, nativeAsset: asset(`cosmos:${c.chainId}`), testnet: c.testnet, rpcUrls: [], explorerUrl: "" }));
+
+const ACCOUNTS: Partial> = {
+ cosmos: [{ address: "cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4", publicKey: "024f4e2ad99c34d60b9ba6283c9431a8418af8673212961f97a77b6377fcd05b62" }],
+ provenance: [{ address: "pb1fpwxdyscnu2dzjwghpstx7l9xtvsap48l5gnrm", publicKey: "02b46c78777309c65fb5c2308574fb9a76853d7c0e91d1a1bd1f79f6ccb64ef6da" }],
+ thorchain: [{ address: "thor1gm00vwsfcp48enm4uv9e5dhm37jtd0ye27wrx0", publicKey: "02205c476a22d5fe10b74489db9479d0e36e25a32da393a771fcf12380136a451f" }],
+ initia: [{ address: "init1npvwllfr9dqr8erajqqr6s0vxnk2ak558xjc5c", publicKey: "0237b0bb7a8288d38ed49a524b5dc98cff3eb5ca824c9f9dc0dfdb3d9cd600f299" }],
+};
+const OSMO = "osmo19rl4cm2hmr8afy4kldpxz3fka4jguq0a5m7df8";
+const OSMO_BOB = "osmo1jrkmdcwgq94uaamx6zax2luewlhf7u4k5r4pqs";
+
+function harness(opts: { read?: boolean; result?: (r: DappRequest) => unknown } = {}) {
+ const granted = new Set();
+ const approved: DappRequest[] = [];
+ const reads: DappRequest[] = [];
+ const connects: { family: Family; params: unknown }[] = [];
+ const internals: CosmosRouterInternals = {
+ permitted: async (o, f) => granted.has(`${o}|${f}`),
+ requirePermission: async (o, f) => {
+ if (!granted.has(`${o}|${f}`)) throw Object.assign(new Error("Connect Clip Wallet to this site first."), { code: 4100 });
+ },
+ accounts: async (_o, f) => ACCOUNTS[f] ?? [],
+ connect: async (o, f, _net, _m, params) => {
+ connects.push({ family: f, params });
+ granted.add(`${o}|${f}`);
+ return ACCOUNTS[f] ?? [];
+ },
+ approve: async (req) => {
+ approved.push(req);
+ return opts.result ? opts.result(req) : null;
+ },
+ makeReq: (origin, family, net, method, params) => ({ id: String(approved.length + reads.length), origin, via: "injected", family, networkId: net.id, method, params }),
+ requireNetwork: (family, _o, chain) => {
+ const n = NETS.find((x) => x.family === family && x.id === chain);
+ if (!n) throw Object.assign(new Error(`Clip Wallet does not support ${chain}.`), { code: 4901 });
+ return n;
+ },
+ revoke: async (o, f) => void granted.delete(`${o}|${f}`),
+ ...(opts.read
+ ? {
+ read: async (req: DappRequest) => {
+ reads.push(req);
+ return req.method === COSMOS_INJECTED.sendTx ? { txhash: "AABBCC" } : true;
+ },
+ }
+ : {}),
+ };
+ const dispatcher = createCosmosDispatcher(internals);
+ const listeners = new Set<(family: Family, event: "accountsChanged" | "disconnect", data: unknown) => void>();
+ const transport: InpageTransport = {
+ request: async (family, method, params, chain) => {
+ try {
+ // JSON round trip, as across postMessage and the runtime port.
+ const p = params === undefined ? undefined : JSON.parse(JSON.stringify(params));
+ return JSON.parse(JSON.stringify((await dispatcher.dispatch(ORIGIN, family, method, p, chain)) ?? null));
+ } catch (e) {
+ throw fromRpcErrorShape(toRpcErrorShape(e));
+ }
+ },
+ onEvent: (l) => (listeners.add(l as never), () => listeners.delete(l as never)),
+ destroy: () => undefined,
+ };
+ const win = newWindow();
+ const { provider, stop } = installCosmosProvider(win, DEFAULT_IDENTITY, NETS, transport);
+ const emit = (family: Family, event: "accountsChanged" | "disconnect", data: unknown) => listeners.forEach((l) => l(family, event, data));
+ return { provider, stop, win, approved, reads, connects, granted, emit, dispatcher };
+}
+
+describe("window.clipwallet.cosmos (Keplr API)", () => {
+ it("is exposed under Clip's own global, never window.keplr", () => {
+ const h = harness();
+ const w = h.win as unknown as Record | undefined>;
+ expect(w.clipwallet!.cosmos).toBe(h.provider);
+ expect(w.keplr).toBeUndefined();
+ expect((w.clipwallet!.info as { name: string }).name).toBe("Clip Wallet");
+ expect(h.provider.mode).toBe("extension");
+ h.stop();
+ expect(w.clipwallet!.cosmos).toBeUndefined();
+ });
+
+ it("enable prompts once per key family; getKey spells the account per chain", async () => {
+ const h = harness();
+ await expect(h.provider.getKey("osmo-test-5")).rejects.toMatchObject({ code: 4100 });
+ await h.provider.enable(["osmo-test-5", "dydx-testnet-4", "initiation-2"]);
+ expect(h.connects).toEqual([
+ { family: "cosmos", params: { chainIds: ["osmo-test-5", "dydx-testnet-4"] } },
+ { family: "initia", params: { chainIds: ["initiation-2"] } },
+ ]);
+ await h.provider.enable("osmo-test-5");
+ expect(h.connects.length).toBe(2);
+
+ const osmo = await h.provider.getKey("osmo-test-5");
+ expect(osmo).toMatchObject({ name: "Clip Wallet", algo: "secp256k1", bech32Address: OSMO, isNanoLedger: false, isKeystone: false });
+ expect(hexOf(osmo.pubKey)).toBe(ACCOUNTS.cosmos![0]!.publicKey);
+ expect(hexOf(osmo.address)).toBe(hexOf(bech32Decode(OSMO)!.data));
+ expect(osmo.ethereumHexAddress).toBe(`0x${hexOf(osmo.address)}`);
+ expect((await h.provider.getKey("dydx-testnet-4")).bech32Address).toBe("dydx19rl4cm2hmr8afy4kldpxz3fka4jguq0a4erelz");
+ const init = await h.provider.getKey("initiation-2");
+ expect(init).toMatchObject({ algo: "ethsecp256k1", bech32Address: "init1npvwllfr9dqr8erajqqr6s0vxnk2ak558xjc5c" });
+ expect(init.ethereumHexAddress).toBe("0x9858effd232b4033e47d90003d41ec34ecaeda94");
+ // Provenance isn't connected yet.
+ await expect(h.provider.getKey("pio-testnet-1")).rejects.toMatchObject({ code: 4100 });
+ });
+
+ it("refuses chains Clip doesn't ship, in plain words", async () => {
+ const h = harness();
+ await expect(h.provider.enable("cosmoshub-4")).rejects.toThrow("There is no chain info for cosmoshub-4");
+ await expect(h.provider.enable([])).rejects.toThrow("chain id not set");
+ await expect(h.provider.experimentalSuggestChain({ chainId: "evil-1" })).rejects.toThrow("Clip Wallet doesn't support evil-1 and can't add chains from websites.");
+ await expect(h.provider.experimentalSuggestChain({ chainId: "osmo-test-5" })).resolves.toBeUndefined();
+ const infos = await h.provider.getChainInfosWithoutEndpoints();
+ expect(infos.map((i) => i.chainId)).toEqual(COSMOS_CHAIN_FACTS.map((c) => c.chainId));
+ expect(infos[0]).toMatchObject({ rest: undefined, rpc: undefined, bip44: { coinType: 118 }, bech32Config: { bech32PrefixAccAddr: "osmo", bech32PrefixValAddr: "osmovaloper" } });
+ expect(chainInfoWithoutEndpoints("thorchain-1")!.stakeCurrency).toBeUndefined();
+ });
+
+ it("offline signer: getAccounts, signDirect through an approval, account number type kept", async () => {
+ const h = harness({
+ result: (r) => {
+ const p = r.params as { signDoc: unknown };
+ return { signed: p.signDoc, signature: { pub_key: { type: "tendermint/PubKeySecp256k1", value: "AA==" }, signature: "c2ln" } };
+ },
+ });
+ await h.provider.enable("osmo-test-5");
+ const signer = h.provider.getOfflineSigner("osmo-test-5");
+ const [acc] = await signer.getAccounts();
+ expect(acc).toMatchObject({ address: OSMO, algo: "secp256k1" });
+ const doc = { bodyBytes: new Uint8Array([1, 2]), authInfoBytes: new Uint8Array([3]), chainId: "osmo-test-5", accountNumber: 4242n };
+ const res = await signer.signDirect(OSMO, doc);
+ expect(res.signed.bodyBytes).toEqual(new Uint8Array([1, 2]));
+ expect(res.signed.accountNumber).toBe(4242n);
+ expect(res.signature.signature).toBe("c2ln");
+ expect(h.approved.at(-1)).toMatchObject({
+ family: "cosmos",
+ networkId: "cosmos:osmo-test-5",
+ method: "cosmos_signDirect",
+ origin: ORIGIN,
+ params: { signerAddress: OSMO, signDoc: { bodyBytes: "AQI=", authInfoBytes: "Aw==", chainId: "osmo-test-5", accountNumber: "4242" } },
+ });
+ await expect(signer.signDirect(OSMO, { ...doc, chainId: "osmosis-1" })).rejects.toThrow("Unmatched chain id with the offline signer");
+ await expect(signer.signDirect(OSMO_BOB, doc)).rejects.toThrow("Unknown signer address");
+ const amino = h.provider.getOfflineSignerOnlyAmino("osmo-test-5");
+ expect("signDirect" in amino).toBe(false);
+ const auto = await h.provider.getOfflineSignerAuto("osmo-test-5");
+ expect("signDirect" in auto).toBe(true);
+ });
+
+ it("the background checks the signer and the sign doc's chain itself", async () => {
+ const h = harness();
+ await h.provider.enable("osmo-test-5");
+ const wire = { bodyBytes: "AQI=", authInfoBytes: "Aw==", chainId: "osmo-test-5", accountNumber: "1" };
+ const call = (method: string, params: unknown, chain = "cosmos:osmo-test-5") => h.dispatcher.dispatch(ORIGIN, "cosmos", method, params, chain);
+ await expect(call("cosmos_signDirect", { signerAddress: OSMO_BOB, signDoc: wire })).rejects.toMatchObject({ code: 4100, message: "Signer mismatched" });
+ await expect(call("cosmos_signDirect", { signerAddress: "dydx19rl4cm2hmr8afy4kldpxz3fka4jguq0a4erelz", signDoc: wire })).rejects.toMatchObject({ code: 4100 });
+ await expect(call("cosmos_signDirect", { signerAddress: OSMO, signDoc: { ...wire, chainId: "dydx-testnet-4" } })).rejects.toMatchObject({ code: -32602 });
+ await expect(call("cosmos_signAmino", { signerAddress: OSMO, signDoc: { chain_id: "osmosis-1" } })).rejects.toMatchObject({ code: -32602 });
+ await expect(call("cosmos_signArbitrary", { signer: OSMO, data: "" })).rejects.toMatchObject({ code: -32602 });
+ // Another family's network can't be named through this family.
+ await expect(call("cosmos_signDirect", { signerAddress: OSMO, signDoc: wire }, "cosmos:initiation-2")).rejects.toMatchObject({ code: 4901 });
+ await expect(call("cosmos_signEverything", {})).rejects.toMatchObject({ code: 4200 });
+ expect(h.approved).toEqual([]);
+ // Same 20 bytes, this chain's prefix: allowed.
+ await call("cosmos_signDirect", { signerAddress: OSMO, signDoc: wire });
+ expect(h.approved.length).toBe(1);
+ });
+
+ it("signAmino, signArbitrary (ADR-36 string → UTF-8 → base64), verifyArbitrary and sendTx", async () => {
+ const h = harness({ read: true, result: (r) => (r.method === "cosmos_signArbitrary" ? { pub_key: { type: "tendermint/PubKeySecp256k1", value: "AA==" }, signature: "c2ln" } : { signed: (r.params as { signDoc: unknown }).signDoc, signature: {} }) });
+ await h.provider.enable("osmo-test-5");
+ const doc = { chain_id: "osmo-test-5", account_number: "1", sequence: "0", fee: { amount: [], gas: "1" }, msgs: [], memo: "" };
+ expect((await h.provider.signAmino("osmo-test-5", OSMO, doc)).signed).toEqual(doc);
+ expect(await h.provider.signArbitrary("osmo-test-5", OSMO, "héllo")).toMatchObject({ signature: "c2ln" });
+ expect(h.approved.at(-1)).toMatchObject({ method: "cosmos_signArbitrary", params: { signer: OSMO, data: btoa(String.fromCharCode(...new TextEncoder().encode("héllo"))), isString: true } });
+ expect(await h.provider.verifyArbitrary("osmo-test-5", OSMO, "x", { pub_key: { type: "t", value: "" }, signature: "" })).toBe(true);
+ expect(await h.provider.sendTx("osmo-test-5", new Uint8Array([1, 2, 3]), "sync")).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc]));
+ expect(h.reads.map((r) => r.method)).toEqual(["cosmos_verifyArbitrary", "cosmos_sendTx"]);
+ await expect(h.dispatcher.dispatch(ORIGIN, "cosmos", "cosmos_sendTx", { tx: "AQ==", mode: "block!" }, "cosmos:osmo-test-5")).rejects.toMatchObject({ code: -32602 });
+ });
+
+ it("without a read hook sendTx is unsupported (4200), not approved", async () => {
+ const h = harness();
+ await h.provider.enable("osmo-test-5");
+ await expect(h.provider.sendTx("osmo-test-5", new Uint8Array([1]), "sync")).rejects.toMatchObject({ code: 4200 });
+ expect(h.approved).toEqual([]);
+ });
+
+ it("account changes fire keystorechange and the namespaced window event; disable forgets the site", async () => {
+ const h = harness();
+ await h.provider.enable(["osmo-test-5", "pio-testnet-1"]);
+ let n = 0;
+ let w = 0;
+ h.provider.on("keystorechange", () => n++);
+ h.win.addEventListener("clipwallet_keystorechange", () => w++);
+ h.emit("cosmos", "accountsChanged", []);
+ h.emit("evm", "accountsChanged", []);
+ expect(n).toBe(1);
+ expect(w).toBe(1);
+ await h.provider.disable("pio-testnet-1");
+ expect([...h.granted]).toEqual([`${ORIGIN}|cosmos`]);
+ await h.provider.disable();
+ expect([...h.granted]).toEqual([]);
+ });
+
+ it("allowlist and table", () => {
+ expect([...cosmosInjectedAllowlist("initia")]).toEqual(Object.values(COSMOS_METHODS_ALLOWED).flat());
+ expect(cosmosInjectedAllowlist("evm").size).toBe(0);
+ for (const c of COSMOS_CHAIN_FACTS) {
+ const data = new Uint8Array(20).fill(7);
+ expect(bech32Decode(bech32Encode(c.prefix, data))).toEqual({ prefix: c.prefix, data });
+ }
+ expect(bech32Decode(OSMO.slice(0, -1) + "q")).toBeNull();
+ });
+});
diff --git a/packages/1mask/test/fuel.test.ts b/packages/1mask/test/fuel.test.ts
new file mode 100644
index 00000000..58a9bc37
--- /dev/null
+++ b/packages/1mask/test/fuel.test.ts
@@ -0,0 +1,208 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { createFuelDispatcher, fuelInjectedAllowlist, type FuelRouterInternals } from "../src/background/fuel.js";
+import { ClipFuelConnector, FUEL_CONNECTOR_EVENT, fuelWireMessage, installFuelConnector } from "../src/inpage/fuel.js";
+import type { InpageTransport } from "../src/inpage/transport.js";
+import { DEFAULT_IDENTITY } from "../src/shared/config.js";
+import { ProviderRpcError, RpcErrorCode } from "../src/shared/errors.js";
+import { FUEL_INJECTED } from "../src/shared/fuel.js";
+import type { ExposedAccount, OneMaskEvent } from "../src/shared/protocol.js";
+
+const asset = (networkId: string) => ({ key: "eth", symbol: "ETH", name: "Ether", decimals: 9, networkId });
+const net = (id: string, url: string): Network => ({ id, family: "fuel", name: id, nativeAsset: asset(id), testnet: id === "fuel:0", rpcUrls: [url], explorerUrl: "" });
+const TESTNET = net("fuel:0", "https://testnet.fuel.network/v1/graphql");
+const MAINNET = net("fuel:9889", "https://mainnet.fuel.network/v1/graphql");
+const ME: ExposedAccount = { address: "0x806EC69A1bC1398877A7327c467EE09511B3f26DcfB55b305D8ea2cd2c285b89", publicKey: "026760754232b8ae531b05039f630cf2549027d47dfe6af603f7504616602dfcd2" };
+const ORIGIN = "https://app.example";
+
+/** A router stand-in: one origin, one permission flag, approvals recorded. */
+function fakeRouter(opts: { permitted?: boolean; reject?: boolean } = {}) {
+ let permitted = opts.permitted ?? false;
+ const selected = new Map();
+ const approved: DappRequest[] = [];
+ const events: { event: OneMaskEvent; data: unknown }[] = [];
+ const h: FuelRouterInternals = {
+ permitted: async () => permitted,
+ accounts: async () => (permitted ? [ME] : []),
+ connect: async () => {
+ if (opts.reject) throw new ProviderRpcError(RpcErrorCode.UserRejected, "The user rejected the request.");
+ permitted = true;
+ return [ME];
+ },
+ approve: async (r) => (approved.push(r), r.method === FUEL_INJECTED.sendTransaction ? "0xabc" : r.method === FUEL_INJECTED.signMessage ? "0xsig" : { signed: true }),
+ makeReq: (origin, family, n, method, params) => ({ id: "x", origin, via: "injected", family, networkId: n.id, method, params }),
+ selectedNetwork: (origin) => [TESTNET, MAINNET].find((n) => n.id === selected.get(origin)) ?? TESTNET,
+ setSelected: (origin, _f, id) => void selected.set(origin, id),
+ candidates: (f: Family) => (f === "fuel" ? [TESTNET, MAINNET] : []),
+ emit: (_o, _f, event, data) => void events.push({ event, data }),
+ revoke: async () => void (permitted = false),
+ };
+ return { h, approved, events, dispatcher: createFuelDispatcher(h) };
+}
+
+/** Page ↔ background without the content script: requests go straight to the dispatcher. */
+function directTransport(r: ReturnType) {
+ const listeners = new Set<(f: Family, e: OneMaskEvent, d: unknown) => void>();
+ const sent: { method: string; params: unknown }[] = [];
+ const t: InpageTransport & { push(e: OneMaskEvent, d: unknown): void; sent: typeof sent } = {
+ request: async (family, method, params) => {
+ expect(family).toBe("fuel");
+ sent.push({ method, params });
+ // Round-trip through JSON like the real transport (postMessage + runtime port).
+ const p = params === undefined ? undefined : JSON.parse(JSON.stringify(params));
+ return JSON.parse(JSON.stringify((await r.dispatcher.dispatch(ORIGIN, method, p)) ?? null));
+ },
+ onEvent: (l) => (listeners.add(l), () => listeners.delete(l)),
+ destroy: () => listeners.clear(),
+ push: (e, d) => listeners.forEach((l) => l("fuel", e, d)),
+ sent,
+ };
+ return t;
+}
+
+describe("Fuel dispatcher (background)", () => {
+ it("allowlists the connector's methods only", () => {
+ expect([...fuelInjectedAllowlist()].sort()).toEqual(
+ ["1mask_getAccounts", "fuel:connect", "fuel:currentNetwork", "fuel:disconnect", "fuel:networks", "fuel:selectNetwork", "fuel_sendTransaction", "fuel_signMessage", "fuel_signTransaction"].sort(),
+ );
+ });
+
+ it("reads accounts silently, connects once, and refuses signing before connect", async () => {
+ const r = fakeRouter();
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.accounts, undefined)).toEqual([]);
+ await expect(r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.signMessage, { address: ME.address, message: { text: "hi" } })).rejects.toMatchObject({ code: 4100 });
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.connect, {})).toEqual([ME]);
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.accounts, undefined)).toEqual([ME]);
+ expect(r.approved).toEqual([]);
+ });
+
+ it("sends signing requests for the connected account to approval, on the site's network", async () => {
+ const r = fakeRouter({ permitted: true });
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.sendTransaction, { address: ME.address.toLowerCase(), transaction: { type: 0 } })).toBe("0xabc");
+ expect(r.approved[0]).toMatchObject({ family: "fuel", networkId: "fuel:0", method: "fuel_sendTransaction", origin: ORIGIN });
+ await expect(r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.signTransaction, { address: `0x${"11".repeat(32)}`, transaction: { type: 0 } })).rejects.toMatchObject({ code: 4100 });
+ await expect(r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.signMessage, { address: ME.address })).rejects.toMatchObject({ code: -32602 });
+ await expect(r.dispatcher.dispatch(ORIGIN, "fuel_signTypedData", {})).rejects.toMatchObject({ code: 4200 });
+ });
+
+ it("lists and switches only the wallet's networks, telling the page", async () => {
+ const r = fakeRouter();
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.currentNetwork, undefined)).toEqual({ url: TESTNET.rpcUrls[0], chainId: 0 });
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.networks, undefined)).toEqual([
+ { url: TESTNET.rpcUrls[0], chainId: 0 },
+ { url: MAINNET.rpcUrls[0], chainId: 9889 },
+ ]);
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.selectNetwork, { chainId: 9889 })).toBe(true);
+ expect(r.events).toEqual([{ event: "chainChanged", data: { url: MAINNET.rpcUrls[0], chainId: 9889 } }]);
+ expect(await r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.selectNetwork, { url: "https://mainnet.fuel.network/v1/graphql/" })).toBe(true);
+ expect(r.events).toHaveLength(1);
+ await expect(r.dispatcher.dispatch(ORIGIN, FUEL_INJECTED.selectNetwork, { chainId: 1119889111 })).rejects.toMatchObject({ code: 4902 });
+ });
+});
+
+describe("Fuel connector (inpage)", () => {
+ it("announces itself with the FuelConnector event under the wallet's own identity", () => {
+ const seen: unknown[] = [];
+ const onAnnounce = (e: Event) => seen.push((e as CustomEvent).detail);
+ window.addEventListener(FUEL_CONNECTOR_EVENT, onAnnounce);
+ const identity = { ...DEFAULT_IDENTITY, name: "Kit Wallet", rdns: "com.example.kit" };
+ const { connector, announce, stop } = installFuelConnector(window, identity, directTransport(fakeRouter()), { reannounce: false });
+ expect(seen).toEqual([connector]);
+ expect(connector.name).toBe("Kit Wallet");
+ expect(connector.name).not.toMatch(/fuel wallet/i);
+ expect(connector.metadata.image).toBe(identity.icon);
+ expect((window as unknown as { clipwallet: { fuel: unknown } }).clipwallet.fuel).toBe(connector);
+ announce();
+ expect(seen).toHaveLength(2);
+ // Fuel writes these on the connector, so it must stay writable.
+ connector.installed = true;
+ (connector as unknown as { _latestUpdate: number })._latestUpdate = 1;
+ stop();
+ window.removeEventListener(FUEL_CONNECTOR_EVENT, onAnnounce);
+ });
+
+ it("connects, follows account and network events, and disconnects", async () => {
+ const r = fakeRouter();
+ const t = directTransport(r);
+ const c = new ClipFuelConnector(DEFAULT_IDENTITY, t);
+ const log: [string, unknown][] = [];
+ for (const e of ["accounts", "currentAccount", "connection", "currentNetwork"]) c.on(e, (d: unknown) => log.push([e, d]));
+ expect(await c.ping()).toBe(true);
+ expect(await c.isConnected()).toBe(false);
+ expect(await c.connect()).toBe(true);
+ expect(await c.accounts()).toEqual([ME.address]);
+ expect(await c.currentAccount()).toBe(ME.address);
+ expect(log).toEqual([
+ ["accounts", [ME.address]],
+ ["currentAccount", ME.address],
+ ["connection", true],
+ ]);
+ t.push("chainChanged", { url: MAINNET.rpcUrls[0], chainId: 9889 });
+ expect(log.at(-1)).toEqual(["currentNetwork", { url: MAINNET.rpcUrls[0], chainId: 9889 }]);
+ expect(await c.disconnect()).toBe(false);
+ expect(log.slice(-3)).toEqual([
+ ["accounts", []],
+ ["currentAccount", null],
+ ["connection", false],
+ ]);
+ expect(await c.isConnected()).toBe(false);
+ });
+
+ it("answers false when the person declines to connect", async () => {
+ const c = new ClipFuelConnector(DEFAULT_IDENTITY, directTransport(fakeRouter({ reject: true })));
+ expect(await c.connect()).toBe(false);
+ });
+
+ it("sends messages and transactions as plain JSON", async () => {
+ const r = fakeRouter({ permitted: true });
+ const t = directTransport(r);
+ const c = new ClipFuelConnector(DEFAULT_IDENTITY, t);
+ expect(await c.signMessage(ME.address, "hello")).toBe("0xsig");
+ await c.signMessage(ME.address, { personalSign: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) });
+ expect(r.approved.map((a) => (a.params as { message: unknown }).message)).toEqual([{ text: "hello" }, { personalSignHex: "0xdeadbeef" }]);
+
+ // A fuels-ts-like request instance: BN fields serialize through toJSON, functions and prototypes don't cross.
+ class Bn {
+ constructor(private v: number) {}
+ toJSON() {
+ return `0x${this.v.toString(16)}`;
+ }
+ }
+ class Req {
+ type = 0;
+ gasLimit = new Bn(64);
+ maxFee = new Bn(168);
+ inputs = [];
+ outputs = [];
+ witnesses = [];
+ helper() {
+ return 1;
+ }
+ }
+ const id = await c.sendTransaction(ME.address, new Req(), {
+ provider: { url: TESTNET.rpcUrls[0]! },
+ onBeforeSend: async (x) => Object.assign(x as object, { tip: new Bn(1) }),
+ });
+ expect(id).toBe("0xabc");
+ expect(r.approved.at(-1)!.params).toEqual({
+ address: ME.address,
+ transaction: { type: 0, gasLimit: "0x40", maxFee: "0xa8", inputs: [], outputs: [], witnesses: [], tip: "0x1" },
+ provider: { url: TESTNET.rpcUrls[0] },
+ });
+ expect(await c.signTransaction(ME.address, { type: 0 })).toEqual({ signed: true });
+ expect(r.approved.at(-1)!.method).toBe("fuel_signTransaction");
+ });
+
+ it("checks message shapes in the page and doesn't let sites add networks or assets", async () => {
+ expect(fuelWireMessage({ personalSign: "hi" })).toEqual({ personalSign: "hi" });
+ expect(() => fuelWireMessage(" ")).toThrow();
+ expect(() => fuelWireMessage({ nope: 1 })).toThrow();
+ const c = new ClipFuelConnector(DEFAULT_IDENTITY, directTransport(fakeRouter()));
+ await expect(c.addNetwork("https://evil.example/v1/graphql")).rejects.toMatchObject({ code: 4001 });
+ expect(await c.addAssets([{}])).toBe(false);
+ expect(await c.assets()).toEqual([]);
+ expect(await c.hasABI("0x00")).toBe(false);
+ expect(await c.selectNetwork({ chainId: 9889 })).toBe(true);
+ expect(await c.currentNetwork()).toEqual({ url: MAINNET.rpcUrls[0], chainId: 9889 });
+ });
+});
diff --git a/packages/1mask/test/multiversx.test.ts b/packages/1mask/test/multiversx.test.ts
new file mode 100644
index 00000000..3ef6149c
--- /dev/null
+++ b/packages/1mask/test/multiversx.test.ts
@@ -0,0 +1,228 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { createMemoryPermissionStore } from "../src/background/index.js";
+import { MULTIVERSX_CONNECT_METHODS, createMultiversXDispatcher, multiversxInjectedAllowlist } from "../src/background/multiversx.js";
+import { type MultiversXProviderEntry, installMultiversXProvider } from "../src/inpage/multiversx.js";
+import type { EventListener, InpageTransport } from "../src/inpage/transport.js";
+import { resolveIdentity } from "../src/shared/config.js";
+import { ProviderRpcError } from "../src/shared/errors.js";
+import { MULTIVERSX_INJECTED, multiversxNetworkIdOf, nativeAuthOrigin } from "../src/shared/multiversx-methods.js";
+import type { ExposedAccount, OneMaskEvent } from "../src/shared/protocol.js";
+import { newWindow } from "./helpers.js";
+
+const asset = (networkId: string) => ({ key: "egld", symbol: "EGLD", name: "MultiversX eGold", decimals: 18, networkId });
+const net = (id: string, name: string): Network => ({ id, family: "multiversx", name, nativeAsset: asset(id), testnet: id !== "mvx:1", rpcUrls: ["https://devnet-gateway.multiversx.com"], explorerUrl: "https://devnet-explorer.multiversx.com" });
+const DEVNET = net("mvx:D", "MultiversX Devnet");
+const MAINNET = net("mvx:1", "MultiversX");
+const NETWORKS: Network[] = [DEVNET, MAINNET];
+const ME = "erd1sqhjrtmsn5yjk6w85099p8v0ly0g8z9pxeqe5dvu5rlf2n7vq3vqytny9g";
+const BOB = "erd1sxmr0k8u6trd5c6eu6trzyapzux7090ykujmsng7pdx0m8k93n5s48kwzk";
+const ORIGIN = "https://dapp.example";
+const SIG = "ab".repeat(64);
+
+const b64url = (t: string) => btoa(t).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+const loginToken = (origin: string) => `${b64url(origin)}.${"cd".repeat(32)}.86400.${b64url("{}")}`;
+
+/** Router stand-in lending the same closures createOneMaskRouter lends the dispatchers. */
+function harness(handleImpl: (r: DappRequest) => unknown = () => ({ signature: SIG })) {
+ const permissions = createMemoryPermissionStore();
+ const handled: DappRequest[] = [];
+ const listeners = new Set();
+ const accounts: ExposedAccount[] = [{ address: ME, publicKey: "80".repeat(32) }];
+ const emit = (family: Family, event: OneMaskEvent, data?: unknown) => listeners.forEach((l) => l(family, event, data));
+ const d = createMultiversXDispatcher({
+ permitted: async (o, f) => !!(await permissions.has(o, f)),
+ requirePermission: async (o, f) => {
+ if (!(await permissions.has(o, f))) throw new ProviderRpcError(4100, "Connect first.");
+ },
+ accounts: async () => accounts,
+ approve: async (req) => {
+ handled.push(req);
+ return handleImpl(req);
+ },
+ connect: async (o, f, n, method, params) => {
+ handled.push({ id: "c", origin: o, via: "injected", family: f, networkId: n.id, method, params });
+ await permissions.grant(o, f);
+ emit(f, "accountsChanged", accounts);
+ return accounts;
+ },
+ makeReq: (o, f, n, method, params) => ({ id: String(handled.length), origin: o, via: "injected", family: f, networkId: n.id, method, params }),
+ requireNetwork: (_f, _o, chain) => {
+ if (chain === undefined) return DEVNET;
+ const n = NETWORKS.find((x) => x.id === chain);
+ if (!n) throw new ProviderRpcError(4901, "Not connected to that network.");
+ return n;
+ },
+ revoke: async (o, f) => {
+ await permissions.revoke(o, f);
+ emit(f, "accountsChanged", []);
+ emit(f, "disconnect");
+ },
+ });
+ const transport: InpageTransport & { calls: { method: string; params: unknown; chain?: string }[] } = {
+ calls: [],
+ async request(family, method, params, chain) {
+ transport.calls.push({ method, params, ...(chain !== undefined ? { chain } : {}) });
+ expect(family).toBe("multiversx");
+ // The page→background hop is JSON (ports serialise).
+ return JSON.parse(JSON.stringify((await d.dispatch(ORIGIN, method, params === undefined ? undefined : JSON.parse(JSON.stringify(params)), chain)) ?? null));
+ },
+ onEvent(l) {
+ listeners.add(l);
+ return () => listeners.delete(l);
+ },
+ destroy() {},
+ };
+ return { d, handled, permissions, transport };
+}
+
+/** A stand-in for an sdk-core Transaction (the provider only uses toPlainObject() and `signature`). */
+class FakeTransaction {
+ signature = new Uint8Array();
+ guardianSignature = new Uint8Array([9]);
+ constructor(readonly plain: Record) {}
+ toPlainObject() {
+ return { ...this.plain };
+ }
+}
+const plainTx = (over: Record = {}) => ({ nonce: 7, value: "1000000000000000000", receiver: BOB, sender: ME, gasPrice: 1000000000, gasLimit: 50000, chainID: "D", version: 2, ...over });
+
+describe("MultiversX background dispatcher", () => {
+ it("allows only the MultiversX wire methods", async () => {
+ expect([...multiversxInjectedAllowlist()].sort()).toEqual([MULTIVERSX_INJECTED.accounts, MULTIVERSX_INJECTED.connect, MULTIVERSX_INJECTED.disconnect, "mvx_signMessage", "mvx_signTransactions"].sort());
+ expect(MULTIVERSX_CONNECT_METHODS).toEqual(["mvx:connect"]);
+ const { d } = harness();
+ await expect(d.dispatch(ORIGIN, "mvx_signAndSendTransactions", {}, undefined)).rejects.toMatchObject({ code: 4200 });
+ });
+
+ it("needs a connection before signing; accounts are silent and empty until then", async () => {
+ const { d, handled } = harness();
+ expect(await d.dispatch(ORIGIN, MULTIVERSX_INJECTED.accounts, undefined, undefined)).toEqual([]);
+ await expect(d.dispatch(ORIGIN, "mvx_signMessage", { message: "hi", address: ME }, "mvx:D")).rejects.toMatchObject({ code: 4100 });
+ expect(handled).toHaveLength(0);
+ });
+
+ it("checks that every signer is this site's account", async () => {
+ const { d, handled } = harness();
+ await d.dispatch(ORIGIN, MULTIVERSX_INJECTED.connect, {}, "mvx:D");
+ await expect(d.dispatch(ORIGIN, "mvx_signMessage", { message: "hi", address: BOB }, "mvx:D")).rejects.toMatchObject({ code: 4100 });
+ await expect(d.dispatch(ORIGIN, "mvx_signTransactions", { transactions: [plainTx({ sender: BOB })] }, "mvx:D")).rejects.toMatchObject({ code: 4100 });
+ await expect(d.dispatch(ORIGIN, "mvx_signTransactions", { transactions: [] }, "mvx:D")).rejects.toMatchObject({ code: -32602 });
+ await expect(d.dispatch(ORIGIN, "mvx_signTransactions", { transactions: [plainTx()] }, "mvx:Z")).rejects.toMatchObject({ code: 4901 });
+ expect(handled.map((h) => h.method)).toEqual(["mvx:connect"]);
+ await d.dispatch(ORIGIN, "mvx_signTransactions", { transactions: [plainTx()], address: ME }, "mvx:D");
+ expect(handled.at(-1)).toMatchObject({ method: "mvx_signTransactions", networkId: "mvx:D", family: "multiversx" });
+ });
+
+ it("refuses a native-auth login token made for another site", async () => {
+ const { d, handled } = harness();
+ await d.dispatch(ORIGIN, MULTIVERSX_INJECTED.connect, {}, undefined);
+ expect(nativeAuthOrigin(loginToken("https://other.example"))).toBe("https://other.example");
+ await expect(d.dispatch(ORIGIN, "mvx_signMessage", { message: ME + loginToken("https://other.example"), address: ME }, undefined)).rejects.toMatchObject({ code: 4100 });
+ await d.dispatch(ORIGIN, "mvx_signMessage", { message: ME + loginToken(ORIGIN), address: ME }, undefined);
+ expect(handled.at(-1)?.method).toBe("mvx_signMessage");
+ });
+});
+
+describe("MultiversX provider on window.multiversx.providers (sdk-dapp custom provider hook)", () => {
+ const identity = resolveIdentity({ name: "Kit Wallet", rdns: "org.example.kit" });
+
+ it("adds its own entry without replacing the array or other wallets' entries", () => {
+ const win = newWindow();
+ const other = { name: "Other", type: "other", constructor: async () => ({}) };
+ const original = [other];
+ (win as unknown as { multiversx: unknown }).multiversx = { providers: original, keep: 1 };
+ const { transport } = harness();
+ const { entry, provider, stop } = installMultiversXProvider(win, identity, NETWORKS, transport, { globalKey: "kitwallet" });
+ const mvx = (win as unknown as { multiversx: { providers: MultiversXProviderEntry[]; keep: number } }).multiversx;
+ expect(mvx.providers).toBe(original);
+ expect(mvx.keep).toBe(1);
+ expect(mvx.providers.map((e) => e.type)).toEqual(["other", "kitwallet"]);
+ expect(entry).toMatchObject({ name: "Kit Wallet", type: "kitwallet", iconUrl: identity.icon });
+ expect(Object.isFrozen(entry)).toBe(true);
+ expect((win as unknown as { kitwallet: { multiversx: unknown } }).kitwallet.multiversx).toBe(provider);
+ // Installing twice doesn't duplicate (sdk-dapp dedupes by type too).
+ installMultiversXProvider(win, identity, NETWORKS, transport, { globalKey: "kitwallet" });
+ expect(mvx.providers.filter((e) => e.type === "kitwallet")).toHaveLength(1);
+ stop();
+ expect(mvx.providers.map((e) => e.type)).toEqual(["other"]);
+ });
+
+ it("creates the hook when the page has none, never as the DeFi Wallet", () => {
+ const win = newWindow();
+ const { transport } = harness();
+ const { entry } = installMultiversXProvider(win, resolveIdentity(), NETWORKS, transport);
+ expect(entry.type).toBe("clipwallet");
+ expect(entry.type).not.toBe("extension");
+ const w = win as unknown as Record;
+ expect(w.elrondWallet).toBeUndefined();
+ expect(w.multiversxWallet).toBeUndefined();
+ });
+
+ it("ProviderFactory-style use: construct, init, login with a native-auth token, sign transactions and a message", async () => {
+ const win = newWindow();
+ const { transport, handled } = harness((req) => {
+ if (req.method === "mvx_signTransactions") {
+ const txs = (req.params as { transactions: unknown[] }).transactions;
+ return { signatures: txs.map((_, i) => ({ signature: String(i + 1).repeat(128).slice(0, 128) })), transactions: txs };
+ }
+ return { signature: SIG, address: ME };
+ });
+ const { entry } = installMultiversXProvider(win, identity, NETWORKS, transport);
+ // sdk-dapp ProviderFactory.create: `await entry.constructor({ address, anchor })`, then `provider.init()`.
+ const p = await entry.constructor({ address: undefined });
+ expect(await p.init()).toBe(true);
+ expect(p.isInitialized() && !p.isConnected()).toBe(true);
+ expect(p.getType()).toBe("clipwallet");
+
+ const token = loginToken(ORIGIN);
+ const login = await p.login({ token });
+ expect(login).toEqual({ address: ME, signature: SIG });
+ expect(handled.map((h) => h.method)).toEqual(["mvx:connect", "mvx_signMessage"]);
+ // The native-auth message: address + token (sdk-native-auth-server `${address}${body}`).
+ expect(handled[1]!.params).toEqual({ message: `${ME}${token}`, address: ME });
+ expect(await p.getAddress()).toBe(ME);
+ expect(p.getAccount()).toEqual({ address: ME });
+
+ const t1 = new FakeTransaction(plainTx());
+ const t2 = new FakeTransaction(plainTx({ nonce: 8 }));
+ const signed = await p.signTransactions([t1, t2]);
+ expect(signed[0]).toBe(t1);
+ expect(Array.from(t1.signature.slice(0, 2))).toEqual([0x11, 0x11]);
+ expect(Array.from(t2.signature.slice(0, 2))).toEqual([0x22, 0x22]);
+ expect(t1.guardianSignature).toEqual(new Uint8Array([9]));
+ expect(handled.at(-1)).toMatchObject({ method: "mvx_signTransactions", networkId: "mvx:D", params: { transactions: [plainTx(), plainTx({ nonce: 8 })], address: ME } });
+ expect(await p.signTransaction(new FakeTransaction(plainTx({ chainID: "1" })))).toBeInstanceOf(FakeTransaction);
+ expect(handled.at(-1)?.networkId).toBe("mvx:1");
+ await expect(p.signTransactions([new FakeTransaction(plainTx()), new FakeTransaction(plainTx({ chainID: "1" }))])).rejects.toMatchObject({ code: -32602 });
+
+ const message = { data: new TextEncoder().encode("hello"), signer: "sdk-js" } as { data: Uint8Array; signature?: Uint8Array; signer: string };
+ const sm = await p.signMessage(message);
+ expect(sm).toBe(message);
+ expect(sm.signature).toHaveLength(64);
+ expect(sm.signer).toBe("Kit Wallet");
+ expect(handled.at(-1)?.params).toEqual({ message: "hello", address: ME });
+ await expect(p.signMessage({ data: Uint8Array.of(0xff, 0xfe) })).rejects.toMatchObject({ code: -32602 });
+
+ expect(await p.logout()).toBe(true);
+ expect(p.isConnected()).toBe(false);
+ await expect(p.signMessage({ data: new TextEncoder().encode("x") })).rejects.toMatchObject({ code: 4100 });
+ });
+
+ it("session restore: the constructor takes sdk-dapp's stored address; the background still checks it", async () => {
+ const win = newWindow();
+ const { transport } = harness();
+ const { entry } = installMultiversXProvider(win, identity, NETWORKS, transport);
+ const p = await entry.constructor({ address: BOB });
+ expect(await p.getAddress()).toBe(BOB);
+ // Not permitted yet → 4100 from the background, nothing signed.
+ await expect(p.signMessage({ data: new TextEncoder().encode("x") })).rejects.toMatchObject({ code: 4100 });
+ });
+
+ it("chain ids", () => {
+ expect(multiversxNetworkIdOf("D")).toBe("mvx:D");
+ expect(multiversxNetworkIdOf("1")).toBe("mvx:1");
+ expect(multiversxNetworkIdOf("")).toBeNull();
+ expect(nativeAuthOrigin("not.a.token")).toBeNull();
+ });
+});
diff --git a/packages/1mask/test/n87-account-events.test.ts b/packages/1mask/test/n87-account-events.test.ts
new file mode 100644
index 00000000..c7416035
--- /dev/null
+++ b/packages/1mask/test/n87-account-events.test.ts
@@ -0,0 +1,68 @@
+import type { Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { createMemoryPermissionStore, createOneMaskRouter, type RouterPort } from "../src/background/index.js";
+
+/** The public "abandon … about" accounts (chains-stacks / chains-bitcoincash test/signatures.ts), vault spelling. */
+const SP = "SPC5KHM41H6WHAST7MWWDD807YSPRQKJ69FSH54J";
+const ST = "STC5KHM41H6WHAST7MWWDD807YSPRQKJ68T330BQ";
+const BCH_MAIN = "bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6";
+const BCH_TEST = "bchtest:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnqseeszx8x";
+const ORIGIN = "https://dapp.example";
+
+const asset = (key: string, networkId: string) => ({ key, symbol: key, name: key, decimals: 6, networkId });
+const net = (id: string, family: Network["family"], testnet = true): Network => ({ id, family, name: id, nativeAsset: asset(family, id), testnet, rpcUrls: [], explorerUrl: "" });
+const NETWORKS: Network[] = [
+ net("stacks:2147483648", "stacks"),
+ net("stacks:1", "stacks", false),
+ net("bip122:00000000040ba9641ba98a37b2e5ceea", "bitcoincash"),
+ net("bip122:000000000000000000651ef99cb9fcbe", "bitcoincash", false),
+];
+
+function setup() {
+ const permissions = createMemoryPermissionStore();
+ const router = createOneMaskRouter({
+ networks: NETWORKS,
+ permissions,
+ accountsFor: (_o, f) => (f === "stacks" ? [{ address: SP, publicKey: "03d5" }] : f === "bitcoincash" ? [{ address: BCH_MAIN }] : []),
+ handle: async () => true,
+ });
+ const events: { family: string; event: string; data: unknown }[] = [];
+ const port: RouterPort = {
+ postMessage: (m: unknown) => {
+ const e = m as { type: string; family: string; event: string; data: unknown };
+ if (e.type === "event") events.push({ family: e.family, event: e.event, data: e.data });
+ },
+ onMessage: { addListener: () => {} },
+ onDisconnect: { addListener: () => {} },
+ } as unknown as RouterPort;
+ router.attachPort(port, { senderOrigin: ORIGIN });
+ return { router, permissions, events };
+}
+
+describe("accountsChanged spelling for Stacks and Bitcoin Cash", () => {
+ it("connect and notifyAccountsChanged carry the site's network spelling, not the vault's mainnet form", async () => {
+ const { router, permissions, events } = setup();
+ expect(await router.dispatch(ORIGIN, { family: "stacks", method: "stacks:connect", params: {} })).toEqual([{ address: ST, publicKey: "03d5" }]);
+ expect(events.at(-1)).toEqual({ family: "stacks", event: "accountsChanged", data: [{ address: ST, publicKey: "03d5" }] });
+
+ await permissions.grant(ORIGIN, "bitcoincash");
+ await router.notifyAccountsChanged();
+ expect(events.filter((e) => e.event === "accountsChanged").slice(-2)).toEqual([
+ { family: "stacks", event: "accountsChanged", data: [{ address: ST, publicKey: "03d5" }] },
+ { family: "bitcoincash", event: "accountsChanged", data: [{ address: BCH_TEST }] },
+ ]);
+ });
+
+ it("follows the network a site selected (mainnet → SP / bitcoincash:)", async () => {
+ const { router, permissions, events } = setup();
+ await router.dispatch(ORIGIN, { family: "stacks", method: "stacks:connect", params: {}, chain: "stacks:1" });
+ expect(events.at(-1)?.data).toEqual([{ address: SP, publicKey: "03d5" }]);
+ await router.notifyAccountsChanged("stacks");
+ expect(events.at(-1)?.data).toEqual([{ address: SP, publicKey: "03d5" }]);
+ await router.dispatch(ORIGIN, { family: "stacks", method: "stacks:disconnect" });
+ await permissions.grant(ORIGIN, "bitcoincash");
+ await router.notifyAccountsChanged("bitcoincash");
+ expect(events.at(-1)?.data).toEqual([{ address: BCH_TEST }]);
+
+ });
+});
diff --git a/packages/1mask/test/stacks-provider.test.ts b/packages/1mask/test/stacks-provider.test.ts
new file mode 100644
index 00000000..9274ee30
--- /dev/null
+++ b/packages/1mask/test/stacks-provider.test.ts
@@ -0,0 +1,150 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { createStacksDispatcher, stacksInjectedAllowlist } from "../src/background/stacks.js";
+import { DEFAULT_IDENTITY } from "../src/shared/config.js";
+import { rpcError } from "../src/shared/errors.js";
+import type { ExposedAccount } from "../src/shared/protocol.js";
+import { STACKS_INJECTED, stacksAddressOn, stacksChainHint } from "../src/shared/stacks.js";
+import { ClipStacksProvider, installStacksProvider, toSip030Error } from "../src/inpage/stacks.js";
+import type { InpageTransport } from "../src/inpage/transport.js";
+import { newWindow } from "./helpers.js";
+
+/** The public "abandon … about" Stacks account 0 (chains-stacks test/signatures.ts). */
+const SP = "SPC5KHM41H6WHAST7MWWDD807YSPRQKJ69FSH54J";
+const ST = "STC5KHM41H6WHAST7MWWDD807YSPRQKJ68T330BQ";
+const PUB = "03d5d038bce81b3965314dba54f636f093c7dbdd6617cded013a53474fbccb100c";
+const OTHER_ST = "ST3XHES5990FYDV5BHBZCJRFYFD2Z4X3FMEXRWMFR";
+
+const asset = (networkId: string) => ({ key: "stx", symbol: "STX", name: "Stacks", decimals: 6, networkId });
+const TESTNET: Network = { id: "stacks:2147483648", family: "stacks", name: "Stacks Testnet", nativeAsset: asset("stacks:2147483648"), testnet: true, rpcUrls: [], explorerUrl: "" };
+const MAINNET: Network = { id: "stacks:1", family: "stacks", name: "Stacks", nativeAsset: asset("stacks:1"), testnet: false, rpcUrls: [], explorerUrl: "" };
+
+function fakeRouter(opts: { permitted?: boolean; approve?: (r: DappRequest) => unknown } = {}) {
+ let permitted = opts.permitted ?? false;
+ const approved: DappRequest[] = [];
+ const accounts: ExposedAccount[] = [{ address: SP, publicKey: PUB }];
+ const r = {
+ permitted: async () => permitted,
+ requirePermission: async () => {
+ if (!permitted) throw rpcError.unauthorized();
+ },
+ accounts: async () => accounts,
+ connect: async () => {
+ permitted = true;
+ return accounts;
+ },
+ approve: async (req: DappRequest) => {
+ approved.push(req);
+ return opts.approve ? opts.approve(req) : { txid: "ab".repeat(32) };
+ },
+ makeReq: (origin: string, family: Family, net: Network, method: string, params: unknown): DappRequest => ({ id: "1", origin, via: "injected", family, networkId: net.id, method, params }),
+ requireNetwork: (_f: Family, _o: string, chain: string | undefined) => (chain === MAINNET.id ? MAINNET : TESTNET),
+ revoke: async () => {
+ permitted = false;
+ },
+ networks: () => [TESTNET, MAINNET],
+ };
+ return { r, approved, d: createStacksDispatcher(r) };
+}
+
+/** Inpage transport straight into the dispatcher (no content script), origin fixed. */
+function direct(d: ReturnType, origin = "https://app.example"): InpageTransport {
+ return {
+ request: (family, method, params, chain) => d.dispatch(origin, family, method, params, chain),
+ onEvent: () => () => {},
+ destroy: () => {},
+ };
+}
+
+describe("Stacks addresses per network", () => {
+ it("re-spells the mainnet SP address as ST on testnet (checksum verified)", async () => {
+ expect(await stacksAddressOn(SP, TESTNET.id)).toBe(ST);
+ expect(await stacksAddressOn(ST, MAINNET.id)).toBe(SP);
+ expect(await stacksAddressOn(SP.slice(0, -1) + "K", TESTNET.id)).toBeUndefined();
+ expect(await stacksAddressOn("0x1234", TESTNET.id)).toBeUndefined();
+ expect(await stacksAddressOn("SP000000000000000000002Q6VF78", TESTNET.id)).toBe("ST000000000000000000002AMW42H");
+ expect(stacksChainHint("testnet")).toBe(TESTNET.id);
+ expect(stacksChainHint("devnet")).toBeUndefined();
+ });
+});
+
+describe("background dispatcher", () => {
+ it("connects with the testnet spelling and lets signing through only after connect", async () => {
+ const { d, approved } = fakeRouter();
+ expect(await d.dispatch("https://app.example", "stacks", STACKS_INJECTED.accounts, undefined, undefined)).toEqual([]);
+ await expect(d.dispatch("https://app.example", "stacks", "stx_transferStx", { recipient: OTHER_ST, amount: "1" }, undefined)).rejects.toMatchObject({ code: 4100 });
+ expect(await d.dispatch("https://app.example", "stacks", STACKS_INJECTED.connect, {}, undefined)).toEqual([{ address: ST, publicKey: PUB }]);
+ await d.dispatch("https://app.example", "stacks", "stx_transferStx", { recipient: OTHER_ST, amount: "1" }, undefined);
+ expect(approved[0]).toMatchObject({ family: "stacks", networkId: TESTNET.id, method: "stx_transferStx" });
+ });
+
+ it("refuses a signer address that isn't connected, and unknown methods", async () => {
+ const { d } = fakeRouter({ permitted: true });
+ await expect(d.dispatch("https://app.example", "stacks", "stx_signMessage", { message: "hi", address: OTHER_ST }, undefined)).rejects.toMatchObject({ code: 4100 });
+ await d.dispatch("https://app.example", "stacks", "stx_signMessage", { message: "hi", address: ST }, undefined);
+ await expect(d.dispatch("https://app.example", "stacks", "stx_getAccounts", {}, undefined)).rejects.toMatchObject({ code: 4200 });
+ expect(stacksInjectedAllowlist().has("stx_callContract")).toBe(true);
+ expect(stacksInjectedAllowlist().has("stx_updateProfile")).toBe(false);
+ });
+
+ it("answers stx_getNetworks", async () => {
+ const { d } = fakeRouter({ permitted: true });
+ expect(await d.dispatch("https://app.example", "stacks", "stx_getNetworks", undefined, undefined)).toEqual({
+ active: "testnet",
+ networks: [
+ { id: "testnet", chainId: 2147483648, transactionVersion: 128 },
+ { id: "mainnet", chainId: 1, transactionVersion: 0 },
+ ],
+ });
+ });
+});
+
+describe("injected SIP-030 provider", () => {
+ it("registers on window.clipwallet.stacks and WBIP-004 without touching other wallets", () => {
+ const win = newWindow();
+ const leather = { request: () => "leather" };
+ const others = [{ id: "LeatherProvider", name: "Leather", icon: "data:," }];
+ Object.assign(win, { LeatherProvider: leather, wbip_providers: others });
+ const { provider, entry, stop } = installStacksProvider(win, DEFAULT_IDENTITY, [TESTNET], direct(fakeRouter().d));
+ const w = win as unknown as Record;
+ expect(w.wbip_providers).toBe(others);
+ expect(w.wbip_providers.map((p: { id: string }) => p.id)).toEqual(["LeatherProvider", "clipwallet.stacks"]);
+ expect(entry).toMatchObject({ name: "Clip Wallet", icon: DEFAULT_IDENTITY.icon });
+ expect(entry.methods).toContain("stx_signMessage");
+ // @stacks/connect-ui getProviderFromId
+ expect(entry.id.split(".").reduce((o: any, k) => o?.[k], win)).toBe(provider);
+ expect(w.LeatherProvider).toBe(leather);
+ expect(w.StacksProvider).toBeUndefined();
+ stop();
+ expect(w.wbip_providers.map((p: { id: string }) => p.id)).toEqual(["LeatherProvider"]);
+ });
+
+ it("creates the registry when none exists and uses a kit wallet's own identity", () => {
+ const win = newWindow();
+ const { entry } = installStacksProvider(win, { name: "Acme Wallet", icon: DEFAULT_IDENTITY.icon, rdns: "com.acme" }, [TESTNET], direct(fakeRouter().d), { globalKey: "acme" });
+ expect((win as unknown as { wbip_providers: unknown[] }).wbip_providers).toEqual([entry]);
+ expect(entry).toMatchObject({ id: "acme.stacks", name: "Acme Wallet" });
+ });
+
+ it("request() answers JSON-RPC responses the way @stacks/connect reads them", async () => {
+ const { d } = fakeRouter({ approve: (r) => (r.method === "stx_signMessage" ? { signature: "aa", publicKey: PUB } : { txid: "cd".repeat(32) }) });
+ const p = new ClipStacksProvider(DEFAULT_IDENTITY, [TESTNET], direct(d));
+ const seen: unknown[] = [];
+ const unlisten = p.listen("stx_accountChange", (a) => seen.push(a));
+ const res = await p.request("getAddresses");
+ expect(res).toMatchObject({ jsonrpc: "2.0", result: { addresses: [{ symbol: "STX", address: ST, publicKey: PUB }] } });
+ expect(seen).toEqual([[{ address: ST, publicKey: PUB }]]);
+ unlisten();
+ expect(await p.request("stx_signMessage", { message: "hello" })).toMatchObject({ result: { signature: "aa", publicKey: PUB } });
+ expect(await p.request("stx_transferStx", { recipient: OTHER_ST, amount: "1", network: "testnet" })).toMatchObject({ result: { txid: "cd".repeat(32) } });
+ await expect(p.request("stx_transferStx", { recipient: OTHER_ST, amount: "1", network: "devnet" })).rejects.toMatchObject({ jsonrpc: "2.0", error: { code: -32602 } });
+ await expect(p.request("stx_updateProfile", {})).rejects.toMatchObject({ error: { code: -32601 } });
+ });
+
+ it("maps rejections to SIP-030 codes", () => {
+ expect(toSip030Error(rpcError.userRejected()).code).toBe(-32000);
+ expect(toSip030Error(rpcError.unauthorized()).code).toBe(-32002);
+ expect(toSip030Error(rpcError.invalidParams("x")).code).toBe(-32602);
+ expect(toSip030Error(new Error("boom")).code).toBe(-32603);
+ });
+});
diff --git a/packages/1mask/test/tron.test.ts b/packages/1mask/test/tron.test.ts
new file mode 100644
index 00000000..ebb48551
--- /dev/null
+++ b/packages/1mask/test/tron.test.ts
@@ -0,0 +1,210 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { createMemoryPermissionStore } from "../src/background/index.js";
+import { createTronDispatcher, tronInjectedAllowlist } from "../src/background/tron.js";
+import { type TIP6963ProviderDetail, installTronProvider } from "../src/inpage/tron.js";
+import type { EventListener, InpageTransport } from "../src/inpage/transport.js";
+import { resolveIdentity } from "../src/shared/config.js";
+import { ProviderRpcError } from "../src/shared/errors.js";
+import type { ExposedAccount, OneMaskEvent } from "../src/shared/protocol.js";
+import { TRON_INJECTED, tronChainIdOf, tronNetworkIdOf } from "../src/shared/tron-methods.js";
+import { newWindow } from "./helpers.js";
+
+const asset = (networkId: string) => ({ key: "trx", symbol: "TRX", name: "TRON", decimals: 6, networkId });
+const net = (id: string, name: string, rpc: string): Network => ({ id, family: "tron", name, nativeAsset: asset(id), testnet: true, rpcUrls: [rpc], explorerUrl: "https://nile.tronscan.org" });
+const NILE = net("tron:0xcd8690dc", "TRON Nile Testnet", "https://nile.trongrid.io");
+const MAINNET = { ...net("tron:0x2b6653dc", "TRON", "https://api.trongrid.io"), testnet: false };
+const NETWORKS: Network[] = [NILE, MAINNET];
+const ME = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH";
+const PUB = "03ff21f8e64d3a3c0198edfbb7afdc79be959432e92e2f8a1984bb436a414b8edc";
+const ORIGIN = "https://dapp.example";
+
+/** Router stand-in lending the same closures createOneMaskRouter lends the dispatchers. */
+function harness(handleImpl: (r: DappRequest) => unknown = () => ({ ok: true })) {
+ const permissions = createMemoryPermissionStore();
+ const handled: DappRequest[] = [];
+ const listeners = new Set();
+ const accounts: ExposedAccount[] = [{ address: ME, publicKey: PUB }];
+ const emit = (family: Family, event: OneMaskEvent, data?: unknown) => listeners.forEach((l) => l(family, event, data));
+ const requireNetwork = (_f: Family, _o: string, chain: string | undefined) => {
+ if (chain === undefined) return NILE;
+ const n = NETWORKS.find((x) => x.id === chain);
+ if (!n) throw new ProviderRpcError(4901, "Not connected to that network.");
+ return n;
+ };
+ const d = createTronDispatcher({
+ permitted: async (o, f) => !!(await permissions.has(o, f)),
+ requirePermission: async (o, f) => {
+ if (!(await permissions.has(o, f))) throw new ProviderRpcError(4100, "Connect first.");
+ },
+ accounts: async () => accounts,
+ approve: async (req) => {
+ handled.push(req);
+ return handleImpl(req);
+ },
+ connect: async (o, f, n, method, params) => {
+ handled.push({ id: "c", origin: o, via: "injected", family: f, networkId: n.id, method, params });
+ await permissions.grant(o, f);
+ emit(f, "accountsChanged", accounts);
+ return accounts;
+ },
+ makeReq: (o, f, n, method, params) => ({ id: String(handled.length), origin: o, via: "injected", family: f, networkId: n.id, method, params }),
+ requireNetwork,
+ revoke: async (o, f) => {
+ await permissions.revoke(o, f);
+ emit(f, "accountsChanged", []);
+ emit(f, "disconnect");
+ },
+ });
+ const transport: InpageTransport & { calls: { method: string; params: unknown; chain?: string }[] } = {
+ calls: [],
+ async request(family, method, params, chain) {
+ transport.calls.push({ method, params, ...(chain !== undefined ? { chain } : {}) });
+ expect(family).toBe("tron");
+ // The page→background hop is JSON (ports serialise).
+ return JSON.parse(JSON.stringify((await d.dispatch(ORIGIN, method, params === undefined ? undefined : JSON.parse(JSON.stringify(params)), chain)) ?? null));
+ },
+ onEvent(l) {
+ listeners.add(l);
+ return () => listeners.delete(l);
+ },
+ destroy() {},
+ };
+ return { d, handled, permissions, transport };
+}
+
+describe("TRON background dispatcher", () => {
+ it("allows only the TRON wire methods", async () => {
+ expect([...tronInjectedAllowlist()].sort()).toEqual([TRON_INJECTED.accounts, TRON_INJECTED.connect, TRON_INJECTED.disconnect, "tron_signMessage", "tron_signTransaction"].sort());
+ const { d } = harness();
+ await expect(d.dispatch(ORIGIN, "tron_sendRawTransaction", {}, undefined)).rejects.toMatchObject({ code: 4200 });
+ await expect(d.dispatch(ORIGIN, "tron_signAndSendTransaction", {}, undefined)).rejects.toMatchObject({ code: 4200 });
+ });
+
+ it("reads accounts silently, connects through an approval, signs only for connected accounts", async () => {
+ const { d, handled } = harness();
+ expect(await d.dispatch(ORIGIN, TRON_INJECTED.accounts, undefined, undefined)).toEqual([]);
+ await expect(d.dispatch(ORIGIN, "tron_signMessage", { address: ME, message: "hi" }, NILE.id)).rejects.toMatchObject({ code: 4100 });
+ expect(await d.dispatch(ORIGIN, TRON_INJECTED.connect, {}, NILE.id)).toEqual([{ address: ME, publicKey: PUB }]);
+ expect(handled.map((h) => [h.method, h.networkId])).toEqual([[TRON_INJECTED.connect, NILE.id]]);
+ await expect(d.dispatch(ORIGIN, "tron_signMessage", { address: "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK", message: "hi" }, NILE.id)).rejects.toMatchObject({ code: 4100 });
+ await expect(d.dispatch(ORIGIN, "tron_signTransaction", { address: ME }, NILE.id)).rejects.toMatchObject({ code: -32602 });
+ await expect(d.dispatch(ORIGIN, "tron_signMessage", { address: ME, message: "hi" }, "tron:0x94a9059e")).rejects.toMatchObject({ code: 4901 });
+ await d.dispatch(ORIGIN, "tron_signTransaction", { address: ME, transaction: { raw_data_hex: "0a00" } }, MAINNET.id);
+ expect(handled.at(-1)).toMatchObject({ family: "tron", networkId: MAINNET.id, method: "tron_signTransaction", origin: ORIGIN });
+ expect(await d.dispatch(ORIGIN, TRON_INJECTED.disconnect, undefined, undefined)).toBeNull();
+ expect(await d.dispatch(ORIGIN, TRON_INJECTED.accounts, undefined, undefined)).toEqual([]);
+ });
+});
+
+describe("TRON injected provider (TIP-1193 + TIP-6963)", () => {
+ const install = (handleImpl?: (r: DappRequest) => unknown, opts = {}) => {
+ const win = newWindow();
+ const h = harness(handleImpl);
+ const seen: TIP6963ProviderDetail[] = [];
+ win.addEventListener("TIP6963:announceProvider", (e) => seen.push((e as CustomEvent).detail));
+ const identity = resolveIdentity({});
+ const r = installTronProvider(win, identity, NETWORKS, h.transport, opts);
+ return { win, h, seen, r, identity };
+ };
+
+ it("announces its own identity (TIP-6963) and stays off other wallets' globals", () => {
+ const { win, seen, r, identity } = install();
+ expect(seen).toHaveLength(1);
+ expect(seen[0]!.info).toMatchObject({ name: identity.name, icon: identity.icon, rdns: identity.rdns });
+ expect(seen[0]!.info.uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+ expect(Object.isFrozen(seen[0])).toBe(true);
+ expect(seen[0]!.provider).toBe(r.provider);
+ win.dispatchEvent(new Event("TIP6963:requestProvider"));
+ expect(seen).toHaveLength(2);
+ expect(seen[1]!.info.uuid).toBe(seen[0]!.info.uuid);
+ const w = win as unknown as Record;
+ expect((w.clipwallet as Record).tron).toBe(r.provider);
+ expect(w.tron).toBeUndefined();
+ expect(w.tronWeb).toBeUndefined();
+ expect(w.tronLink).toBeUndefined();
+ expect((r.provider as unknown as Record).isTronLink).toBeUndefined();
+ r.stop();
+ win.dispatchEvent(new Event("TIP6963:requestProvider"));
+ expect(seen).toHaveLength(2);
+ });
+
+ it("claims window.tron only when asked and nothing owns it", () => {
+ const a = install(undefined, { claimWindowTron: true });
+ expect((a.win as unknown as { tron?: unknown }).tron).toBe(a.r.provider);
+ const win = newWindow();
+ const other = { isTronLink: true };
+ (win as unknown as { tron: unknown }).tron = other;
+ const r = installTronProvider(win, resolveIdentity({}), NETWORKS, harness().transport, { claimWindowTron: true });
+ expect(r.claimedWindowTron).toBe(false);
+ expect((win as unknown as { tron: unknown }).tron).toBe(other);
+ });
+
+ it("eth_requestAccounts, eth_chainId, wallet_switchEthereumChain and the events", async () => {
+ const { r, h } = install();
+ const p = r.provider;
+ const events: [string, unknown][] = [];
+ for (const e of ["connect", "accountsChanged", "chainChanged", "disconnect"]) expect(p.on(e, (x: unknown) => events.push([e, x]))).toBe(p);
+ expect(await p.request({ method: "eth_chainId" })).toBe("0xcd8690dc");
+ expect(await p.request({ method: "eth_requestAccounts" })).toEqual([ME]);
+ expect(h.transport.calls.map((c) => [c.method, c.chain])).toEqual([
+ [TRON_INJECTED.accounts, NILE.id],
+ [TRON_INJECTED.connect, NILE.id],
+ ]);
+ expect(events).toEqual([
+ ["accountsChanged", [ME]],
+ ["connect", { chainId: "0xcd8690dc" }],
+ ]);
+ expect(await p.request({ method: "eth_requestAccounts" })).toEqual([ME]); // already connected: no second prompt
+ expect(h.handled.filter((x) => x.method === TRON_INJECTED.connect)).toHaveLength(1);
+
+ expect(await p.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0x2b6653dc" }] })).toBeNull();
+ expect(events.at(-1)).toEqual(["chainChanged", { chainId: "0x2b6653dc" }]);
+ expect(await p.request({ method: "eth_chainId" })).toBe("0x2b6653dc");
+ await expect(p.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0x94a9059e" }] })).rejects.toMatchObject({ code: 4902 });
+ await expect(p.request({ method: "wallet_switchEthereumChain", params: [{}] })).rejects.toMatchObject({ code: -32602 });
+ await expect(p.request({ method: "tron_requestAccounts" })).rejects.toMatchObject({ code: 4200 });
+ await expect(p.request({ method: "eth_sign", params: [] })).rejects.toMatchObject({ code: 4200 });
+
+ await p.disconnect();
+ expect(events.slice(-2)).toEqual([
+ ["accountsChanged", []],
+ ["disconnect", expect.objectContaining({ code: 4900 })],
+ ]);
+ });
+
+ it("signs through the TronWeb subset on the current chain", async () => {
+ const { r, h } = install((req) => (req.method === "tron_signMessage" ? { signature: "0xabc" } : { ...(req.params as { transaction: object }).transaction, signature: ["11"] }));
+ const tw = r.provider.tronWeb;
+ expect(tw.ready).toBe(false);
+ expect(tw.defaultAddress).toEqual({ base58: false, hex: false });
+ await expect(tw.trx.sign({ raw_data_hex: "0a00" })).rejects.toMatchObject({ code: 4100 });
+ await r.provider.request({ method: "eth_requestAccounts" });
+ expect(tw.ready).toBe(true);
+ expect(tw.defaultAddress).toEqual({ base58: ME, hex: "41c8599111f29c1e1e061265b4af93ea1f274ad78a" });
+ expect(tw.fullNode.host).toBe("https://nile.trongrid.io");
+
+ expect(await tw.trx.sign({ txID: "aa", raw_data_hex: "0a00" })).toEqual({ txID: "aa", raw_data_hex: "0a00", signature: ["11"] });
+ expect(h.handled.at(-1)).toMatchObject({ method: "tron_signTransaction", networkId: NILE.id, params: { address: ME, transaction: { txID: "aa", raw_data_hex: "0a00" } } });
+ expect(await tw.trx.signMessageV2("hello")).toBe("0xabc");
+ expect(h.handled.at(-1)).toMatchObject({ method: "tron_signMessage", params: { address: ME, message: "hello" } });
+ await tw.trx.signMessageV2(new Uint8Array([0, 255]));
+ expect(h.handled.at(-1)?.params).toEqual({ address: ME, message: "00ff", encoding: "hex" });
+
+ await expect(tw.trx.sign("deadbeef")).rejects.toMatchObject({ code: 4200 });
+ await expect(tw.trx.sign({ raw_data_hex: "0a00" }, "a".repeat(64))).rejects.toMatchObject({ code: -32602 });
+ await expect(tw.trx.multiSign()).rejects.toMatchObject({ code: 4200 });
+ await expect(tw.trx._signTypedData()).rejects.toMatchObject({ code: 4200 });
+ await r.provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0x2b6653dc" }] });
+ await tw.trx.signMessageV2("on mainnet");
+ expect(h.handled.at(-1)?.networkId).toBe(MAINNET.id);
+ });
+
+ it("maps chain ids both ways", () => {
+ expect(tronNetworkIdOf("0x2B6653DC")).toBe("tron:0x2b6653dc");
+ expect(tronNetworkIdOf("728126428")).toBe("tron:0x2b6653dc");
+ expect(tronNetworkIdOf("tron")).toBeNull();
+ expect(tronChainIdOf("tron:0xcd8690dc")).toBe("0xcd8690dc");
+ expect(tronChainIdOf("eip155:1")).toBeNull();
+ });
+});
diff --git a/packages/1mask/test/walletconnect-bch.test.ts b/packages/1mask/test/walletconnect-bch.test.ts
new file mode 100644
index 00000000..5f9e2fbd
--- /dev/null
+++ b/packages/1mask/test/walletconnect-bch.test.ts
@@ -0,0 +1,140 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import { describe, expect, it } from "vitest";
+import { createWalletConnectWallet, mapProposalNamespaces, type WalletKitLike, type WcSessionLike } from "../src/walletconnect/index.js";
+import { BTC_ADDR, NETWORKS, tick } from "./helpers.js";
+
+/**
+ * Bitcoin Cash over WalletConnect, wc2-bch-bcr (https://github.com/mainnet-pat/wc2-bch-bcr): namespace "bch", chains
+ * "bch:bitcoincash" / "bch:bchtest" aliased to the wallet's bip122 BCH networks, session accounts "bch:"
+ * as Cashonize builds them. The public "abandon … about" BCH account 0, vault (mainnet) spelling.
+ */
+const BCH_MAIN = "bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6";
+const BCH_TEST = "bchtest:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnqseeszx8x";
+const CHIPNET = "bip122:00000000040ba9641ba98a37b2e5ceea";
+const MAINNET = "bip122:000000000000000000651ef99cb9fcbe";
+const BTC_TESTNET = "bip122:000000000933ea01ad0ee984209779ba";
+
+const asset = (networkId: string) => ({ key: "bch", symbol: "BCH", name: "Bitcoin Cash", decimals: 8, networkId });
+const BCH_NETS: Network[] = [
+ { id: CHIPNET, family: "bitcoincash", name: "Bitcoin Cash Chipnet", nativeAsset: asset(CHIPNET), testnet: true, rpcUrls: [], explorerUrl: "" },
+ { id: MAINNET, family: "bitcoincash", name: "Bitcoin Cash", nativeAsset: asset(MAINNET), testnet: false, rpcUrls: [], explorerUrl: "" },
+];
+const networks = [...NETWORKS, ...BCH_NETS];
+const addressesFor = (_chain: string, family: Family): string[] => (family === "bitcoincash" ? [BCH_MAIN] : family === "bitcoin" ? [BTC_ADDR] : []);
+
+const BCH_METHODS = ["bch_getAddresses", "bch_signTransaction", "bch_signMessage"];
+const proposal = {
+ requiredNamespaces: { bch: { chains: ["bch:bchtest"], methods: BCH_METHODS, events: ["addressesChanged"] } },
+ optionalNamespaces: {
+ bch: { chains: ["bch:bitcoincash", "bch:bchreg"], methods: BCH_METHODS, events: ["addressesChanged"] },
+ bip122: { chains: [BTC_TESTNET], methods: ["signPsbt"], events: ["bip122_addressesChanged"] },
+ },
+};
+
+describe("wc2-bch-bcr namespace mapping", () => {
+ it("aliases bch:* chains to the BCH networks and spells session accounts per chain", () => {
+ const m = mapProposalNamespaces(proposal, { networks, addressesFor });
+ expect(m.ok).toBe(true);
+ if (!m.ok) return;
+ expect(m.namespaces.bch).toEqual({
+ chains: ["bch:bchtest", "bch:bitcoincash"],
+ accounts: [`bch:${BCH_TEST}`, `bch:${BCH_MAIN}`],
+ methods: BCH_METHODS,
+ events: ["addressesChanged"],
+ });
+ expect(m.unsupported.chains).toEqual(["bch:bchreg"]);
+ // the Bitcoin bip122 namespace is untouched
+ expect(m.namespaces.bip122).toEqual({ chains: [BTC_TESTNET], accounts: [`${BTC_TESTNET}:${BTC_ADDR}`], methods: ["signPsbt"], events: ["bip122_addressesChanged"] });
+ });
+
+ it("refuses bch chains the wallet doesn't have", () => {
+ const m = mapProposalNamespaces(proposal, { networks: NETWORKS, addressesFor });
+ expect(m).toMatchObject({ ok: false, reason: "UNSUPPORTED_CHAINS", unsupported: { chains: ["bch:bchtest"] } });
+ });
+});
+
+function fakeKit() {
+ const handlers = new Map void>();
+ const sessions: Record = {};
+ const calls: { name: string; args: any }[] = [];
+ const rec = (name: string) => async (args: any) => void calls.push({ name, args });
+ const kit: WalletKitLike = {
+ pair: rec("pair"),
+ approveSession: async (args) => (calls.push({ name: "approveSession", args }), { topic: "t1" }),
+ rejectSession: rec("rejectSession"),
+ respondSessionRequest: rec("respondSessionRequest"),
+ disconnectSession: rec("disconnectSession"),
+ getActiveSessions: () => sessions,
+ emitSessionEvent: rec("emitSessionEvent"),
+ updateSession: rec("updateSession"),
+ approveSessionAuthenticate: rec("approveSessionAuthenticate"),
+ rejectSessionAuthenticate: rec("rejectSessionAuthenticate"),
+ formatAuthMessage: () => "",
+ on: (e, l) => void handlers.set(e, l),
+ off: (e) => void handlers.delete(e),
+ };
+ return { kit, sessions, calls, fire: async (e: string, a: any) => (handlers.get(e)!(a), tick(5)) };
+}
+
+const peer = { name: "TapSwap", description: "", url: "https://tapswap.example", icons: [] };
+
+describe("wc2-bch-bcr sessions (fake WalletKit)", () => {
+ async function setup() {
+ const f = fakeKit();
+ const handled: DappRequest[] = [];
+ const summaries: { approvedChains: string[]; networks?: unknown }[] = [];
+ const w = await createWalletConnectWallet({
+ projectId: "test",
+ metadata: { name: "Clip Wallet", description: "", url: "https://clip.example", icons: [] },
+ networks,
+ addressesFor,
+ approveProposal: async (s) => (summaries.push(s as never), true),
+ handle: async (req) => (handled.push(req), { signedTransaction: "02", signedTransactionHash: "ab" }),
+ walletKitFactory: async () => f.kit,
+ });
+ await f.fire("session_proposal", { id: 3, params: { id: 3, proposer: { metadata: peer }, ...proposal } });
+ const approved = f.calls.find((c) => c.name === "approveSession")!.args.namespaces;
+ f.sessions.t1 = { topic: "t1", expiry: 0, peer: { metadata: peer }, namespaces: approved };
+ return { w, f, handled, summaries, approved };
+ }
+
+ it("approves the session with bch accounts and names the networks on the connect screen", async () => {
+ const { approved, summaries } = await setup();
+ expect(approved.bch.accounts).toEqual([`bch:${BCH_TEST}`, `bch:${BCH_MAIN}`]);
+ expect(summaries[0]!.approvedChains).toContain("bch:bchtest");
+ });
+
+ it("answers bch_getAddresses locally and forwards signing to chains-bitcoincash on the aliased network", async () => {
+ const { f, handled } = await setup();
+ await f.fire("session_request", { id: 10, topic: "t1", params: { chainId: "bch:bchtest", request: { method: "bch_getAddresses", params: {} } } });
+ expect(f.calls.at(-1)).toMatchObject({ name: "respondSessionRequest", args: { response: { id: 10, result: [BCH_TEST] } } });
+ expect(handled).toHaveLength(0);
+
+ await f.fire("session_request", { id: 11, topic: "t1", params: { chainId: "bch:bchtest", request: { method: "bch_signTransaction", params: { transaction: "00" } } } });
+ expect(handled[0]).toMatchObject({ family: "bitcoincash", networkId: CHIPNET, method: "bch_signTransaction", via: "walletconnect", origin: "https://tapswap.example.unverified.invalid" });
+ expect(f.calls.at(-1)).toMatchObject({ args: { response: { id: 11, result: { signedTransaction: "02" } } } });
+
+ await f.fire("session_request", { id: 12, topic: "t1", params: { chainId: "bch:bitcoincash", request: { method: "bch_signMessage", params: { message: "hi" } } } });
+ expect(handled[1]).toMatchObject({ family: "bitcoincash", networkId: MAINNET });
+
+ await f.fire("session_request", { id: 13, topic: "t1", params: { chainId: BTC_TESTNET, request: { method: "signPsbt", params: {} } } });
+ expect(handled[2]).toMatchObject({ family: "bitcoin", networkId: BTC_TESTNET, method: "signPsbt" });
+ });
+
+ it("refuses a bch chain outside the session", async () => {
+ const { f } = await setup();
+ await f.fire("session_request", { id: 20, topic: "t1", params: { chainId: "bch:bchreg", request: { method: "bch_getAddresses", params: {} } } });
+ expect(f.calls.at(-1)).toMatchObject({ args: { response: { id: 20, error: { code: 5100 } } } });
+ });
+
+ it("notifyAccountsChanged updates the session and emits addressesChanged with CashAddrs", async () => {
+ const { w, f } = await setup();
+ await w.notifyAccountsChanged();
+ const upd = f.calls.find((c) => c.name === "updateSession")!;
+ expect(upd.args.namespaces.bch.accounts).toEqual([`bch:${BCH_TEST}`, `bch:${BCH_MAIN}`]);
+ const ev = f.calls.filter((c) => c.name === "emitSessionEvent").map((c) => c.args);
+ expect(ev).toContainEqual({ topic: "t1", event: { name: "addressesChanged", data: [BCH_TEST] }, chainId: "bch:bchtest" });
+ expect(ev).toContainEqual({ topic: "t1", event: { name: "addressesChanged", data: [BCH_MAIN] }, chainId: "bch:bitcoincash" });
+ expect(ev).toContainEqual({ topic: "t1", event: { name: "bip122_addressesChanged", data: [`${BTC_TESTNET}:${BTC_ADDR}`] }, chainId: BTC_TESTNET });
+ });
+});
diff --git a/packages/1mask/test/xrpl.test.ts b/packages/1mask/test/xrpl.test.ts
new file mode 100644
index 00000000..377089b0
--- /dev/null
+++ b/packages/1mask/test/xrpl.test.ts
@@ -0,0 +1,146 @@
+import type { DappRequest, Family, Network } from "@clip-wallet/core";
+import type { Wallet } from "@wallet-standard/base";
+import { describe, expect, it } from "vitest";
+import { createXrplDispatcher, type XrplRouterInternals } from "../src/background/xrpl.js";
+import { ClipXrplWallet, XRPL_FEATURES, installXrpl, xrplChains } from "../src/inpage/xrpl.js";
+import type { InpageTransport } from "../src/inpage/transport.js";
+import { DEFAULT_IDENTITY } from "../src/shared/config.js";
+import { ProviderRpcError } from "../src/shared/errors.js";
+import { xrplAllowlist, xrplChainId } from "../src/shared/xrpl.js";
+
+const asset = (id: string) => ({ key: "xrp", symbol: "XRP", name: "XRP", decimals: 6, networkId: id });
+const net = (id: string, family: Family = "xrpl"): Network => ({ id, family, name: id, nativeAsset: asset(id), testnet: id !== "xrpl:0", rpcUrls: [], explorerUrl: "" });
+const NETS = [net("xrpl:1"), net("xrpl:0"), net("solana:devnet", "solana")];
+const ME = "rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3";
+const PUB = "031d68bc1a142e6766b2bdfb006ccfe135ef2e0e2e94abb5cf5c9ab6104776fbae";
+const OTHER = "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe";
+const TX = { TransactionType: "Payment", Account: ME, Destination: OTHER, Amount: "1" };
+
+/** Router stand-in: one connected origin with one account; records approvals. */
+function internals(o: { permitted?: boolean } = {}) {
+ let permitted = o.permitted ?? true;
+ const approved: DappRequest[] = [];
+ const r: XrplRouterInternals = {
+ permitted: async () => permitted,
+ requirePermission: async () => {
+ if (!permitted) throw new ProviderRpcError(4100, "not connected");
+ },
+ accounts: async () => (permitted ? [{ address: ME, publicKey: PUB }] : []),
+ connect: async () => {
+ permitted = true;
+ return [{ address: ME, publicKey: PUB }];
+ },
+ approve: async (req) => {
+ approved.push(req);
+ return req.method === "xrpl:signTransaction" ? { signed_tx_blob: "AB" } : { tx_hash: "H", tx_json: {} };
+ },
+ makeReq: (origin, family, n, method, params) => ({ id: "1", origin, via: "injected", family, networkId: n.id, method, params }),
+ requireNetwork: (_f, _o, chain) => {
+ const n = NETS.find((x) => x.family === "xrpl" && x.id === (chain ?? "xrpl:1"));
+ if (!n) throw new ProviderRpcError(4901, "no such chain");
+ return n;
+ },
+ revoke: async () => {
+ permitted = false;
+ },
+ };
+ return { r, approved };
+}
+
+describe("XLS-72d background dispatcher", () => {
+ it("allowlists only the XLS-72d methods and maps network aliases", () => {
+ expect([...xrplAllowlist()].sort()).toEqual(["1mask_getAccounts", "standard:connect", "standard:disconnect", "xrpl:signAndSubmitTransaction", "xrpl:signTransaction"].sort());
+ expect(xrplChainId("xrpl:testnet")).toBe("xrpl:1");
+ expect(xrplChainId("xrpl:21337")).toBe("xrpl:21337");
+ expect(xrplChainId("eip155:1")).toBeUndefined();
+ });
+
+ it("connects, then approves a sign request with the network and signer checked", async () => {
+ const { r, approved } = internals({ permitted: false });
+ const d = createXrplDispatcher(r);
+ await expect(d.dispatch("https://dapp.example", "xrpl:signTransaction", { tx_json: TX, account: ME, network: "xrpl:1" }, "xrpl:1")).rejects.toMatchObject({ code: 4100 });
+ expect(await d.dispatch("https://dapp.example", "1mask_getAccounts", undefined, undefined)).toEqual([]);
+ expect(await d.dispatch("https://dapp.example", "standard:connect", undefined, undefined)).toEqual([{ address: ME, publicKey: PUB }]);
+ expect(await d.dispatch("https://dapp.example", "xrpl:signTransaction", { tx_json: TX, account: ME, network: "xrpl:testnet", options: { autofill: true } }, "xrpl:1")).toEqual({ signed_tx_blob: "AB" });
+ expect(approved[0]).toMatchObject({ family: "xrpl", networkId: "xrpl:1", method: "xrpl:signTransaction", params: { tx_json: TX, account: ME, network: "xrpl:1", options: { autofill: true } } });
+ await d.dispatch("https://dapp.example", "xrpl:signAndSubmitTransaction", { tx_json: TX, account: ME, network: "xrpl:0" }, "xrpl:0");
+ expect(approved[1]).toMatchObject({ networkId: "xrpl:0", method: "xrpl:signAndSubmitTransaction" });
+ });
+
+ it("refuses other accounts, mixed networks, unknown networks and other methods", async () => {
+ const { r, approved } = internals();
+ const d = createXrplDispatcher(r);
+ await expect(d.dispatch("https://dapp.example", "xrpl:signTransaction", { tx_json: TX, account: OTHER, network: "xrpl:1" }, undefined)).rejects.toMatchObject({ code: 4100 });
+ await expect(d.dispatch("https://dapp.example", "xrpl:signTransaction", { tx_json: { ...TX, Account: OTHER }, account: ME }, undefined)).rejects.toMatchObject({ code: 4100 });
+ await expect(d.dispatch("https://dapp.example", "xrpl:signTransaction", { tx_json: TX, account: ME, network: "xrpl:0" }, "xrpl:1")).rejects.toMatchObject({ code: -32602 });
+ await expect(d.dispatch("https://dapp.example", "xrpl:signTransaction", { tx_json: TX, account: ME, network: "xrpl:21337" }, undefined)).rejects.toMatchObject({ code: 4901 });
+ await expect(d.dispatch("https://dapp.example", "xrpl:signTransaction", { account: ME }, undefined)).rejects.toMatchObject({ code: -32602 });
+ await expect(d.dispatch("https://dapp.example", "xrpl:signMessage", {}, undefined)).rejects.toMatchObject({ code: 4200 });
+ expect(approved).toEqual([]);
+ expect(await d.dispatch("https://dapp.example", "standard:disconnect", undefined, undefined)).toBeNull();
+ });
+});
+
+/** Transport stand-in that answers like the background would. */
+function transport() {
+ const sent: { family: Family; method: string; params?: unknown; chain?: string }[] = [];
+ let listener: ((f: Family, e: string, d: unknown) => void) | undefined;
+ const t: InpageTransport = {
+ request: async (family, method, params, chain) => {
+ sent.push({ family, method, params, chain });
+ if (method === "standard:connect" || method === "1mask_getAccounts") return [{ address: ME, publicKey: PUB }];
+ if (method === "xrpl:signTransaction") return { signed_tx_blob: "BLOB", extra: 1 };
+ if (method === "xrpl:signAndSubmitTransaction") return { tx_hash: "HASH", tx_json: { validated: true } };
+ return null;
+ },
+ onEvent: (l) => {
+ listener = l as typeof listener;
+ return () => (listener = undefined);
+ },
+ } as InpageTransport;
+ return { t, sent, emit: (e: string, d: unknown) => listener?.("xrpl", e, d) };
+}
+
+describe("XLS-72d in-page wallet", () => {
+ it("registers under the wallet's own identity with the XLS-72d features and chains", () => {
+ const registered: Wallet[] = [];
+ const win = {} as Window;
+ const { t } = transport();
+ const out = installXrpl(win, DEFAULT_IDENTITY, NETS, t, { register: (w) => void registered.push(w) })!;
+ expect(registered).toEqual([out.wallet]);
+ expect(out.wallet.name).toBe(DEFAULT_IDENTITY.name);
+ expect(out.wallet.chains).toEqual(["xrpl:1", "xrpl:0"]);
+ expect(Object.keys(out.wallet.features).sort()).toEqual([...XRPL_FEATURES].sort());
+ expect((win as unknown as { clipwallet: { xrpl: unknown } }).clipwallet.xrpl).toBe(out.wallet);
+ out.stop();
+ expect(installXrpl({} as Window, DEFAULT_IDENTITY, [net("solana:devnet", "solana")], t, { register: () => {} })).toBeUndefined();
+ expect(xrplChains(NETS)).toEqual(["xrpl:1", "xrpl:0"]);
+ });
+
+ it("connects, signs with the connected account and maps aliases to the CAIP-2 chain", async () => {
+ const { t, sent } = transport();
+ const w = new ClipXrplWallet({ ...DEFAULT_IDENTITY, name: "Kit Wallet" }, NETS, t);
+ const { accounts } = await w.features["standard:connect"].connect();
+ expect(accounts.map((a) => a.address)).toEqual([ME]);
+ expect(w.name).toBe("Kit Wallet");
+ const signed = await w.features["xrpl:signTransaction"].signTransaction({ tx_json: TX, account: accounts[0]!, network: "xrpl:testnet", options: { autofill: true } });
+ expect(signed).toEqual({ signed_tx_blob: "BLOB" });
+ expect(sent.at(-1)).toEqual({ family: "xrpl", method: "xrpl:signTransaction", params: { tx_json: TX, account: ME, network: "xrpl:1", options: { autofill: true } }, chain: "xrpl:1" });
+ expect(await w.features["xrpl:signAndSubmitTransaction"].signAndSubmitTransaction({ tx_json: TX, account: accounts[0]!, network: "xrpl:0" })).toEqual({ tx_hash: "HASH", tx_json: { validated: true } });
+ });
+
+ it("refuses accounts that aren't connected and networks it doesn't have", async () => {
+ const { t, emit } = transport();
+ const w = new ClipXrplWallet(DEFAULT_IDENTITY, NETS, t);
+ const stranger = { address: OTHER, publicKey: new Uint8Array(), chains: ["xrpl:1"], features: [] } as never;
+ await expect(w.features["xrpl:signTransaction"].signTransaction({ tx_json: TX, account: stranger, network: "xrpl:1" })).rejects.toMatchObject({ code: 4100 });
+ const { accounts } = await w.features["standard:connect"].connect();
+ await expect(w.features["xrpl:signTransaction"].signTransaction({ tx_json: TX, account: accounts[0]!, network: "xrpl:21337" })).rejects.toMatchObject({ code: 4901 });
+ await expect(w.features["xrpl:signTransaction"].signTransaction({ tx_json: TX, account: accounts[0]!, network: "xrpl:2" })).rejects.toMatchObject({ code: 4901 });
+ const changes: unknown[] = [];
+ w.features["standard:events"].on("change", (c) => changes.push(c));
+ emit("disconnect", undefined);
+ expect(w.accounts).toEqual([]);
+ expect(changes).toHaveLength(1);
+ });
+});
diff --git a/packages/backup-client/src/index.ts b/packages/backup-client/src/index.ts
index 2464d36b..33293641 100644
--- a/packages/backup-client/src/index.ts
+++ b/packages/backup-client/src/index.ts
@@ -35,6 +35,13 @@ import {
tokenFromLink,
} from "./protocol.js";
+/** `url` without trailing slashes (a scan, not a regex: linear on any input). */
+function withoutTrailingSlashes(url: string): string {
+ let end = url.length;
+ while (end > 0 && url[end - 1] === "/") end--;
+ return url.slice(0, end);
+}
+
export * from "./protocol.js";
export interface BackupClientOptions {
@@ -88,7 +95,7 @@ export class BackupClient {
private readonly random: (n: number) => Uint8Array;
constructor(opts: BackupClientOptions) {
- this.base = `${opts.baseUrl.replace(/\/+$/, "")}/${API_VERSION}`;
+ this.base = `${withoutTrailingSlashes(opts.baseUrl)}/${API_VERSION}`;
this.f = opts.fetch ?? globalThis.fetch.bind(globalThis);
this.session = opts.session ?? null;
this.now = opts.now ?? Date.now;
diff --git a/packages/chains-antelope/LICENSE b/packages/chains-antelope/LICENSE
new file mode 100644
index 00000000..fd2e97a7
--- /dev/null
+++ b/packages/chains-antelope/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 ColdAI (https://coldai.org)
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/chains-antelope/NOTICE b/packages/chains-antelope/NOTICE
new file mode 100644
index 00000000..2f081dce
--- /dev/null
+++ b/packages/chains-antelope/NOTICE
@@ -0,0 +1,8 @@
+Clip Wallet: @clip-wallet/chains-antelope
+Copyright 2026 ColdAI (https://coldai.org)
+
+This product includes software developed by ColdAI (https://coldai.org).
+Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+"Clip Wallet", "1Mask" and the Clip Wallet logo are trademarks of ColdAI. The Apache
+License 2.0 grants no rights to use them (Section 6).
diff --git a/packages/chains-antelope/README.md b/packages/chains-antelope/README.md
new file mode 100644
index 00000000..a8c2fc8d
--- /dev/null
+++ b/packages/chains-antelope/README.md
@@ -0,0 +1,112 @@
+# @clip-wallet/chains-antelope
+
+Antelope `ChainModule` for Clip Wallet: Vaulta (formerly EOS), Telos and XPR Network, with their testnets. One K1 key
+signs for all three. It builds, decodes and checks transactions in plain words and never touches keys: `prepare()`
+returns SHA-256(chain_id ‖ packed_trx ‖ 32 zero bytes) for the vault to sign (`ecdsa-secp256k1`, canonical K1), and
+`finalize()` checks the signature (verify, canonical, recovery id) before anything is assembled or sent.
+
+No SDK at runtime: names, assets, keys, `SIG_K1_` strings, transactions and the ABI (de)serialiser are hand-written on
+`@noble/hashes`, `@noble/curves` (verification and recovery only) and `@scure/base`. Tests check bytes, transaction
+ids, digests and signature strings against `@wharfkit/antelope` (devDependency) and verify with
+`Signature.verifyDigest` / `recoverDigest`.
+
+## Networks
+
+| network | NetworkId | chain API (public, no key) | Hyperion | explorer |
+|---|---|---|---|---|
+| Jungle4 (Vaulta testnet) | `antelope:73e4385a2708e6d7048834fbc1079f2f` | jungle4.greymass.com, jungle4.cryptolions.io, jungle4.api.eosnation.io | jungle4.cryptolions.io | jungle4.unicove.com |
+| Vaulta | `antelope:aca376f206b8fc25a6ed44dbdc66547c` | eos.greymass.com, eos.api.eosnation.io | eos.eosusa.io | bloks.io |
+| Telos Testnet | `antelope:1eaa0824707c8c16bd25145493bf062a` | testnet.telos.net, telos-testnet.cryptolions.io | testnet.telos.net | explorer-test.telos.net |
+| Telos | `antelope:4667b205c6838ef70ff7988f6e8257e8` | mainnet.telos.net, telos.greymass.com | mainnet.telos.net | explorer.telos.net |
+| XPR Network Testnet | `antelope:71ee83bcf52142d61019d95f9cc5427b` | tn1.protonnz.com, testnet.protonchain.com, proton-testnet.cryptolions.io | — | testnet.explorer.xprnetwork.org |
+| XPR Network | `antelope:384da888112027f0321850a169f737c3` | proton.greymass.com, api.protonnz.com, proton.eosusa.io | proton.eosusa.io | explorer.xprnetwork.org |
+
+- Ids: ChainAgnostic namespaces `antelope/caip2.md` ("antelope:" + the first 32 hex of the chain id; its test cases
+ include EOS Mainnet and Telos Mainnet). `netOf` also takes the full chain id.
+- Every endpoint answered `get_info` with the chain id above (October 2026). `send_transaction2` exists on all of
+ them (`push_transaction` is the fallback on a 404). `get_accounts_by_authorizers` is load-balanced on
+ testnet.telos.net (some backends 404), so the module tries every server, then Hyperion.
+
+## Keys, accounts, addresses
+
+`curve: "secp256k1"`, `derivationPath(i) = m/44'/194'/0'/0/i` (SLIP-44 194, TokenPocket). `Account.address` and
+`addressFromPublicKey` give the key as `PUB_K1_…` (the vault's `antelopePublicKey`; legacy `EOS…` keys are parsed too).
+Antelope accounts are 12-character names created on chain that point at keys, so:
+
+- `accounts(ctx)` / `receiveAddress(ctx)`: the names whose permission this key satisfies alone (weight ≥ threshold), from
+ `/v1/chain/get_accounts_by_authorizers`, else Hyperion `/v2/state/get_key_accounts`. `receiveAddress` returns the
+ first (an `active` one first). None → ClipError `antelope/no-account` in plain words ("No Jungle4 account uses this
+ key yet … create a free test account for your public key …" / on mainnets: an exchange, a friend or an
+ account-creation service creates it).
+- `isAddress` = a valid account name (what you send to); keys are not addresses. `networksForAddress` = every Antelope
+ network (a name can exist on each, owned by different people).
+- The public "abandon … about" key `PUB_K1_6zpSNY1Y…NK2aD4t` already has accounts made by strangers (kasekitest11 on
+ Jungle4, binancegold4 on Vaulta). Never use that phrase for anything real.
+
+## Assets
+
+- Vaulta: **EOS** (eosio.token, key `eos`) is still the system token: RAM, PowerUp and staking are priced in it.
+ **A** (Vaulta, core.vaulta, key `vaulta`; EOS and A swap 1:1 through core.vaulta) and **USDT** (Tether's own issue on
+ tethertether, key `usdt`) are shown beside it. Jungle4 has EOS and A.
+- Telos: **TLOS**; XPR Network: **XPR** (eosio.token, 4 decimals).
+- Balances: `get_currency_balance` for those; other tokens from Hyperion `state/get_tokens` (non-zero only). A token
+ that borrows a known symbol (EOS, A, USDT, TLOS, XPR, USD…) from another contract is `spam: true` (XPR mainnet has
+ an "XPR" on `ternary`).
+- `getNfts` returns `[]` (AtomicAssets has no `Nft.standard` value in core).
+
+## Requests
+
+There is no dapp standard to implement on the wallet side (below), so the methods are Clip Wallet's own:
+
+| method | params | result |
+|---|---|---|
+| `antelope_signTransaction` | `{ transaction, account?, chainId? }` | `{ signatures: ["SIG_K1_…"], packed_trx, transaction_id }` |
+| `antelope_signAndPushTransaction` | same | `{ transaction_id, processed? }` (send_transaction2) |
+
+`transaction` is the abieos JSON; the header (expiration, ref_block_num, ref_block_prefix) may be left out, and is then
+filled once in `decode()` (TAPoS from the last irreversible block, expiry 5 minutes after head) and cached per
+request, so the signature covers what was shown. Action `data` is packed hex, or JSON serialised with the contract's
+ABI (`/v1/chain/get_abi`, cached). eosio.token-style transfers of the known token contracts use a built-in ABI, so
+`buildTransfer` makes no ABI call.
+
+- Refused: another network, context-free actions or transaction extensions, an action no authorization of this key can
+ sign ("This transaction doesn't need your signature"), a permission of our account this key doesn't hold, and on
+ sign-and-push any other signer (co-signing is fine with `antelope_signTransaction`: "Also needs bob@active").
+- Signature: r ‖ s + recovery from the vault → `SIG_K1_` (recovery byte = recid + 31, checksum RIPEMD-160(sig ‖ "K1")).
+- Chain refusals in plain words (`plainAntelopeError`): CPU/NET exhausted → per network (Vaulta: rent with PowerUp;
+ Telos: stake TLOS or PowerUp; XPR: the network covers CPU/NET for users, try again), RAM, overdrawn balance,
+ expired, authorization, missing recipient, duplicate.
+
+### decode()
+
+- Any action whose ABI struct is the eosio.token transfer shape (from, to, quantity: asset, memo): "Send 1.5000 EOS to
+ bob" with the memo, the balance change, a check that the recipient exists; an unknown contract's token → caution, a
+ look-alike → danger `known-scam`.
+- `eosio::updateauth` / `deleteauth` / `linkauth` → **danger `account-takeover`**; `unlinkauth` → caution.
+- Anything else readable through the contract's ABI: "Call {action} on {contract}" with every field, caution
+ `unknown-call`. Several actions: "Approve N operations" with "Action N" lines.
+- No ABI, a type the serialiser doesn't know (floats, 128-bit integers, WebAuthn keys) or data that doesn't parse →
+ **blind**.
+- `delay_sec` > 0 → caution. The fee line says there's no token fee (CPU/NET, or covered by the network on XPR).
+
+## Dapp connectivity: send/receive only
+
+Checked October 2026; none lets a wallet extension answer under its own name:
+
+- **WharfKit** (the current Antelope dapp SDK): the wallet *plugin* lives in the dapp (`@wharfkit/wallet-plugin-*`);
+ a wallet appears in a dapp only when the dapp ships that wallet's plugin. Nothing for an extension to implement.
+- **Anchor Link / ESR** (EOSIO Signing Requests over a Buoy relay): the dapp's `wallet-plugin-anchor` shows "Anchor";
+ a different wallet answering it would appear as Anchor.
+- **Scatter-compatible injection** (`window.scatter`, ScatterJS; what TokenPocket and the WharfKit Scatter /
+ TokenPocket plugins use): answering it means pretending to be Scatter/TokenPocket (AGENTS.md rule 8).
+- **XPR WebAuth** (proton-web-sdk) talks to WebAuth's own wallet and relay.
+
+So 1Mask has no Antelope provider; sends and receives work from the wallet itself.
+
+## Getting a testnet account (needs a person)
+
+| network | create an account for a public key | test tokens |
+|---|---|---|
+| Jungle4 | `POST https://jungle4.greymass.com/account/create` `{ accountName: "<9 chars>.gm", ownerKey, activeKey, network: "73e4…6c4d" }` → 201 (what `npx @wharfkit/cli account create` sends; no captcha). Or monitor.jungletestnet.io (Google reCAPTCHA). | monitor.jungletestnet.io faucet (reCAPTCHA). New accounts also need CPU: PowerUp paid by the account, or another account |
+| Telos Testnet | `POST https://api-dev.telos.net/v1/testnet/account` `{ accountName, ownerKey, activeKey }` (the app.telos.net testnet developer page calls it; no captcha, one per IP per 24 h) | `GET https://api-dev.telos.net/v1/testnet/faucet/` (one per IP/account per 24 h) |
+| XPR Testnet | `proton account:create` → `identity.api.dev.metalx.com` with an e-mail address and a 6-digit code sent to it (or the WebAuth testnet app) | `token.faucet::claim` on chain (`proton faucet:claim XPR @active`) |
diff --git a/packages/chains-antelope/package.json b/packages/chains-antelope/package.json
new file mode 100644
index 00000000..42f29512
--- /dev/null
+++ b/packages/chains-antelope/package.json
@@ -0,0 +1,79 @@
+{
+ "name": "@clip-wallet/chains-antelope",
+ "version": "0.1.0",
+ "description": "Antelope (Vaulta, Telos, XPR Network) ChainModule for Clip Wallet: builds and decodes transactions in plain words; never touches keys",
+ "keywords": [
+ "clip-wallet",
+ "wallet",
+ "antelope",
+ "chain-module"
+ ],
+ "license": "Apache-2.0",
+ "author": "ColdAI (https://coldai.org)",
+ "homepage": "https://github.com/ColdAI-org/clip-wallet/tree/main/packages/chains-antelope#readme",
+ "bugs": {
+ "url": "https://github.com/ColdAI-org/clip-wallet/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ColdAI-org/clip-wallet.git",
+ "directory": "packages/chains-antelope"
+ },
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "development": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./networks": {
+ "development": "./src/networks.ts",
+ "types": "./dist/networks.d.ts",
+ "import": "./dist/networks.js",
+ "default": "./dist/networks.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE",
+ "NOTICE"
+ ],
+ "publishConfig": {
+ "access": "public",
+ "provenance": true,
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./networks": {
+ "types": "./dist/networks.d.ts",
+ "import": "./dist/networks.js",
+ "default": "./dist/networks.js"
+ }
+ }
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "build": "node ../../tools/release/build-package.mjs",
+ "typecheck": "tsc -p . --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@clip-wallet/core": "workspace:*",
+ "@noble/curves": "2.4.0",
+ "@noble/hashes": "2.4.0",
+ "@scure/base": "2.4.0"
+ },
+ "devDependencies": {
+ "@wharfkit/antelope": "^4.0.2"
+ }
+}
diff --git a/packages/chains-antelope/src/abi.ts b/packages/chains-antelope/src/abi.ts
new file mode 100644
index 00000000..f33a8e37
--- /dev/null
+++ b/packages/chains-antelope/src/abi.ts
@@ -0,0 +1,315 @@
+import {
+ Reader,
+ Writer,
+ formatAsset,
+ fromHex,
+ hex,
+ nameFromBigInt,
+ nameToBigInt,
+ packPublicKey,
+ packSignature,
+ parseAsset,
+ publicKeyString,
+ symbolFromBigInt,
+ symbolToBigInt,
+} from "./bytes.js";
+
+/**
+ * ABI-driven (de)serialisation for action data (eosio::abi/1.x JSON as `/v1/chain/get_abi` returns it): type aliases,
+ * structs with bases, `T[]`, `T?`, `T$` (binary extensions), variants, and the built-in types Clip Wallet meets in
+ * token, system and dapp contracts. Anything else (float types, 128-bit integers, fixed-size arrays, WebAuthn keys)
+ * throws AbiError, so the request becomes blind instead of being guessed. JSON follows abieos / WharfKit: 64-bit
+ * integers as decimal strings, `bytes` and checksums as hex, variants as [type, value].
+ */
+
+export interface Abi {
+ version?: string;
+ types?: { new_type_name: string; type: string }[];
+ structs?: { name: string; base?: string; fields: { name: string; type: string }[] }[];
+ actions?: { name: string; type: string; ricardian_contract?: string }[];
+ variants?: { name: string; types: string[] }[];
+}
+
+export class AbiError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "AbiError";
+ }
+}
+
+/** eosio.token's transfer, so token sends never need an ABI round trip. */
+export const TOKEN_ABI: Abi = {
+ version: "eosio::abi/1.2",
+ structs: [{ name: "transfer", base: "", fields: [{ name: "from", type: "name" }, { name: "to", type: "name" }, { name: "quantity", type: "asset" }, { name: "memo", type: "string" }] }],
+ actions: [{ name: "transfer", type: "transfer" }],
+};
+
+const INT_SIZES: Record = {
+ int8: [1, true],
+ uint8: [1, false],
+ int16: [2, true],
+ uint16: [2, false],
+ int32: [4, true],
+ uint32: [4, false],
+ int64: [8, true],
+ uint64: [8, false],
+};
+
+const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v);
+
+function resolve(abi: Abi, type: string, depth = 0): string {
+ if (depth > 32) throw new AbiError(`type alias loop at ${type}`);
+ const alias = abi.types?.find((t) => t.new_type_name === type);
+ return alias ? resolve(abi, alias.type, depth + 1) : type;
+}
+
+function bigOf(v: unknown, what: string): bigint {
+ if (typeof v === "bigint") return v;
+ if (typeof v === "number" && Number.isSafeInteger(v)) return BigInt(v);
+ if (typeof v === "string" && /^-?\d+$/.test(v)) return BigInt(v);
+ throw new AbiError(`${what} isn't an integer`);
+}
+
+function hexOf(v: unknown, bytes: number | null, what: string): Uint8Array {
+ if (typeof v !== "string" || !/^([0-9a-fA-F]{2})*$/.test(v) || (bytes !== null && v.length !== bytes * 2)) throw new AbiError(`${what} isn't valid hex`);
+ return fromHex(v);
+}
+
+/** Microseconds since epoch ↔ "2024-01-01T00:00:00.000" (UTC, no zone, as Spring prints it). */
+function parseTime(v: unknown, what: string): number {
+ if (typeof v !== "string") throw new AbiError(`${what} isn't a time`);
+ const ms = Date.parse(/[zZ]|[+-]\d\d:\d\d$/.test(v) ? v : `${v}Z`);
+ if (Number.isNaN(ms)) throw new AbiError(`${what} isn't a time`);
+ return ms;
+}
+const timeText = (ms: number) => new Date(ms).toISOString().replace(/Z$/, "");
+
+const SLOT_EPOCH_MS = 946684800000; // block_timestamp_type: half-seconds since 2000-01-01
+
+export function serialize(abi: Abi, type: string, value: unknown, w: Writer = new Writer(), depth = 0): Writer {
+ if (depth > 32) throw new AbiError("value nests too deep");
+ if (type.endsWith("$")) {
+ if (value !== undefined) serialize(abi, type.slice(0, -1), value, w, depth + 1);
+ return w;
+ }
+ if (type.endsWith("?")) {
+ if (value === undefined || value === null) return w.byte(0);
+ w.byte(1);
+ return serialize(abi, type.slice(0, -1), value, w, depth + 1);
+ }
+ if (type.endsWith("[]")) {
+ if (!Array.isArray(value)) throw new AbiError(`${type} needs an array`);
+ w.varuint32(value.length);
+ for (const v of value) serialize(abi, type.slice(0, -2), v, w, depth + 1);
+ return w;
+ }
+ const t = resolve(abi, type);
+ if (t !== type) return serialize(abi, t, value, w, depth + 1);
+ const int = INT_SIZES[t];
+ if (int) return int[1] ? w.int(bigOf(value, t), int[0]) : w.uint(bigOf(value, t), int[0]);
+ switch (t) {
+ case "bool":
+ if (typeof value !== "boolean") throw new AbiError("bool needs true or false");
+ return w.byte(value ? 1 : 0);
+ case "varuint32":
+ return w.varuint32(Number(bigOf(value, t)));
+ case "varint32":
+ return w.varint32(Number(bigOf(value, t)));
+ case "name":
+ if (typeof value !== "string") throw new AbiError("name needs a string");
+ try {
+ return w.uint(nameToBigInt(value), 8);
+ } catch {
+ throw new AbiError(`invalid name ${value}`);
+ }
+ case "string":
+ if (typeof value !== "string") throw new AbiError("string needs a string");
+ return w.blob(new TextEncoder().encode(value));
+ case "bytes":
+ return w.blob(hexOf(value, null, t));
+ case "checksum160":
+ return w.bytes(hexOf(value, 20, t));
+ case "checksum256":
+ return w.bytes(hexOf(value, 32, t));
+ case "checksum512":
+ return w.bytes(hexOf(value, 64, t));
+ case "public_key":
+ try {
+ return w.bytes(packPublicKey(String(value)));
+ } catch {
+ throw new AbiError("unsupported public key");
+ }
+ case "signature":
+ try {
+ return w.bytes(packSignature(String(value)));
+ } catch {
+ throw new AbiError("unsupported signature");
+ }
+ case "time_point":
+ return w.int(BigInt(parseTime(value, t)) * 1000n, 8);
+ case "time_point_sec":
+ return w.uint(Math.floor(parseTime(value, t) / 1000), 4);
+ case "block_timestamp_type":
+ return w.uint(Math.round((parseTime(value, t) - SLOT_EPOCH_MS) / 500), 4);
+ case "symbol": {
+ const m = typeof value === "string" ? /^(\d{1,2}),([A-Z]{1,7})$/.exec(value) : null;
+ if (!m) throw new AbiError("symbol needs \"4,EOS\"");
+ return w.uint(symbolToBigInt({ precision: Number(m[1]), code: m[2]! }), 8);
+ }
+ case "symbol_code":
+ if (typeof value !== "string" || !/^[A-Z]{1,7}$/.test(value)) throw new AbiError("symbol_code needs \"EOS\"");
+ return w.uint(symbolToBigInt({ precision: 0, code: value }) >> 8n, 8);
+ case "asset": {
+ let a;
+ try {
+ a = parseAsset(String(value));
+ } catch {
+ throw new AbiError(`invalid asset ${String(value)}`);
+ }
+ return w.int(a.amount, 8).uint(symbolToBigInt(a.symbol), 8);
+ }
+ case "extended_asset":
+ if (!isObj(value)) throw new AbiError("extended_asset needs { quantity, contract }");
+ serialize(abi, "asset", value.quantity, w, depth + 1);
+ return serialize(abi, "name", value.contract, w, depth + 1);
+ }
+ const variant = abi.variants?.find((v) => v.name === t);
+ if (variant) {
+ if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string") throw new AbiError(`${t} needs [type, value]`);
+ const i = variant.types.indexOf(value[0]);
+ if (i < 0) throw new AbiError(`${t} has no ${value[0]}`);
+ w.varuint32(i);
+ return serialize(abi, value[0], value[1], w, depth + 1);
+ }
+ const struct = abi.structs?.find((s) => s.name === t);
+ if (struct) {
+ if (!isObj(value)) throw new AbiError(`${t} needs an object`);
+ if (struct.base) serialize(abi, struct.base, value, w, depth + 1);
+ for (const f of struct.fields) {
+ if (value[f.name] === undefined && !f.type.endsWith("$") && !f.type.endsWith("?")) throw new AbiError(`${t}.${f.name} is missing`);
+ serialize(abi, f.type, value[f.name], w, depth + 1);
+ }
+ return w;
+ }
+ throw new AbiError(`unknown type ${t}`);
+}
+
+export function deserialize(abi: Abi, type: string, r: Reader, depth = 0): unknown {
+ if (depth > 32) throw new AbiError("value nests too deep");
+ if (type.endsWith("$")) return r.remaining > 0 ? deserialize(abi, type.slice(0, -1), r, depth + 1) : undefined;
+ if (type.endsWith("?")) return r.byte() ? deserialize(abi, type.slice(0, -1), r, depth + 1) : null;
+ if (type.endsWith("[]")) {
+ const n = r.varuint32();
+ if (n > r.remaining) throw new AbiError("array longer than the data");
+ return Array.from({ length: n }, () => deserialize(abi, type.slice(0, -2), r, depth + 1));
+ }
+ const t = resolve(abi, type);
+ if (t !== type) return deserialize(abi, t, r, depth + 1);
+ const int = INT_SIZES[t];
+ if (int) {
+ const v = int[1] ? r.int(int[0]) : r.uint(int[0]);
+ return int[0] > 4 ? v.toString() : Number(v);
+ }
+ switch (t) {
+ case "bool": {
+ const b = r.byte();
+ if (b > 1) throw new AbiError("bad bool");
+ return b === 1;
+ }
+ case "varuint32":
+ return r.varuint32();
+ case "varint32":
+ return r.varint32();
+ case "name":
+ return nameFromBigInt(r.uint(8));
+ case "string":
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(r.blob());
+ } catch {
+ throw new AbiError("string isn't UTF-8");
+ }
+ case "bytes":
+ return hex(r.blob());
+ case "checksum160":
+ return hex(r.bytes(20));
+ case "checksum256":
+ return hex(r.bytes(32));
+ case "checksum512":
+ return hex(r.bytes(64));
+ case "public_key": {
+ if (r.byte() !== 0) throw new AbiError("only K1 public keys");
+ return publicKeyString(r.bytes(33));
+ }
+ case "time_point":
+ return timeText(Number(r.int(8) / 1000n));
+ case "time_point_sec":
+ return timeText(Number(r.uint(4)) * 1000).replace(/\.000$/, "");
+ case "block_timestamp_type":
+ return timeText(Number(r.uint(4)) * 500 + SLOT_EPOCH_MS);
+ case "symbol": {
+ const s = symbolFromBigInt(r.uint(8));
+ return `${s.precision},${s.code}`;
+ }
+ case "symbol_code":
+ return symbolFromBigInt(r.uint(8) << 8n).code;
+ case "asset": {
+ const amount = r.int(8);
+ return formatAsset({ amount, symbol: symbolFromBigInt(r.uint(8)) });
+ }
+ case "extended_asset":
+ return { quantity: deserialize(abi, "asset", r, depth + 1), contract: deserialize(abi, "name", r, depth + 1) };
+ }
+ const variant = abi.variants?.find((v) => v.name === t);
+ if (variant) {
+ const i = r.varuint32();
+ const vt = variant.types[i];
+ if (!vt) throw new AbiError(`${t} index ${i} out of range`);
+ return [vt, deserialize(abi, vt, r, depth + 1)];
+ }
+ const struct = abi.structs?.find((s) => s.name === t);
+ if (struct) {
+ const out: Record = struct.base ? (deserialize(abi, struct.base, r, depth + 1) as Record) : {};
+ for (const f of struct.fields) {
+ const v = deserialize(abi, f.type, r, depth + 1);
+ if (v !== undefined) out[f.name] = v;
+ }
+ return out;
+ }
+ throw new AbiError(`unknown type ${t}`);
+}
+
+/** The struct type an action's data uses, per the contract's ABI. */
+export function actionType(abi: Abi, action: string): string | undefined {
+ return abi.actions?.find((a) => a.name === action)?.type;
+}
+
+/** Action data bytes → JSON (all bytes must be consumed). */
+export function decodeActionData(abi: Abi, action: string, data: Uint8Array): Record {
+ const type = actionType(abi, action);
+ if (!type) throw new AbiError(`the contract's ABI has no action ${action}`);
+ const r = new Reader(data);
+ const out = deserialize(abi, type, r);
+ if (r.remaining) throw new AbiError(`${r.remaining} bytes left over`);
+ if (!isObj(out)) throw new AbiError("action data isn't a struct");
+ return out;
+}
+
+export function encodeActionData(abi: Abi, action: string, value: unknown): Uint8Array {
+ const type = actionType(abi, action);
+ if (!type) throw new AbiError(`the contract's ABI has no action ${action}`);
+ return serialize(abi, type, value).done();
+}
+
+/** True when the action's struct is the eosio.token transfer shape (from, to, quantity: asset, memo). */
+export function isTokenTransfer(abi: Abi, action: string): boolean {
+ const type = actionType(abi, action);
+ const s = type ? abi.structs?.find((x) => x.name === resolve(abi, type)) : undefined;
+ if (!s || s.base) return false;
+ const want = [
+ ["from", "name"],
+ ["to", "name"],
+ ["quantity", "asset"],
+ ["memo", "string"],
+ ];
+ return s.fields.length === 4 && s.fields.every((f, i) => f.name === want[i]![0] && resolve(abi, f.type) === want[i]![1]);
+}
diff --git a/packages/chains-antelope/src/bytes.ts b/packages/chains-antelope/src/bytes.ts
new file mode 100644
index 00000000..616be487
--- /dev/null
+++ b/packages/chains-antelope/src/bytes.ts
@@ -0,0 +1,289 @@
+import { secp256k1 } from "@noble/curves/secp256k1.js";
+import { ripemd160 } from "@noble/hashes/legacy.js";
+import { base58 } from "@scure/base";
+
+/**
+ * Antelope wire primitives (Spring/Leap fc + abieos; https://docs.eosnetwork.com/docs/latest/advanced-topics/
+ * transactions-protocol): little-endian integers, LEB128 varuint32, 64-bit names, assets, K1 keys and signatures.
+ * Cross-checked against @wharfkit/antelope in test/serializer.test.ts.
+ */
+
+export function hex(bytes: Uint8Array): string {
+ let s = "";
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
+ return s;
+}
+
+export function fromHex(h: string): Uint8Array {
+ const clean = h.replace(/^0x/, "");
+ if (clean.length % 2 || /[^0-9a-f]/i.test(clean)) throw new Error("bad hex");
+ const out = new Uint8Array(clean.length / 2);
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
+ return out;
+}
+
+export function concat(...parts: Uint8Array[]): Uint8Array {
+ const out = new Uint8Array(parts.reduce((a, p) => a + p.length, 0));
+ let at = 0;
+ for (const p of parts) {
+ out.set(p, at);
+ at += p.length;
+ }
+ return out;
+}
+
+export const utf8 = (s: string) => new TextEncoder().encode(s);
+
+export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
+ return true;
+}
+
+/* ------------------------------------------------------------------ writer / reader */
+
+export class Writer {
+ private chunks: number[] = [];
+
+ byte(b: number): this {
+ this.chunks.push(b & 0xff);
+ return this;
+ }
+ bytes(b: Uint8Array): this {
+ for (const x of b) this.chunks.push(x);
+ return this;
+ }
+ /** Unsigned little-endian integer of `size` bytes. */
+ uint(v: bigint | number, size: number): this {
+ let n = BigInt(v);
+ if (n < 0n || n >= 1n << BigInt(size * 8)) throw new RangeError("integer out of range");
+ for (let i = 0; i < size; i++, n >>= 8n) this.chunks.push(Number(n & 0xffn));
+ return this;
+ }
+ /** Signed little-endian integer (two's complement). */
+ int(v: bigint | number, size: number): this {
+ const n = BigInt(v);
+ const bits = BigInt(size * 8);
+ if (n < -(1n << (bits - 1n)) || n >= 1n << (bits - 1n)) throw new RangeError("integer out of range");
+ return this.uint(n < 0n ? (1n << bits) + n : n, size);
+ }
+ varuint32(v: number): this {
+ if (!Number.isInteger(v) || v < 0 || v > 0xffffffff) throw new RangeError("varuint32 out of range");
+ let n = v >>> 0;
+ for (;;) {
+ if (n >>> 7) {
+ this.chunks.push(0x80 | (n & 0x7f));
+ n >>>= 7;
+ } else {
+ this.chunks.push(n);
+ return this;
+ }
+ }
+ }
+ varint32(v: number): this {
+ if (!Number.isInteger(v) || v < -0x80000000 || v > 0x7fffffff) throw new RangeError("varint32 out of range");
+ return this.varuint32(((v << 1) ^ (v >> 31)) >>> 0);
+ }
+ blob(b: Uint8Array): this {
+ return this.varuint32(b.length).bytes(b);
+ }
+ done(): Uint8Array {
+ return Uint8Array.from(this.chunks);
+ }
+}
+
+export class Reader {
+ pos = 0;
+ constructor(private readonly buf: Uint8Array) {}
+
+ get remaining(): number {
+ return this.buf.length - this.pos;
+ }
+ bytes(n: number): Uint8Array {
+ if (n < 0 || this.pos + n > this.buf.length) throw new RangeError("read past end");
+ const out = this.buf.slice(this.pos, this.pos + n);
+ this.pos += n;
+ return out;
+ }
+ byte(): number {
+ return this.bytes(1)[0]!;
+ }
+ uint(size: number): bigint {
+ const b = this.bytes(size);
+ let n = 0n;
+ for (let i = size - 1; i >= 0; i--) n = (n << 8n) | BigInt(b[i]!);
+ return n;
+ }
+ int(size: number): bigint {
+ const n = this.uint(size);
+ const bits = BigInt(size * 8);
+ return n >= 1n << (bits - 1n) ? n - (1n << bits) : n;
+ }
+ varuint32(): number {
+ let v = 0;
+ let shift = 0;
+ for (;;) {
+ if (shift > 28) throw new RangeError("varuint32 too long");
+ const b = this.byte();
+ v |= (b & 0x7f) << shift;
+ if (!(b & 0x80)) return v >>> 0;
+ shift += 7;
+ }
+ }
+ varint32(): number {
+ const u = this.varuint32();
+ return u & 1 ? ~(u >>> 1) : u >>> 1;
+ }
+ blob(): Uint8Array {
+ return this.bytes(this.varuint32());
+ }
+}
+
+/* ------------------------------------------------------------------ names */
+
+const NAME_CHARS = ".12345abcdefghijklmnopqrstuvwxyz";
+
+/** Account / action / table names (up to 12 chars of [.1-5a-z], a 13th of [.1-5a-j]) as uint64. */
+export function nameToBigInt(name: string): bigint {
+ if (!isName(name)) throw new Error(`invalid name: ${name}`);
+ let v = 0n;
+ for (let i = 0; i < 13; i++) {
+ const c = i < name.length ? NAME_CHARS.indexOf(name[i]!) : 0;
+ if (i < 12) v |= BigInt(c & 0x1f) << BigInt(64 - 5 * (i + 1));
+ else v |= BigInt(c & 0x0f);
+ }
+ return v;
+}
+
+export function nameFromBigInt(v: bigint): string {
+ let out = "";
+ let tmp = v;
+ for (let i = 0; i <= 12; i++) {
+ const c = i === 0 ? Number(tmp & 0x0fn) : Number(tmp & 0x1fn);
+ out = NAME_CHARS[c]! + out;
+ tmp >>= i === 0 ? 4n : 5n;
+ }
+ return out.replace(/\.+$/, "");
+}
+
+/** A name the chain would store unchanged (no trailing dots, canonical round trip). */
+export function isName(name: string): boolean {
+ if (typeof name !== "string" || name.length > 13 || !/^[.1-5a-z]*$/.test(name)) return false;
+ if (name.length === 13 && !/[.1-5a-j]$/.test(name)) return false;
+ return !name.endsWith(".");
+}
+
+/** An account a person can be: 1–12 characters, [a-z1-5.], not ending with ".". */
+export function isAccountName(name: string): boolean {
+ return /^[a-z1-5.]{1,12}$/.test(name) && !name.endsWith(".") && !name.startsWith(".");
+}
+
+/* ------------------------------------------------------------------ symbols and assets */
+
+export interface Sym {
+ precision: number;
+ code: string;
+}
+
+export function symbolToBigInt(s: Sym): bigint {
+ if (!/^[A-Z]{1,7}$/.test(s.code) || s.precision < 0 || s.precision > 18) throw new Error(`invalid symbol ${s.precision},${s.code}`);
+ let v = BigInt(s.precision);
+ for (let i = 0; i < s.code.length; i++) v |= BigInt(s.code.charCodeAt(i)) << BigInt(8 * (i + 1));
+ return v;
+}
+
+export function symbolFromBigInt(v: bigint): Sym {
+ const precision = Number(v & 0xffn);
+ let code = "";
+ for (let i = 1; i < 8; i++) {
+ const c = Number((v >> BigInt(8 * i)) & 0xffn);
+ if (!c) break;
+ code += String.fromCharCode(c);
+ }
+ return { precision, code };
+}
+
+export interface Asset {
+ amount: bigint;
+ symbol: Sym;
+}
+
+/** "1.2345 EOS" (the precision is the number of decimals written). */
+export function parseAsset(text: string): Asset {
+ const m = /^(-?)(\d+)(?:\.(\d+))? ([A-Z]{1,7})$/.exec(text.trim());
+ if (!m) throw new Error(`invalid asset: ${text}`);
+ const frac = m[3] ?? "";
+ const amount = BigInt(`${m[2]}${frac}`) * (m[1] ? -1n : 1n);
+ return { amount, symbol: { precision: frac.length, code: m[4]! } };
+}
+
+export function formatAsset(a: Asset): string {
+ const neg = a.amount < 0n;
+ const v = neg ? -a.amount : a.amount;
+ const p = a.symbol.precision;
+ const s = v.toString().padStart(p + 1, "0");
+ const num = p ? `${s.slice(0, -p)}.${s.slice(-p)}` : s;
+ return `${neg ? "-" : ""}${num} ${a.symbol.code}`;
+}
+
+/* ------------------------------------------------------------------ keys and signatures */
+
+const keyCheck = (data: Uint8Array, suffix: string) => ripemd160(concat(data, utf8(suffix))).subarray(0, 4);
+
+/** "PUB_K1_…" or legacy "EOS…"/"PUB…" prefixed K1 key → 33 compressed bytes, checksums verified. */
+export function parsePublicKey(text: string): Uint8Array {
+ let raw: Uint8Array;
+ if (text.startsWith("PUB_K1_")) {
+ raw = base58.decode(text.slice(7));
+ if (raw.length !== 37 || !bytesEqual(keyCheck(raw.subarray(0, 33), "K1"), raw.subarray(33))) throw new Error("bad PUB_K1 checksum");
+ } else {
+ const m = /^([A-Z]{2,5})([1-9A-HJ-NP-Za-km-z]{50})$/.exec(text);
+ if (!m) throw new Error("not an Antelope public key");
+ raw = base58.decode(m[2]!);
+ if (raw.length !== 37 || !bytesEqual(ripemd160(raw.subarray(0, 33)).subarray(0, 4), raw.subarray(33))) throw new Error("bad legacy key checksum");
+ }
+ const key = raw.slice(0, 33);
+ secp256k1.Point.fromBytes(key); // must be on the curve
+ return key;
+}
+
+export function publicKeyString(compressed: Uint8Array): string {
+ const key = secp256k1.Point.fromBytes(compressed).toBytes(true);
+ return `PUB_K1_${base58.encode(concat(key, keyCheck(key, "K1")))}`;
+}
+
+export function isPublicKey(text: string): boolean {
+ try {
+ parsePublicKey(text);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Packed public_key: type byte (0 = K1) ‖ 33 bytes. */
+export function packPublicKey(text: string): Uint8Array {
+ return concat(new Uint8Array([0]), parsePublicKey(text));
+}
+
+/** r ‖ s + recovery id (0/1) → "SIG_K1_…": base58(i ‖ r ‖ s ‖ RIPEMD-160(i ‖ r ‖ s ‖ "K1")[0..4]), i = recid + 31. */
+export function signatureString(rs: Uint8Array, recovery: number): string {
+ if (rs.length !== 64 || (recovery !== 0 && recovery !== 1)) throw new Error("expected r||s and a recovery id");
+ const data = concat(new Uint8Array([recovery + 31]), rs);
+ return `SIG_K1_${base58.encode(concat(data, keyCheck(data, "K1")))}`;
+}
+
+/** Packed signature: type byte (0 = K1) ‖ 65 bytes. */
+export function packSignature(text: string): Uint8Array {
+ if (!text.startsWith("SIG_K1_")) throw new Error("only K1 signatures");
+ const raw = base58.decode(text.slice(7));
+ if (raw.length !== 69 || !bytesEqual(keyCheck(raw.subarray(0, 65), "K1"), raw.subarray(65))) throw new Error("bad SIG_K1 checksum");
+ return concat(new Uint8Array([0]), raw.subarray(0, 65));
+}
+
+/** Canonical K1 (Spring/Leap fc is_canonical): neither r nor s has its top bit set or a needless zero byte. */
+export function isCanonical(rs: Uint8Array): boolean {
+ const r = rs.subarray(0, 32);
+ const s = rs.subarray(32, 64);
+ return !(r[0]! & 0x80) && !(r[0] === 0 && !(r[1]! & 0x80)) && !(s[0]! & 0x80) && !(s[0] === 0 && !(s[1]! & 0x80));
+}
diff --git a/packages/chains-antelope/src/describe.ts b/packages/chains-antelope/src/describe.ts
new file mode 100644
index 00000000..a16c2a39
--- /dev/null
+++ b/packages/chains-antelope/src/describe.ts
@@ -0,0 +1,156 @@
+import { type BalanceChange, type NetworkId, type Warning, say } from "@clip-wallet/core";
+import { type Abi, AbiError, TOKEN_ABI, decodeActionData, isTokenTransfer } from "./abi.js";
+import { fromHex, parseAsset } from "./bytes.js";
+import { assetFor, knownTokens } from "./networks.js";
+import type { Rpc } from "./rpc.js";
+import type { ActionJson } from "./transaction.js";
+
+export interface Line {
+ label: string;
+ value: string;
+}
+
+export interface Described {
+ title: string;
+ lines: Line[];
+ balanceChanges: BalanceChange[];
+ warnings: Warning[];
+ blind: boolean;
+}
+
+export interface DescribeContext {
+ networkId: NetworkId;
+ /** This key's accounts on the network. */
+ mine: Set;
+ rpc: Rpc | null;
+}
+
+/** Actions on eosio that hand control of an account to other keys or accounts. */
+const AUTH_ACTIONS = new Set(["updateauth", "deleteauth", "linkauth", "unlinkauth"]);
+
+const danger = (code: Warning["code"], message: string): Warning => ({ level: "danger", code, message });
+const caution = (code: Warning["code"], message: string): Warning => ({ level: "caution", code, message });
+
+function short(v: unknown): string {
+ const s = typeof v === "string" ? v : JSON.stringify(v);
+ return s.length > 120 ? `${s.slice(0, 117)}…` : s;
+}
+
+/** The ABI to read an action with: eosio.token-style transfers of known token contracts need no network call. */
+export async function abiFor(a: ActionJson, dc: { networkId: NetworkId; rpc: Rpc | null }): Promise {
+ if (a.name === "transfer" && knownTokens(dc.networkId).some((t) => t.contract === a.account)) return TOKEN_ABI;
+ if (!dc.rpc) return null;
+ return dc.rpc.abi(a.account);
+}
+
+interface One {
+ title: string;
+ lines: Line[];
+ changes: BalanceChange[];
+ warnings: Warning[];
+ blind?: boolean;
+}
+
+async function describeAction(a: ActionJson, dc: DescribeContext): Promise {
+ let abi: Abi | null = null;
+ try {
+ abi = await abiFor(a, dc);
+ } catch {
+ abi = null;
+ }
+ let data: Record;
+ try {
+ if (!abi) throw new AbiError("no ABI");
+ data = decodeActionData(abi, a.name, fromHex(a.data));
+ } catch {
+ return { title: say("bg.antelope.callOn", { action: a.name, contract: a.account }), lines: [{ label: "Data", value: `0x${a.data.slice(0, 64)}${a.data.length > 64 ? "…" : ""}` }], changes: [], warnings: [], blind: true };
+ }
+
+ if (abi && isTokenTransfer(abi, a.name)) {
+ const from = String(data.from);
+ const to = String(data.to);
+ let q;
+ try {
+ q = parseAsset(String(data.quantity));
+ } catch {
+ return { title: say("bg.antelope.callOn", { action: a.name, contract: a.account }), lines: [], changes: [], warnings: [], blind: true };
+ }
+ const asset = assetFor(dc.networkId, a.account, q.symbol.code, q.symbol.precision);
+ const amount = `${String(data.quantity).split(" ")[0]} ${q.symbol.code}`;
+ const lines: Line[] = [{ label: "To", value: to }];
+ if (!dc.mine.has(from)) lines.unshift({ label: "From", value: from });
+ if (data.memo) lines.push({ label: "Memo", value: short(data.memo) });
+ lines.push({ label: "Token", value: `${q.symbol.code} · ${a.account}` });
+ const warnings: Warning[] = [];
+ if (asset.spam) warnings.push(danger("known-scam", say("bg.warn.spamToken", { symbol: q.symbol.code })));
+ else if (!knownTokens(dc.networkId).some((t) => t.contract === a.account && t.symbol === q.symbol.code)) {
+ warnings.push(caution("unknown-call", say("bg.antelope.unknownToken", { symbol: q.symbol.code, contract: a.account })));
+ }
+ const changes: BalanceChange[] = [];
+ if (dc.mine.has(from) && !dc.mine.has(to)) changes.push({ asset, delta: (-q.amount).toString() });
+ if (!dc.mine.has(from) && dc.mine.has(to)) changes.push({ asset, delta: q.amount.toString() });
+ const title = dc.mine.has(from) ? say("bg.req.sendTo", { amount, to }) : say("bg.req.othersSend", { from, amount, to });
+ if (dc.rpc && dc.mine.has(from) && !dc.mine.has(to)) {
+ const exists = await dc.rpc.account(to).catch(() => undefined);
+ if (exists === null) warnings.push(danger("simulation-failed", say("bg.antelope.noRecipient", { to })));
+ }
+ return { title, lines, changes, warnings };
+ }
+
+ const fields: Line[] = Object.entries(data).map(([k, v]) => ({ label: k, value: short(v) }));
+ if (a.account === "eosio" && AUTH_ACTIONS.has(a.name)) {
+ const account = String(data.account ?? "");
+ const permission = String(data.permission ?? data.requirement ?? "");
+ const title =
+ a.name === "updateauth"
+ ? say("bg.antelope.updateAuth", { permission, account })
+ : a.name === "deleteauth"
+ ? say("bg.antelope.deleteAuth", { permission, account })
+ : a.name === "linkauth"
+ ? say("bg.antelope.linkAuth", { permission: String(data.requirement ?? ""), action: `${String(data.code)}::${String(data.type || "*")}` })
+ : say("bg.antelope.unlinkAuth", { action: `${String(data.code)}::${String(data.type || "*")}` });
+ const warning = a.name === "unlinkauth" ? caution("account-takeover", say("bg.antelope.unlinkWarning")) : danger("account-takeover", say("bg.antelope.authWarning", { account: account || [...dc.mine][0] || "?" }));
+ return { title, lines: fields, changes: [], warnings: [warning] };
+ }
+
+ return {
+ title: say("bg.antelope.callOn", { action: a.name, contract: a.account }),
+ lines: fields,
+ changes: [],
+ warnings: [caution("unknown-call", "Clip Wallet can name this call but can't fully read it, so some of its effects may not be shown.")],
+ };
+}
+
+/** Reads every action. Blind when any action can't be read through its contract's ABI. */
+export async function describeActions(actions: ActionJson[], dc: DescribeContext): Promise {
+ const parts: One[] = [];
+ for (const a of actions) parts.push(await describeAction(a, dc));
+ const blind = parts.some((p) => p.blind);
+ const lines: Line[] = [];
+ if (parts.length === 1) lines.push(...parts[0]!.lines);
+ else {
+ parts.forEach((p, i) => {
+ lines.push({ label: say("bg.label.actionN", { n: i + 1 }), value: p.title });
+ lines.push(...p.lines);
+ });
+ }
+ for (const a of actions) {
+ for (const p of a.authorization) if (!dc.mine.has(p.actor)) lines.push({ label: say("bg.antelope.label.alsoSigns"), value: `${p.actor}@${p.permission}` });
+ }
+ const warnings = parts.flatMap((p) => p.warnings);
+ if (blind) warnings.unshift({ level: "danger", code: "blind-signing", message: "Clip Wallet can't read this transaction. Only sign it if you trust the app." });
+ // Net balance changes per asset.
+ const net = new Map();
+ for (const c of parts.flatMap((p) => p.changes)) {
+ const k = `${c.asset.key}/${c.asset.address ?? ""}`;
+ const prev = net.get(k);
+ net.set(k, { asset: c.asset, delta: (BigInt(prev?.delta ?? "0") + BigInt(c.delta)).toString() });
+ }
+ return {
+ title: parts.length === 1 ? parts[0]!.title : say("bg.req.approveOps", { count: parts.length }),
+ lines,
+ balanceChanges: [...net.values()].filter((c) => c.delta !== "0"),
+ warnings,
+ blind,
+ };
+}
diff --git a/packages/chains-antelope/src/index.ts b/packages/chains-antelope/src/index.ts
new file mode 100644
index 00000000..1cec9d45
--- /dev/null
+++ b/packages/chains-antelope/src/index.ts
@@ -0,0 +1,28 @@
+export { ANTELOPE_METHODS, type AntelopeModuleOptions, createAntelopeModule, normalize } from "./module.js";
+export {
+ ANTELOPE_NETS,
+ ANTELOPE_NETWORKS,
+ JUNGLE4,
+ TELOS_MAINNET,
+ TELOS_TESTNET,
+ VAULTA_MAINNET,
+ XPR_MAINNET,
+ XPR_TESTNET,
+ type AntelopeNet,
+ type AntelopeNetSpec,
+ type AntelopeToken,
+ assetFor,
+ caip2Of,
+ explorerTxUrl,
+ knownTokens,
+ netOf,
+} from "./networks.js";
+export { type Abi, AbiError, TOKEN_ABI, decodeActionData, encodeActionData, isTokenTransfer } from "./abi.js";
+export { formatAsset, isAccountName, isPublicKey, nameFromBigInt, nameToBigInt, parseAsset, parsePublicKey, publicKeyString, signatureString } from "./bytes.js";
+export { type ActionJson, type TransactionJson, packTransaction, signingDigest, tapos, transactionId, unpackTransaction } from "./transaction.js";
+export { AntelopeRpcError, type KeyAccount, clearAbiCache, plainAntelopeError } from "./rpc.js";
+
+import { createAntelopeModule } from "./module.js";
+
+/** Default instance. */
+export const antelopeModule = createAntelopeModule();
diff --git a/packages/chains-antelope/src/module.ts b/packages/chains-antelope/src/module.ts
new file mode 100644
index 00000000..7581e8b2
--- /dev/null
+++ b/packages/chains-antelope/src/module.ts
@@ -0,0 +1,385 @@
+import {
+ type AssetRef,
+ type ChainContext,
+ type ChainModule,
+ ClipError,
+ type DappRequest,
+ type DecodedRequest,
+ type Network,
+ type Nft,
+ type Signature,
+ type SignablePayload,
+ type TokenBalance,
+ WALLET_ORIGIN,
+ type Warning,
+ msg,
+ say,
+} from "@clip-wallet/core";
+import { secp256k1 } from "@noble/curves/secp256k1.js";
+import { AbiError, TOKEN_ABI, encodeActionData } from "./abi.js";
+import { fromHex, hex, isAccountName, isCanonical, isName, parseAsset, parsePublicKey, publicKeyString, signatureString, formatAsset } from "./bytes.js";
+import { abiFor, describeActions } from "./describe.js";
+import { assetFor, knownTokens, specFor, tokenAssetOf, type AntelopeToken } from "./networks.js";
+import { AntelopeRpcError, type KeyAccount, type Rpc, plainAntelopeError, rpcFor } from "./rpc.js";
+import { type ActionJson, type TransactionJson, expirationSeconds, expirationText, packTransaction, signingDigest, tapos, transactionId } from "./transaction.js";
+
+/**
+ * Request methods (Clip Wallet's own; no wallet-side Antelope standard exists to implement, see README):
+ * - antelope_signTransaction: { transaction, account? } → { signatures, packed_trx, transaction_id }
+ * - antelope_signAndPushTransaction: same → { transaction_id, processed? } (sent with send_transaction2)
+ * `transaction` is the abieos JSON of a transaction; the header (expiration, ref_block_num, ref_block_prefix) may be
+ * left out for the wallet to fill. Action `data` is packed hex, or an object serialised with the contract's ABI.
+ * buildTransfer uses antelope_signAndPushTransaction with WALLET_ORIGIN.
+ */
+export const ANTELOPE_METHODS = {
+ signTransaction: "antelope_signTransaction",
+ signAndPushTransaction: "antelope_signAndPushTransaction",
+} as const;
+
+export interface AntelopeModuleOptions {
+ /** Seconds until a filled-in transaction expires (default 300). */
+ expireSeconds?: number;
+ /** How long discovered account names are kept (default 60 s). */
+ accountsTtlMs?: number;
+}
+
+interface Normalized {
+ header: Partial;
+ actions: { account: string; name: string; authorization: { actor: string; permission: string }[]; data: unknown }[];
+ push: boolean;
+ needsHeader: boolean;
+}
+
+const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v);
+
+function malformed(): ClipError {
+ return new ClipError("This request from the app is malformed, so we stopped it.", "antelope/bad-params");
+}
+
+export function normalize(request: DappRequest, ctx: ChainContext): Normalized {
+ if (request.method !== ANTELOPE_METHODS.signTransaction && request.method !== ANTELOPE_METHODS.signAndPushTransaction) {
+ throw new ClipError("Clip Wallet doesn't support this kind of request yet.", "antelope/unsupported-method");
+ }
+ if (!specFor(request.networkId) || request.networkId !== ctx.network.id) {
+ throw new ClipError(msg("bg.antelope.err.wrongNetwork"), "antelope/network-mismatch");
+ }
+ const p = request.params;
+ if (!isObj(p)) throw malformed();
+ const t = isObj(p.transaction) ? p.transaction : p;
+ if (p.chainId !== undefined && (typeof p.chainId !== "string" || specFor(p.chainId)?.chainId !== specFor(ctx.network.id)?.chainId)) {
+ throw new ClipError(msg("bg.antelope.err.wrongNetwork"), "antelope/network-mismatch");
+ }
+ if (!Array.isArray(t.actions) || !t.actions.length) throw malformed();
+ if ((Array.isArray(t.context_free_actions) && t.context_free_actions.length) || (Array.isArray(t.transaction_extensions) && t.transaction_extensions.length)) {
+ throw new ClipError(msg("bg.antelope.err.contextFree"), "antelope/unsupported");
+ }
+ const actions = t.actions.map((a) => {
+ if (!isObj(a) || typeof a.account !== "string" || typeof a.name !== "string" || !isName(a.account) || !isName(a.name) || !Array.isArray(a.authorization)) throw malformed();
+ const authorization = a.authorization.map((x) => {
+ if (!isObj(x) || typeof x.actor !== "string" || typeof x.permission !== "string" || !isName(x.actor) || !isName(x.permission)) throw malformed();
+ return { actor: x.actor, permission: x.permission };
+ });
+ return { account: a.account, name: a.name, authorization, data: a.data };
+ });
+ const header: Partial = {};
+ for (const k of ["expiration", "ref_block_num", "ref_block_prefix", "max_net_usage_words", "max_cpu_usage_ms", "delay_sec"] as const) {
+ if (t[k] !== undefined) (header as Record)[k] = t[k];
+ }
+ const needsHeader = header.expiration === undefined || header.ref_block_num === undefined || header.ref_block_prefix === undefined;
+ return { header, actions, push: request.method === ANTELOPE_METHODS.signAndPushTransaction, needsHeader };
+}
+
+export function createAntelopeModule(options: AntelopeModuleOptions = {}): ChainModule & {
+ normalize: typeof normalize;
+ accounts(ctx: ChainContext): Promise;
+ receiveAddress(ctx: ChainContext): Promise;
+} {
+ const expireSeconds = options.expireSeconds ?? 300;
+ const ttl = options.accountsTtlMs ?? 60_000;
+ const accountCache = new Map();
+ /** Per request: the exact transaction decode showed (header filled once). */
+ const built = new Map();
+ const remember = (id: string, v: { tx: TransactionJson; packed: Uint8Array }) => {
+ built.set(id, v);
+ if (built.size > 64) built.delete(built.keys().next().value!);
+ };
+
+ function keyOf(ctx: ChainContext): string {
+ return ctx.account.address.startsWith("PUB_K1_") ? ctx.account.address : publicKeyString(fromHex(ctx.account.publicKey));
+ }
+
+ async function accounts(ctx: ChainContext): Promise {
+ const k = `${ctx.network.id}/${keyOf(ctx)}`;
+ const hit = accountCache.get(k);
+ if (hit && Date.now() - hit.at < ttl) return hit.list;
+ const list = await rpcFor(ctx).accountsForKey(keyOf(ctx));
+ accountCache.set(k, { at: Date.now(), list });
+ return list;
+ }
+
+ function noAccount(ctx: ChainContext): ClipError {
+ return new ClipError(msg(ctx.network.testnet ? "bg.antelope.err.noAccountTestnet" : "bg.antelope.err.noAccount", { network: ctx.network.name }), "antelope/no-account");
+ }
+
+ async function firstAccount(ctx: ChainContext): Promise {
+ const list = await accounts(ctx);
+ const pick = list.find((a) => a.permission === "active") ?? list[0];
+ if (!pick) throw noAccount(ctx);
+ return pick.account;
+ }
+
+ async function header(rpc: Rpc, h: Partial): Promise> {
+ if (h.expiration !== undefined && h.ref_block_num !== undefined && h.ref_block_prefix !== undefined) {
+ return { expiration: String(h.expiration), ref_block_num: Number(h.ref_block_num), ref_block_prefix: Number(h.ref_block_prefix) };
+ }
+ const info = await rpc.info();
+ const head = expirationSeconds(info.head_block_time.replace(/\.\d+$/, ""));
+ return { expiration: h.expiration !== undefined ? String(h.expiration) : expirationText(head + expireSeconds), ...tapos(info.last_irreversible_block_id) };
+ }
+
+ async function build(n: Normalized, ctx: ChainContext, rpc: Rpc): Promise<{ tx: TransactionJson; packed: Uint8Array }> {
+ const actions: ActionJson[] = [];
+ for (const a of n.actions) {
+ let data: string;
+ if (typeof a.data === "string" && /^([0-9a-fA-F]{2})*$/.test(a.data)) data = a.data.toLowerCase();
+ else if (isObj(a.data)) {
+ const abi = await abiFor({ ...a, data: "" }, { networkId: ctx.network.id, rpc });
+ if (!abi) throw new ClipError(msg("bg.antelope.err.noAbi", { contract: a.account }), "antelope/unreadable-operation");
+ try {
+ data = hex(encodeActionData(abi, a.name, a.data));
+ } catch (e) {
+ if (e instanceof AbiError) throw new ClipError(msg("bg.antelope.err.noAbi", { contract: a.account }), "antelope/unreadable-operation", e);
+ throw e;
+ }
+ } else throw malformed();
+ actions.push({ account: a.account, name: a.name, authorization: a.authorization, data });
+ }
+ const h = await header(rpc, n.header);
+ const tx: TransactionJson = {
+ ...h,
+ max_net_usage_words: Number(n.header.max_net_usage_words ?? 0),
+ max_cpu_usage_ms: Number(n.header.max_cpu_usage_ms ?? 0),
+ delay_sec: Number(n.header.delay_sec ?? 0),
+ context_free_actions: [],
+ actions,
+ transaction_extensions: [],
+ };
+ let packed: Uint8Array;
+ try {
+ packed = packTransaction(tx);
+ } catch (e) {
+ throw new ClipError("This request from the app is malformed, so we stopped it.", "antelope/bad-params", e);
+ }
+ return { tx, packed };
+ }
+
+ /** Every authorization must be one this key holds (sign-and-push), or at least one (sign only: others co-sign). */
+ function checkAuth(tx: TransactionJson, mine: KeyAccount[], push: boolean): void {
+ const holds = (p: { actor: string; permission: string }) => mine.some((m) => m.account === p.actor && (m.permission === p.permission || (m.permission === "owner" && p.permission === "active")));
+ const auths = tx.actions.flatMap((a) => a.authorization);
+ const ours = auths.filter(holds);
+ if (!ours.length) {
+ if (auths.some((p) => mine.some((m) => m.account === p.actor))) throw new ClipError(msg("bg.antelope.err.permission"), "antelope/wrong-account");
+ throw new ClipError("This transaction doesn't need your signature.", "antelope/wrong-account");
+ }
+ if (push && ours.length !== auths.length) throw new ClipError(msg("bg.antelope.err.otherSigners"), "antelope/missing-signature");
+ }
+
+ async function decode(req: DappRequest, ctx: ChainContext): Promise {
+ const n = normalize(req, ctx);
+ const rpc = rpcFor(ctx);
+ const mine = await accounts(ctx);
+ if (!mine.length) throw noAccount(ctx);
+ const b = await build(n, ctx, rpc);
+ checkAuth(b.tx, mine, n.push);
+ remember(req.id, b);
+ const spec = specFor(ctx.network.id);
+ const d = await describeActions(b.tx.actions, { networkId: ctx.network.id, mine: new Set(mine.map((m) => m.account)), rpc });
+ const warnings: Warning[] = [...d.warnings];
+ if (b.tx.delay_sec > 0) warnings.push({ level: "caution", code: "durable-nonce", message: say("bg.antelope.delayed", { seconds: b.tx.delay_sec }) });
+ const lines = [...d.lines];
+ lines.push({ label: "Network fee", value: spec?.resources === "free" ? say("bg.antelope.feeFree") : say("bg.antelope.feeResources") });
+ if (!n.push) lines.push({ label: "Sent by", value: say("bg.antelope.appSends", { host: hostOf(req.origin) }) });
+ return { requestId: req.id, networkId: req.networkId, title: d.title, lines, balanceChanges: d.balanceChanges, simulated: false, blind: d.blind, warnings };
+ }
+
+ async function builtFor(req: DappRequest, ctx: ChainContext): Promise<{ tx: TransactionJson; packed: Uint8Array }> {
+ const cached = built.get(req.id);
+ if (cached) return cached;
+ const n = normalize(req, ctx);
+ if (n.needsHeader) throw new ClipError("This request expired. Please try again from the app.", "antelope/not-prepared");
+ const b = await build(n, ctx, rpcFor(ctx));
+ checkAuth(b.tx, await accounts(ctx), n.push);
+ remember(req.id, b);
+ return b;
+ }
+
+ async function prepare(req: DappRequest, ctx: ChainContext, approvalId: string): Promise {
+ const b = await builtFor(req, ctx);
+ const spec = specFor(ctx.network.id)!;
+ return [{ accountId: ctx.account.id, scheme: "ecdsa-secp256k1", bytes: signingDigest(spec.chainId, b.packed), approvalId }];
+ }
+
+ async function finalize(req: DappRequest, signatures: Signature[], ctx: ChainContext): Promise {
+ const n = normalize(req, ctx);
+ const b = built.get(req.id);
+ if (!b) throw new ClipError("This request expired. Please try again from the app.", "antelope/not-prepared");
+ const spec = specFor(ctx.network.id)!;
+ const digest = signingDigest(spec.chainId, b.packed);
+ const sig = signatures[0];
+ const pub = parsePublicKey(keyOf(ctx));
+ if (
+ signatures.length !== 1 ||
+ !sig ||
+ sig.scheme !== "ecdsa-secp256k1" ||
+ sig.bytes.length !== 64 ||
+ (sig.recovery !== 0 && sig.recovery !== 1) ||
+ !isCanonical(sig.bytes) ||
+ !secp256k1.verify(sig.bytes, digest, pub, { prehash: false }) ||
+ !recoversTo(sig.bytes, sig.recovery, digest, pub)
+ ) {
+ throw new ClipError("The signature didn't match. Nothing was sent.", "antelope/bad-signature");
+ }
+ const sigText = signatureString(sig.bytes, sig.recovery);
+ const packedHex = hex(b.packed);
+ const id = transactionId(b.packed);
+ built.delete(req.id);
+ if (!n.push) return { signatures: [sigText], packed_trx: packedHex, transaction_id: id };
+ try {
+ const r = await rpcFor(ctx).send(packedHex, [sigText]);
+ return { transaction_id: r.transaction_id ?? id, ...(r.processed ? { processed: r.processed } : {}) };
+ } catch (e) {
+ if (e instanceof AntelopeRpcError) {
+ const m = plainAntelopeError(e, spec, ctx.network.name);
+ const resource = /cpu|net|ram/i.test(m.id);
+ throw new ClipError(m, resource ? "antelope/insufficient-resources" : "antelope/send-failed", e);
+ }
+ throw e;
+ }
+ }
+
+ async function getBalances(ctx: ChainContext): Promise