diff --git a/.changeset/config.json b/.changeset/config.json
index d88011f..cb78d07 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -4,8 +4,8 @@
"commit": false,
"fixed": [],
"linked": [],
- "access": "restricted",
+ "access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
- "ignore": []
+ "ignore": ["@hermis/solana-headless-enterprise-core"]
}
diff --git a/.changeset/pre.json b/.changeset/pre.json
deleted file mode 100644
index c91a458..0000000
--- a/.changeset/pre.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "mode": "pre",
- "tag": "beta",
- "initialVersions": {
- "sample-dapp": "1.0.8",
- "sample-react-dapp": "0.0.13",
- "@hermis/solana-headless-adapter-base": "1.1.2",
- "@hermis/solana-headless-core": "1.1.2",
- "@hermis/solana-headless-enterprise-core": "0.0.4",
- "@hermis/errors": "1.0.0",
- "@hermis/solana-headless-format-config": "1.0.5",
- "@hermis/solana-headless-react": "1.1.2",
- "@hermis/wallet-standard-base": "1.0.0"
- },
- "changesets": [
- "hot-drinks-hear",
- "wise-knives-press"
- ]
-}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e3c69f9..90281ec 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -254,7 +254,7 @@ Add useTokenBalance hook for querying SPL token balances
## Documentation
-Our documentation is built with **Mintlify** and hosted at [docs.hermis.io](https://docs.hermis.io).
+Our documentation is built with **Mintlify** and hosted at [docs.hermis.dev](https://docs.hermis.dev).
### Contributing to Docs
@@ -367,7 +367,7 @@ Use workspace protocol for internal dependencies:
If you have questions or need help:
-1. Check the [documentation](https://docs.hermis.io)
+1. Check the [documentation](https://docs.hermis.dev)
2. Search existing [GitHub Issues](https://github.com/Assylum-Labs/hermis/issues)
3. Open a new issue with your question
4. Be specific and provide context
diff --git a/README.MD b/README.MD
index 62ea4ea..29c6855 100644
--- a/README.MD
+++ b/README.MD
@@ -7,12 +7,12 @@ A modular, UI-agnostic wallet adapter for Solana applications. Build wallet inte
## Documentation
-Comprehensive documentation is available at **[docs.hermis.io](https://docs.hermis.io)**
+Comprehensive documentation is available at **[docs.hermis.dev](https://docs.hermis.dev)**
-- [Getting Started](https://docs.hermis.io/getting-started)
-- [Quick Start Guides](https://docs.hermis.io/quickstart)
-- [API Reference](https://docs.hermis.io/api-reference)
-- [Examples](https://docs.hermis.io/examples)
+- [Getting Started](https://docs.hermis.dev/getting-started)
+- [Quick Start Guides](https://docs.hermis.dev/quickstart)
+- [API Reference](https://docs.hermis.dev/api-reference)
+- [Examples](https://docs.hermis.dev/examples)
## Packages
diff --git a/apps/sample-dapp/CHANGELOG.md b/apps/sample-dapp/CHANGELOG.md
index b8714e1..e1fde7b 100644
--- a/apps/sample-dapp/CHANGELOG.md
+++ b/apps/sample-dapp/CHANGELOG.md
@@ -1,5 +1,14 @@
# sample-dapp
+## 1.0.10
+
+### Patch Changes
+
+- Updated dependencies [b0d2b24]
+ - @hermis/solana-headless-core@2.1.0
+ - @hermis/solana-headless-adapter-base@2.1.0
+ - @hermis/wallet-standard-base@1.1.0
+
## 1.0.9
### Patch Changes
diff --git a/apps/sample-dapp/package.json b/apps/sample-dapp/package.json
index c7fd094..28c58f0 100644
--- a/apps/sample-dapp/package.json
+++ b/apps/sample-dapp/package.json
@@ -1,6 +1,7 @@
{
"name": "sample-dapp",
- "version": "1.0.9",
+ "version": "1.0.10",
+ "private": true,
"description": "",
"main": "index.js",
"type": "module",
diff --git a/apps/sample-dapp/src/docs-validation-ui.ts b/apps/sample-dapp/src/docs-validation-ui.ts
index 8c1c05d..1af184f 100644
--- a/apps/sample-dapp/src/docs-validation-ui.ts
+++ b/apps/sample-dapp/src/docs-validation-ui.ts
@@ -11,7 +11,7 @@ import {
sortWalletAdapters,
getAdaptersByReadyState
} from '@hermis/solana-headless-adapter-base';
-import { Adapter, WalletReadyState } from '@hermis/solana-headless-core';
+import { Adapter, WalletAdapterNetwork, WalletReadyState } from '@hermis/solana-headless-core';
import {
testBasicTransferWeb3,
testBasicTransferKit,
@@ -90,7 +90,7 @@ async function initializeApp() {
async function initializeWalletManager() {
try {
// Single call - everything built-in with adapter management
- manager = createWalletConnectionManager([], 'selectedWallet', 'https://api.devnet.solana.com');
+ manager = createWalletConnectionManager([], WalletAdapterNetwork.Devnet, 'selectedWallet', 'https://api.devnet.solana.com');
// Subscribe to adapter changes (handles both initial load and dynamic updates)
manager.onAdaptersChange((adapters) => {
diff --git a/apps/sample-react-dapp/CHANGELOG.md b/apps/sample-react-dapp/CHANGELOG.md
index bb5488d..a87ed73 100644
--- a/apps/sample-react-dapp/CHANGELOG.md
+++ b/apps/sample-react-dapp/CHANGELOG.md
@@ -1,5 +1,14 @@
# sample-react-dapp
+## 0.0.15
+
+### Patch Changes
+
+- Updated dependencies [b0d2b24]
+ - @hermis/solana-headless-core@2.1.0
+ - @hermis/solana-headless-adapter-base@2.1.0
+ - @hermis/solana-headless-react@2.1.0
+
## 0.0.14
### Patch Changes
diff --git a/apps/sample-react-dapp/package.json b/apps/sample-react-dapp/package.json
index 54c5cad..62c1c81 100644
--- a/apps/sample-react-dapp/package.json
+++ b/apps/sample-react-dapp/package.json
@@ -1,7 +1,7 @@
{
"name": "sample-react-dapp",
"private": true,
- "version": "0.0.14",
+ "version": "0.0.15",
"type": "module",
"scripts": {
"dev": "vite --host",
diff --git a/apps/sample-react-dapp/vite.config.ts b/apps/sample-react-dapp/vite.config.ts
index 3fc0d43..4184fb2 100644
--- a/apps/sample-react-dapp/vite.config.ts
+++ b/apps/sample-react-dapp/vite.config.ts
@@ -14,6 +14,8 @@ export default defineConfig(({ command }) => {
define: {
global: 'globalThis',
'process.env': {},
+ // Enable development mode for better error messages from @hermis/errors
+ __DEV__: !isProduction,
},
resolve: {
alias: {
diff --git a/docs/api-reference/adapter-base/kit-integration.mdx b/docs/api-reference/adapter-base/kit-integration.mdx
index 3735640..a39b172 100644
--- a/docs/api-reference/adapter-base/kit-integration.mdx
+++ b/docs/api-reference/adapter-base/kit-integration.mdx
@@ -14,11 +14,13 @@ The Kit integration utilities allow you to convert legacy wallet adapters into K
Convert a wallet adapter to Kit-compatible signers.
```typescript
-import { createKitSignersFromAdapter, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { createKitSignersFromAdapter } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { Connection } from '@solana/web3.js';
// Auto-detect wallet-standard compatible wallets
-const adapters = getStandardWalletAdapters([]);
+const adapters = await getStandardWalletAdapters([], undefined, WalletAdapterNetwork.Mainnet);
const adapter = adapters[0]; // Use first detected wallet
await adapter.connect();
@@ -69,7 +71,9 @@ const { address, addressString, messageSigner, transactionSigner } =
### Usage with Kit
```typescript
-import { createKitSignersFromAdapter, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { createKitSignersFromAdapter } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { createSolanaRpc, devnet } from '@solana/kit';
import { getTransferSolInstruction } from '@solana-program/system';
import {
@@ -81,7 +85,7 @@ import {
} from '@solana/kit';
// Auto-detect wallet-standard compatible wallets
-const adapters = getStandardWalletAdapters([]);
+const adapters = await getStandardWalletAdapters([], undefined, WalletAdapterNetwork.Mainnet);
const adapter = adapters[0]; // Use first detected wallet
await adapter.connect();
@@ -314,15 +318,16 @@ import {
createKitSignersFromAdapter,
createKitTransaction,
adapterSupportsMessageSigning,
- adapterSupportsTransactionSending,
- getStandardWalletAdapters
+ adapterSupportsTransactionSending
} from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { createSolanaRpc, devnet } from '@solana/kit';
import { getTransferSolInstruction } from '@solana-program/system';
import { lamports } from '@solana/kit';
// Auto-detect wallet-standard compatible wallets
-const adapters = getStandardWalletAdapters([]);
+const adapters = await getStandardWalletAdapters([], undefined, WalletAdapterNetwork.Mainnet);
const adapter = adapters[0]; // Use first detected wallet
await adapter.connect();
diff --git a/docs/api-reference/adapter-base/overview.mdx b/docs/api-reference/adapter-base/overview.mdx
index f5f5468..a8f73a7 100644
--- a/docs/api-reference/adapter-base/overview.mdx
+++ b/docs/api-reference/adapter-base/overview.mdx
@@ -39,10 +39,15 @@ await manager.disconnect();
Get all standard wallet adapters including custom ones.
+> **Breaking change in v2:** `network` is now a required argument. Inferring the
+> cluster from the RPC endpoint URL has been removed — substring matching is
+> unsafe because untrusted URLs containing "mainnet" would be misclassified.
+
```typescript
-import { getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
-const adapters = getStandardWalletAdapters(customAdapters);
+const adapters = await getStandardWalletAdapters(customAdapters, undefined, WalletAdapterNetwork.Mainnet);
```
### sortWalletAdapters()
diff --git a/docs/api-reference/adapter-base/wallet-connect.mdx b/docs/api-reference/adapter-base/wallet-connect.mdx
index 68496be..952a46b 100644
--- a/docs/api-reference/adapter-base/wallet-connect.mdx
+++ b/docs/api-reference/adapter-base/wallet-connect.mdx
@@ -8,14 +8,17 @@ description: 'Manage wallet connections in vanilla TypeScript'
Factory function that creates a wallet connection manager for vanilla TypeScript applications.
```typescript
-import { createWalletConnectionManager, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { createWalletConnectionManager } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
-const adapters = getStandardWalletAdapters(wallets);
+const adapters = await getStandardWalletAdapters(wallets, undefined, WalletAdapterNetwork.Mainnet);
const manager = createWalletConnectionManager(
adapters,
+ WalletAdapterNetwork.Mainnet,
'wallet-name-storage-key',
'https://api.devnet.solana.com' // Optional: for wallet detection
);
@@ -27,6 +30,12 @@ const manager = createWalletConnectionManager(
Array of wallet adapters to manage
+
+ Active Solana network. Required so wallet adapters can set their chain hint
+ explicitly — previous versions inferred this from the RPC URL which was
+ unsafe (untrusted URLs containing "mainnet" would be misclassified).
+
+
Key to store the selected wallet name in localStorage
@@ -214,12 +223,14 @@ unsubscribe();
## Complete Example
```typescript
-import { createWalletConnectionManager, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { createWalletConnectionManager } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { Connection, Transaction, SystemProgram, LAMPORTS_PER_SOL } from '@solana/web3.js';
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
-const adapters = getStandardWalletAdapters(wallets);
+const adapters = await getStandardWalletAdapters(wallets, undefined, WalletAdapterNetwork.Mainnet);
// Create manager
const manager = createWalletConnectionManager(
diff --git a/docs/cookbook/connect-wallet.mdx b/docs/cookbook/connect-wallet.mdx
index 0be3daa..8e64856 100644
--- a/docs/cookbook/connect-wallet.mdx
+++ b/docs/cookbook/connect-wallet.mdx
@@ -60,11 +60,13 @@ function WalletSelector() {
## Vanilla JavaScript
```javascript
-import { WalletAdapterManager, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { WalletAdapterManager } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
-const adapters = getStandardWalletAdapters(wallets);
+const adapters = await getStandardWalletAdapters(wallets, undefined, WalletAdapterNetwork.Mainnet);
const manager = new WalletAdapterManager(adapters);
manager.on('connect', (publicKey) => {
diff --git a/docs/examples/vanilla-js.mdx b/docs/examples/vanilla-js.mdx
index 68253d5..44ea276 100644
--- a/docs/examples/vanilla-js.mdx
+++ b/docs/examples/vanilla-js.mdx
@@ -175,7 +175,9 @@ h1 {
```javascript app.js (Web3.js)
-import { WalletAdapterManager, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { WalletAdapterManager } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { Connection, clusterApiUrl, LAMPORTS_PER_SOL } from '@solana/web3.js';
// Global state
@@ -183,12 +185,12 @@ let manager;
let connection;
// Initialize wallet manager and connection
-function initWalletManager() {
+async function initWalletManager() {
connection = new Connection(clusterApiUrl('devnet'));
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
- const adapters = getStandardWalletAdapters(wallets);
+ const adapters = await getStandardWalletAdapters(wallets, connection.rpcEndpoint, WalletAdapterNetwork.Devnet);
manager = new WalletAdapterManager(adapters);
setupEventListeners();
@@ -281,7 +283,9 @@ initWalletManager();
```
```javascript app.js (Kit)
-import { WalletAdapterManager, createKitSignersFromAdapter, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { WalletAdapterManager, createKitSignersFromAdapter } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { Connection, clusterApiUrl } from '@solana/web3.js';
// Global state
@@ -290,12 +294,12 @@ let connection;
let kitSigners;
// Initialize wallet manager with Kit support
-function initWalletManager() {
+async function initWalletManager() {
connection = new Connection(clusterApiUrl('devnet'));
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
- const adapters = getStandardWalletAdapters(wallets);
+ const adapters = await getStandardWalletAdapters(wallets, connection.rpcEndpoint, WalletAdapterNetwork.Devnet);
manager = new WalletAdapterManager(adapters);
setupEventListeners();
diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx
index 515ad56..8777e7e 100644
--- a/docs/getting-started.mdx
+++ b/docs/getting-started.mdx
@@ -113,10 +113,9 @@ function WalletButton() {
```
```typescript Adapter Base (web3.js)
-import {
- WalletAdapterManager,
- getStandardWalletAdapters
-} from '@hermis/solana-headless-adapter-base';
+import { WalletAdapterManager } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { Connection, clusterApiUrl } from '@solana/web3.js';
// Create connection
@@ -124,7 +123,7 @@ const connection = new Connection(clusterApiUrl('devnet'));
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
-const adapters = getStandardWalletAdapters(wallets);
+const adapters = await getStandardWalletAdapters(wallets, connection.rpcEndpoint, WalletAdapterNetwork.Devnet);
// Create wallet manager
const manager = new WalletAdapterManager(adapters);
@@ -144,10 +143,9 @@ await manager.connect();
```
```typescript Adapter Base (@solana/kit)
-import {
- WalletAdapterManager,
- getStandardWalletAdapters
-} from '@hermis/solana-headless-adapter-base';
+import { WalletAdapterManager } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
import { createSolanaRpc, devnet } from '@solana/kit';
// Create connection
@@ -155,7 +153,7 @@ const rpc = createSolanaRpc(devnet('https://api.devnet.solana.com'));
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
-const adapters = getStandardWalletAdapters(wallets);
+const adapters = await getStandardWalletAdapters(wallets, undefined, WalletAdapterNetwork.Devnet);
// Create wallet manager
const manager = new WalletAdapterManager(adapters);
diff --git a/docs/installation.mdx b/docs/installation.mdx
index 108f919..6b56c03 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -150,11 +150,13 @@ After installation, verify everything works:
**For Adapter Base:**
```typescript
-import { WalletAdapterManager, getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+import { WalletAdapterManager } from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
-const adapters = getStandardWalletAdapters(wallets);
+const adapters = await getStandardWalletAdapters(wallets, undefined, WalletAdapterNetwork.Mainnet);
const manager = new WalletAdapterManager(adapters);
console.log('Hermis Adapter Base initialized successfully!');
```
diff --git a/docs/migration/v1-to-v2.mdx b/docs/migration/v1-to-v2.mdx
index cf36c53..2237ac9 100644
--- a/docs/migration/v1-to-v2.mdx
+++ b/docs/migration/v1-to-v2.mdx
@@ -129,6 +129,77 @@ Improved wallet detection with CAIP-2 compliant chain identifiers:
If you were using HermisProvider in early/pre-release versions, the following props were renamed for consistency:
+### `signMessage` now applies the Solana off-chain message domain separator on local-key paths
+
+When the SDK signs a message with an in-SDK key (`Keypair`, `CryptoKeyPair`,
+`KeyPairSigner`), it now prepends the Solana off-chain message header
+(`0xff` + `"solana offchain"` + version + format + length) before signing.
+Without this domain separator, signature bytes are structurally
+indistinguishable from a Solana transaction signature, and a caller can be
+tricked into producing a valid transaction signature by handing the signer
+bytes that happen to encode a Solana transaction message.
+
+This is a behaviour change for **local-key signers only**. External wallets
+(Phantom / Backpack / Solflare / any Standard Wallet or Adapter) already
+apply this header themselves — those paths are unchanged.
+
+**If you verify these signatures server-side**, apply the same domain header
+to the message before verification:
+
+```typescript
+const SIGNING_DOMAIN = Buffer.concat([
+ Buffer.from([0xff]),
+ Buffer.from('solana offchain', 'utf-8'),
+]);
+
+function withOffchainPrefix(message: Uint8Array): Uint8Array {
+ const header = Buffer.alloc(4);
+ header.writeUInt8(0, 0); // header version
+ header.writeUInt8(1, 1); // format: UTF-8
+ header.writeUInt16LE(message.length, 2); // length, LE
+ return Buffer.concat([SIGNING_DOMAIN, header, Buffer.from(message)]);
+}
+
+// Then verify against the wrapped bytes:
+nacl.sign.detached.verify(withOffchainPrefix(originalMessage), signature, publicKey);
+```
+
+### Explicit network required (no more URL inference)
+
+v2 removes substring-based cluster inference from RPC endpoint URLs. URLs whose
+path happens to contain `mainnet` or `devnet` were silently classified as that
+cluster — unsafe whenever the URL came from an untrusted source.
+
+`getStandardWalletAdapters` and `createWalletConnectionManager` now require
+an explicit `network` argument:
+
+
+```typescript Before (v1)
+import { getStandardWalletAdapters } from '@hermis/solana-headless-adapter-base';
+
+const adapters = await getStandardWalletAdapters(wallets, endpoint);
+```
+
+```typescript After (v2)
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
+
+const adapters = await getStandardWalletAdapters(
+ wallets,
+ endpoint,
+ WalletAdapterNetwork.Mainnet, // required
+);
+```
+
+
+`detectClusterFromEndpoint` and `getInferredNetworkFromEndpoint` have been
+removed from the public API. `StandardWalletAdapter` no longer derives its
+cluster from `setRpcEndpoint`; call the new `setNetwork(network)` method
+instead — `HermisProvider` does this automatically when you pass `network`.
+
+`HermisProvider`'s `network` prop was already required after v2.0; no change
+is needed there.
+
### HermisProvider Props Standardized
To match `WalletProvider` interface and follow web3.js conventions:
diff --git a/docs/mint.json b/docs/mint.json
index e393b30..ed5d89d 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -12,8 +12,8 @@
"metadata": {
"og:title": "Hermis - Headless Solana Wallet Adapter",
"og:description": "Framework-agnostic, headless wallet adapter library for Solana. Build wallet integrations with complete UI freedom.",
- "og:image": "https://docs.hermis.io/favicon.png",
- "og:url": "https://docs.hermis.io",
+ "og:image": "https://docs.hermis.dev/favicon.png",
+ "og:url": "https://docs.hermis.dev",
"twitter:card": "summary_large_image",
"twitter:site": "@Hermis_xyz"
},
@@ -55,7 +55,7 @@
{
"name": "Live Demo",
"icon": "play",
- "url": "https://assylum-labs.github.io/hermis/"
+ "url": "https://hermis.dev/"
},
{
"name": "Community",
diff --git a/docs/quickstart/adapter-base.mdx b/docs/quickstart/adapter-base.mdx
index 2a37f60..f66c513 100644
--- a/docs/quickstart/adapter-base.mdx
+++ b/docs/quickstart/adapter-base.mdx
@@ -30,17 +30,19 @@ Set up wallet adapters for your application:
```javascript
import {
WalletAdapterManager,
- getStandardWalletAdapters,
sortWalletAdapters
} from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
// Create wallet adapter instances
const wallets = [
/** Other wallet that implement the wallet standard */
];
-// Get all standard wallet adapters (includes Wallet Standard wallets)
-const adapters = getStandardWalletAdapters(wallets);
+// Get all standard wallet adapters (includes Wallet Standard wallets).
+// `network` is required — we no longer infer it from the RPC URL.
+const adapters = await getStandardWalletAdapters(wallets, undefined, WalletAdapterNetwork.Mainnet);
console.log(`Found ${adapters.length} wallet adapters`);
```
@@ -247,9 +249,10 @@ Here's a complete vanilla JavaScript example:
```javascript app.js
import {
WalletAdapterManager,
- getStandardWalletAdapters,
sortWalletAdapters,
} from '@hermis/solana-headless-adapter-base';
+import { getStandardWalletAdapters } from '@hermis/wallet-standard-base';
+import { WalletAdapterNetwork } from '@hermis/solana-headless-core';
class WalletApp {
constructor() {
@@ -257,11 +260,11 @@ class WalletApp {
this.init();
}
- init() {
+ async init() {
// Auto-detect wallet-standard compatible wallets
const wallets = []; // Add custom wallet adapters here if needed
- const adapters = getStandardWalletAdapters(wallets);
+ const adapters = await getStandardWalletAdapters(wallets, undefined, WalletAdapterNetwork.Mainnet);
const sorted = sortWalletAdapters(adapters);
// Create wallet manager
@@ -391,9 +394,14 @@ const app = new WalletApp();
### Utility Functions
- Gets all standard wallet adapters including custom ones
+ Gets all standard wallet adapters including custom ones. **Network is required**
+ — substring-based inference from the RPC URL was removed for security.
```javascript
- getStandardWalletAdapters(customAdapters?: Adapter[]): Adapter[]
+ getStandardWalletAdapters(
+ customAdapters: Adapter[] | undefined,
+ endpoint: string | undefined,
+ network: WalletAdapterNetwork,
+ ): Promise
```
diff --git a/package.json b/package.json
index a65df24..1b91a1b 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"minor": "changeset add --minor",
"major": "changeset add --major",
"patch": "changeset add --patch",
- "prerelease": "changeset pre enter next",
+ "enter-prerelease": "changeset pre enter next",
"graduate": "changeset pre exit"
},
"publishConfig": {
diff --git a/packages/adapter-base/CHANGELOG.md b/packages/adapter-base/CHANGELOG.md
index fd1f37f..1ddea5b 100644
--- a/packages/adapter-base/CHANGELOG.md
+++ b/packages/adapter-base/CHANGELOG.md
@@ -1,5 +1,56 @@
# @hermis/solana-headless-adapter-base
+## 2.1.0
+
+### Minor Changes
+
+- b0d2b24: P1 security & performance hardening.
+
+ ⚠️ **Behaviour changes worth checking before upgrading from 2.0.x:**
+
+ - `getStandardWalletAdapters(adapters, endpoint, network)` and
+ `createWalletConnectionManager(adapters, network, ...)` now require an
+ explicit `network` argument — RPC-URL substring inference was removed.
+ - Local-key `signMessage` now wraps the message with the Solana off-chain
+ domain separator (`0xff` + `"solana offchain"` + header). Existing
+ verifiers must apply the same wrapper. External wallet adapters
+ (Phantom/Backpack/Solflare/any Standard Wallet) are unaffected.
+ - The alternative 3-arg `sendTransaction(tx, wallet, options)` form is
+ removed; use the 4-arg form with an explicit connection.
+
+ See `docs/migration/v1-to-v2.mdx` for migration steps and verifier-side
+ sample code.
+
+ **Security / correctness**
+
+ - Fix Kit transaction-sending signer to `bs58.decode` signature bytes
+ (previously returned UTF-8 of the base58 string).
+ - Apply the Solana off-chain message domain separator on local-key
+ `signMessage` paths.
+ - Require explicit `network` for cluster identification; remove URL
+ substring inference.
+ - Remove `sendTransaction` / `signAndSendTransaction` alt overload that
+ silently routed to a hard-coded devnet connection.
+ - Fix `useStandardWalletAdapters` / `HermisProvider` memoisation that
+ missed adapter swaps with the same array length.
+ - Strip debug `console.log` calls leaking unsigned transaction bytes
+ from `StandardWalletAdapter`.
+
+ **Performance**
+
+ - Memoise `WalletContext.Provider` value to eliminate full-tree
+ re-renders on `WalletProvider` re-renders.
+ - Bound the `wallet-standard:app-ready` dispatch loop: 10×1s → 4-shot
+ backoff (100ms, 300ms, 1s, 3s).
+ - `useSolanaTransaction` polling: stop on `finalized`/`failed`,
+ exponential backoff (1s → 30s).
+
+### Patch Changes
+
+- Updated dependencies [b0d2b24]
+ - @hermis/solana-headless-core@2.1.0
+ - @hermis/wallet-standard-base@1.1.0
+
## 2.0.0
### Major Changes
diff --git a/packages/adapter-base/README.md b/packages/adapter-base/README.md
index 4a165a5..23613d4 100644
--- a/packages/adapter-base/README.md
+++ b/packages/adapter-base/README.md
@@ -51,12 +51,12 @@ if (phantomAdapter) {
## Documentation
-For complete documentation, visit [docs.hermis.io/quickstart/adapter-base](https://docs.hermis.io/quickstart/adapter-base)
+For complete documentation, visit [docs.hermis.dev/quickstart/adapter-base](https://docs.hermis.dev/quickstart/adapter-base)
## API Reference
-- [WalletAdapterManager](https://docs.hermis.io/api-reference/adapter-base/wallet-manager)
-- [Utility Functions](https://docs.hermis.io/api-reference/adapter-base/utilities)
+- [WalletAdapterManager](https://docs.hermis.dev/api-reference/adapter-base/wallet-manager)
+- [Utility Functions](https://docs.hermis.dev/api-reference/adapter-base/utilities)
## License
diff --git a/packages/adapter-base/__tests__/utils/environment.test.ts b/packages/adapter-base/__tests__/utils/environment.test.ts
index a59cedb..f5572eb 100644
--- a/packages/adapter-base/__tests__/utils/environment.test.ts
+++ b/packages/adapter-base/__tests__/utils/environment.test.ts
@@ -114,11 +114,6 @@ namespace TestEnvironment {
export const isMobileDevice = jest.fn((ua: string) =>
(ua.includes('iPhone') || ua.includes('Android')) && !ua.includes('wv)'));
export const isIosAndRedirectable = jest.fn(() => true);
- export const getInferredNetworkFromEndpoint = jest.fn((endpoint?: string) => {
- if (endpoint?.includes('devnet')) return WalletAdapterNetwork.Devnet;
- if (endpoint?.includes('testnet')) return WalletAdapterNetwork.Testnet;
- return WalletAdapterNetwork.Mainnet;
- });
export const getUriForAppIdentity = jest.fn(() => {
if (typeof window === 'undefined') return undefined;
diff --git a/packages/adapter-base/package.json b/packages/adapter-base/package.json
index 6114a0f..9d45468 100644
--- a/packages/adapter-base/package.json
+++ b/packages/adapter-base/package.json
@@ -1,6 +1,6 @@
{
"name": "@hermis/solana-headless-adapter-base",
- "version": "2.0.0",
+ "version": "2.1.0",
"description": "The framework agnostic implementation of the Hermis headless solana wallet adapetr",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -24,8 +24,8 @@
},
"dependencies": {
"@hermis/errors": "1.0.0",
- "@hermis/solana-headless-core": "2.0.0",
- "@hermis/wallet-standard-base": "1.0.0",
+ "@hermis/solana-headless-core": "2.1.0",
+ "@hermis/wallet-standard-base": "1.1.0",
"@solana/kit": "^3.0.0",
"@solana/wallet-adapter-base": "^0.9.23",
"@solana/web3.js": "^1.98.0",
diff --git a/packages/adapter-base/src/core/manager.ts b/packages/adapter-base/src/core/manager.ts
index 2a2b8d0..de5b4df 100644
--- a/packages/adapter-base/src/core/manager.ts
+++ b/packages/adapter-base/src/core/manager.ts
@@ -1,4 +1,4 @@
-import { Adapter, WalletName, WalletReadyState, EventEmitter, WalletAdapterEvents } from '../types/index.js';
+import { Adapter, WalletName, WalletReadyState, EventEmitter, WalletAdapterEvents, WalletAdapterNetwork } from '../types/index.js';
import { PublicKey, Transaction, TransactionSignature } from '@solana/web3.js';
import { createLocalStorageUtility } from '../utils/storage.js';
import { addWalletAdapterEventListeners } from './adapters.js';
@@ -22,15 +22,21 @@ import {
/**
* Create a simple wallet connection manager with built-in adapter management
+ *
+ * The `network` parameter is required — wallet adapters now need an explicit
+ * cluster so their chain hints can't be spoofed by URL substring inference.
+ *
* @param adapters Array of adapters to manage
+ * @param network Active Solana network for the cluster hint
* @param localStorageKey Key to store the selected wallet name
* @param endpoint Optional RPC endpoint for wallet detection
* @returns Wallet connection manager object
*/
export function createWalletConnectionManager(
adapters: Adapter[],
+ network: WalletAdapterNetwork,
localStorageKey = 'walletName',
- endpoint?: string
+ endpoint?: string,
): WalletConnectionManager {
// Internal state for adapters
let currentAdapters: Adapter[] = [];
@@ -52,7 +58,7 @@ export function createWalletConnectionManager(
// Initialize wallet detection and get initial adapters
(async () => {
- const detectedAdapters = await getStandardWalletAdapters(adapters, endpoint);
+ const detectedAdapters = await getStandardWalletAdapters(adapters, endpoint, network);
currentAdapters = detectedAdapters;
// Try to restore previously selected wallet
diff --git a/packages/adapter-base/src/utils/environment.ts b/packages/adapter-base/src/utils/environment.ts
index 50315d9..13d5fda 100644
--- a/packages/adapter-base/src/utils/environment.ts
+++ b/packages/adapter-base/src/utils/environment.ts
@@ -145,20 +145,3 @@ export function isIosAndRedirectable(): boolean {
return isIos && isSafari;
}
-/**
- * Infer Solana cluster from endpoint
- * @param endpoint RPC endpoint URL
- * @returns The inferred cluster/network
- */
-export function getInferredNetworkFromEndpoint(endpoint?: string): WalletAdapterNetwork {
- if (!endpoint) {
- return WalletAdapterNetwork.Mainnet;
- }
- if (/devnet/i.test(endpoint)) {
- return WalletAdapterNetwork.Devnet;
- } else if (/testnet/i.test(endpoint)) {
- return WalletAdapterNetwork.Testnet;
- } else {
- return WalletAdapterNetwork.Mainnet;
- }
-}
\ No newline at end of file
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index ae7d8a5..0bf9380 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,50 @@
# @solana/headless-core
+## 2.1.0
+
+### Minor Changes
+
+- b0d2b24: P1 security & performance hardening.
+
+ ⚠️ **Behaviour changes worth checking before upgrading from 2.0.x:**
+
+ - `getStandardWalletAdapters(adapters, endpoint, network)` and
+ `createWalletConnectionManager(adapters, network, ...)` now require an
+ explicit `network` argument — RPC-URL substring inference was removed.
+ - Local-key `signMessage` now wraps the message with the Solana off-chain
+ domain separator (`0xff` + `"solana offchain"` + header). Existing
+ verifiers must apply the same wrapper. External wallet adapters
+ (Phantom/Backpack/Solflare/any Standard Wallet) are unaffected.
+ - The alternative 3-arg `sendTransaction(tx, wallet, options)` form is
+ removed; use the 4-arg form with an explicit connection.
+
+ See `docs/migration/v1-to-v2.mdx` for migration steps and verifier-side
+ sample code.
+
+ **Security / correctness**
+
+ - Fix Kit transaction-sending signer to `bs58.decode` signature bytes
+ (previously returned UTF-8 of the base58 string).
+ - Apply the Solana off-chain message domain separator on local-key
+ `signMessage` paths.
+ - Require explicit `network` for cluster identification; remove URL
+ substring inference.
+ - Remove `sendTransaction` / `signAndSendTransaction` alt overload that
+ silently routed to a hard-coded devnet connection.
+ - Fix `useStandardWalletAdapters` / `HermisProvider` memoisation that
+ missed adapter swaps with the same array length.
+ - Strip debug `console.log` calls leaking unsigned transaction bytes
+ from `StandardWalletAdapter`.
+
+ **Performance**
+
+ - Memoise `WalletContext.Provider` value to eliminate full-tree
+ re-renders on `WalletProvider` re-renders.
+ - Bound the `wallet-standard:app-ready` dispatch loop: 10×1s → 4-shot
+ backoff (100ms, 300ms, 1s, 3s).
+ - `useSolanaTransaction` polling: stop on `finalized`/`failed`,
+ exponential backoff (1s → 30s).
+
## 2.0.0
### Major Changes
diff --git a/packages/core/__tests__/kit-signers/factories.test.ts b/packages/core/__tests__/kit-signers/factories.test.ts
new file mode 100644
index 0000000..95ad01c
--- /dev/null
+++ b/packages/core/__tests__/kit-signers/factories.test.ts
@@ -0,0 +1,35 @@
+import bs58 from 'bs58';
+import { createTransactionSendingSignerFromWallet } from '../../src/kit-signers/factories';
+import type { Address } from '@solana/kit';
+
+describe('createTransactionSendingSignerFromWallet', () => {
+ const walletAddress = 'DjVE6JNiYqPL2QXyCUUh8rNjHrbz9hXHNYt99MQ59qw1' as Address;
+ const chain = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const;
+
+ it('returns 64-byte Ed25519 signature bytes (not UTF-8 of the base58 string)', async () => {
+ const knownSignatureBytes = new Uint8Array(64);
+ for (let i = 0; i < 64; i++) knownSignatureBytes[i] = i;
+ const knownSignatureBase58 = bs58.encode(knownSignatureBytes);
+
+ const signer = createTransactionSendingSignerFromWallet(
+ walletAddress,
+ chain,
+ async () => knownSignatureBase58,
+ );
+
+ const [signatureBytes] = await signer.signAndSendTransactions([{}]);
+
+ expect(signatureBytes.length).toBe(64);
+ expect(Array.from(signatureBytes)).toEqual(Array.from(knownSignatureBytes));
+ });
+
+ it('rejects when more than one transaction is passed', async () => {
+ const signer = createTransactionSendingSignerFromWallet(
+ walletAddress,
+ chain,
+ async () => bs58.encode(new Uint8Array(64)),
+ );
+
+ await expect(signer.signAndSendTransactions([{}, {}])).rejects.toThrow();
+ });
+});
diff --git a/packages/core/__tests__/transaction/offchain-message.test.ts b/packages/core/__tests__/transaction/offchain-message.test.ts
new file mode 100644
index 0000000..39daeba
--- /dev/null
+++ b/packages/core/__tests__/transaction/offchain-message.test.ts
@@ -0,0 +1,38 @@
+import { withOffchainMessagePrefix } from '../../src/transaction/offchain-message';
+
+describe('withOffchainMessagePrefix', () => {
+ it('prepends the 0xff "solana offchain" domain separator', () => {
+ const out = withOffchainMessagePrefix(new Uint8Array([0xaa, 0xbb]));
+
+ // 0xff || "solana offchain" || version(0) || format(1) || len(2 LE) || body
+ expect(Array.from(out.slice(0, 16))).toEqual([
+ 0xff,
+ 0x73, 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x20, // "solana "
+ 0x6f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, // "offchain"
+ ]);
+ expect(out[16]).toBe(0); // header version
+ expect(out[17]).toBe(1); // format: UTF-8
+ expect(out[18]).toBe(2); // len LE low byte
+ expect(out[19]).toBe(0); // len LE high byte
+ expect(Array.from(out.slice(20))).toEqual([0xaa, 0xbb]);
+ });
+
+ it('produces bytes that cannot be confused with a Solana transaction', () => {
+ // Real Solana transactions start with a "number of required signatures"
+ // byte that's typically 1-8 and always less than 0x80.
+ const out = withOffchainMessagePrefix(new Uint8Array([1, 2, 3]));
+ expect(out[0]).toBe(0xff);
+ });
+
+ it('encodes message length as little-endian u16', () => {
+ const out = withOffchainMessagePrefix(new Uint8Array(0x0102));
+ expect(out[18]).toBe(0x02);
+ expect(out[19]).toBe(0x01);
+ });
+
+ it('rejects messages longer than u16 max', () => {
+ expect(() => withOffchainMessagePrefix(new Uint8Array(0x10000))).toThrow(
+ /too large/i,
+ );
+ });
+});
diff --git a/packages/core/package.json b/packages/core/package.json
index 853e56b..5e4ca97 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@hermis/solana-headless-core",
- "version": "2.0.0",
+ "version": "2.1.0",
"description": "Core wallet logic for the Hermis Solana Headless SDK",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/core/src/kit-signers/factories.ts b/packages/core/src/kit-signers/factories.ts
index fecfa4b..112dea6 100644
--- a/packages/core/src/kit-signers/factories.ts
+++ b/packages/core/src/kit-signers/factories.ts
@@ -6,6 +6,7 @@
*/
import type { Address } from '@solana/kit'
+import bs58 from 'bs58'
import type {
MessageModifyingSigner,
TransactionSendingSigner,
@@ -139,10 +140,8 @@ export function createTransactionSendingSignerFromWallet(
// Send the transaction and get signature
const signatureString = await sendTransactionFn(transaction)
- // Convert base58 signature string to bytes
- // For now, we'll keep it as Uint8Array from base58 decode
- // In a full implementation, you'd use proper base58 decoder
- const signatureBytes = new TextEncoder().encode(signatureString) as SignatureBytes
+ // Decode the base58 signature string into the 64-byte Ed25519 signature
+ const signatureBytes = bs58.decode(signatureString) as SignatureBytes
return [signatureBytes]
}
diff --git a/packages/core/src/transaction/index.ts b/packages/core/src/transaction/index.ts
index 0187308..7a7ea65 100644
--- a/packages/core/src/transaction/index.ts
+++ b/packages/core/src/transaction/index.ts
@@ -112,6 +112,7 @@ import {
import * as ed25519 from '@noble/ed25519';
import bs58 from 'bs58';
import nacl from 'tweetnacl';
+import { withOffchainMessagePrefix } from './offchain-message.js';
// Import kit functions with correct names based on actual exports
@@ -1161,21 +1162,7 @@ async function signAllTransactionsLegacy;
-
-/**
- * Sends a transaction to the Solana network (supports both legacy and kit architectures)
+ *
* @param connection The Solana connection to use (supports both Legacy Connection and Kit Rpc)
* @param transaction The transaction to send
* @param wallet The wallet to sign with (can be Keypair, Adapter, CryptoKeyPair, or Address)
@@ -1186,44 +1173,9 @@ export async function sendTransaction(
connection: DualConnection,
transaction: DualTransaction,
wallet: DualWallet,
- options?: DualArchitectureOptions
-): Promise;
-
-/**
- * Implementation for sendTransaction with flexible parameter handling
- */
-export async function sendTransaction(
- connectionOrTransaction: DualConnection | DualTransaction,
- transactionOrWallet?: DualTransaction | DualWallet,
- walletOrOptions?: DualWallet | DualArchitectureOptions,
- options: DualArchitectureOptions = {}
+ options: DualArchitectureOptions = {},
): Promise {
- let connection: DualConnection;
- let transaction: DualTransaction;
- let wallet: DualWallet;
- let finalOptions: DualArchitectureOptions;
-
- // Parse parameters based on overload usage
- // Check if first parameter is a connection (either legacy Connection or Kit Rpc)
- // First check if it looks like a transaction
- const looksLikeTransaction = typeof connectionOrTransaction === 'object' && connectionOrTransaction !== null &&
- ('recentBlockhash' in connectionOrTransaction || 'instructions' in connectionOrTransaction || 'message' in connectionOrTransaction);
-
- if (!looksLikeTransaction) {
- // Standard usage: connection, transaction, wallet, options
- connection = connectionOrTransaction as DualConnection;
- transaction = transactionOrWallet as DualTransaction;
- wallet = walletOrOptions as DualWallet;
- finalOptions = options;
- } else {
- // Alternative usage: transaction, wallet, options (creates default connection)
- transaction = connectionOrTransaction as DualTransaction;
- wallet = transactionOrWallet as DualWallet;
- finalOptions = (walletOrOptions as DualArchitectureOptions) || {};
-
- // Create a default connection to devnet
- connection = createConnection('https://api.devnet.solana.com');
- }
+ const finalOptions = options;
try {
// Handle kit architecture
if (isKitWallet(wallet)) {
@@ -1389,7 +1341,7 @@ async function sendTransactionLegacy(
* @returns A promise that resolves to the signed message bytes
*/
export async function signMessage(
- message: string | Uint8Array,
+ message: string | Uint8Array,
wallet: DualWallet,
options: DualArchitectureOptions = {}
): Promise {
@@ -1401,23 +1353,27 @@ export async function signMessage(
// Handle kit architecture
if (isKitWallet(wallet)) {
+ // Kit local-key signers sign raw bytes — wrap with the off-chain message
+ // domain separator so the signature can't be re-used as a transaction
+ // signature.
+ const offchainBytes = withOffchainMessagePrefix(messageBytes);
+
if (isMessagePartialSigner(wallet)) {
// It's already a MessagePartialSigner (KeyPairSigner)
- return await signMessageKit(messageBytes, wallet);
+ return await signMessageKit(offchainBytes, wallet);
} else if (isCryptoKeyPair(wallet)) {
// Use kit's message signing capabilities
const messageSigner = await convertCryptoKeyPairToMessageSigner(wallet);
- return await signMessageKit(messageBytes, messageSigner);
+ return await signMessageKit(offchainBytes, messageSigner);
} else if (typeof wallet === 'string') {
// Address without private key - cannot sign
throw new HermisError(HERMIS_ERROR__SIGNING__PRIVATE_KEY_UNAVAILABLE, {});
}
- // else {
- // throw new Error('Unsupported kit wallet type');
- // }
}
- // Everything else uses legacy implementation (Keypair, Adapter, Standard Wallet)
+ // Everything else uses legacy implementation (Keypair, Adapter, Standard Wallet).
+ // The Keypair branch inside signMessageLegacy applies the off-chain prefix
+ // itself; external wallets (Standard Wallet / Adapter) apply their own.
return await signMessageLegacy(messageBytes, wallet as LegacyWallet);
} catch (error) {
console.error('Failed to sign message:', error);
@@ -1692,11 +1648,13 @@ async function signMessageLegacy(
messageBytes: Uint8Array,
wallet: LegacyWallet
): Promise {
-
+
// Handle different wallet types
if ('secretKey' in wallet) {
- // It's a Keypair
- const signature = nacl.sign.detached(messageBytes, wallet.secretKey);
+ // It's a Keypair — local signing, wrap with the off-chain message domain
+ // separator so the signature is provably not a transaction signature.
+ const offchainBytes = withOffchainMessagePrefix(messageBytes);
+ const signature = nacl.sign.detached(offchainBytes, wallet.secretKey);
return signature;
} else if ("_connectedAccount" in wallet && "_wallet" in wallet) {
// It's a Standard Wallet
@@ -2034,40 +1992,20 @@ export async function signTransactionWithSigner(
}
/**
- * Implementation for signAndSendTransaction with flexible parameter handling
+ * Sign and send a transaction (supports both legacy and kit architectures).
+ *
+ * @param connection The Solana connection to use (supports both legacy Connection and Kit Rpc)
+ * @param transaction The transaction to sign and send
+ * @param wallet The wallet to sign with (can be Keypair, Adapter, CryptoKeyPair, or Address)
+ * @param options Optional configuration for dual architecture behavior
*/
export async function signAndSendTransaction(
- connectionOrTransaction: DualConnection | DualTransaction,
- transactionOrWallet?: DualTransaction | DualWallet,
- walletOrOptions?: DualWallet | DualArchitectureOptions,
- options: DualArchitectureOptions = {}
+ connection: DualConnection,
+ transaction: DualTransaction,
+ wallet: DualWallet,
+ options: DualArchitectureOptions = {},
): Promise {
- let connection: DualConnection;
- let transaction: DualTransaction;
- let wallet: DualWallet;
- let finalOptions: DualArchitectureOptions;
-
- // Parse parameters based on overload usage
- // Check if first parameter is a connection (either legacy Connection or Kit Rpc)
- // First check if it looks like a transaction
- const looksLikeTransaction = typeof connectionOrTransaction === 'object' && connectionOrTransaction !== null &&
- ('recentBlockhash' in connectionOrTransaction || 'instructions' in connectionOrTransaction || 'message' in connectionOrTransaction);
-
- if (!looksLikeTransaction) {
- // Standard usage: connection, transaction, wallet, options
- connection = connectionOrTransaction as DualConnection;
- transaction = transactionOrWallet as DualTransaction;
- wallet = walletOrOptions as DualWallet;
- finalOptions = options;
- } else {
- // Alternative usage: transaction, wallet, options (creates default connection)
- transaction = connectionOrTransaction as DualTransaction;
- wallet = transactionOrWallet as DualWallet;
- finalOptions = (walletOrOptions as DualArchitectureOptions) || {};
-
- // Create a default connection to devnet
- connection = createConnection('https://api.devnet.solana.com');
- }
+ const finalOptions = options;
try {
// Handle kit architecture
diff --git a/packages/core/src/transaction/offchain-message.ts b/packages/core/src/transaction/offchain-message.ts
new file mode 100644
index 0000000..9bff576
--- /dev/null
+++ b/packages/core/src/transaction/offchain-message.ts
@@ -0,0 +1,59 @@
+/**
+ * Solana Off-Chain Message domain separator.
+ *
+ * Without a domain separator, raw `signMessage` output is structurally
+ * indistinguishable from a transaction signature — a caller can be tricked
+ * into producing a valid transaction signature by handing the signer bytes
+ * that happen to encode a Solana transaction message.
+ *
+ * Wallet adapters that implement Standard Wallet / wallet-adapter-base
+ * (Phantom, Backpack, Solflare, …) apply this prefix themselves. The in-SDK
+ * local-key signers (Keypair / CryptoKeyPair / KeyPairSigner) do not, so we
+ * wrap their inputs here.
+ *
+ * Layout (per Solana off-chain message spec):
+ * signing_domain: 0xff "solana offchain" (16 bytes)
+ * header_version: u8 (currently 0)
+ * format: u8 (1 = UTF-8)
+ * length: u16 little-endian
+ * message: variable
+ *
+ * The leading 0xff guarantees the buffer cannot be parsed as a Solana
+ * transaction (a real transaction's first byte is the number of required
+ * signatures, always < 0x80).
+ *
+ * @see https://github.com/solana-foundation/solana-improvement-documents/pull/48
+ */
+const SIGNING_DOMAIN = new Uint8Array([
+ 0xff,
+ 0x73, 0x6f, 0x6c, 0x61, 0x6e, 0x61, 0x20, // "solana "
+ 0x6f, 0x66, 0x66, 0x63, 0x68, 0x61, 0x69, 0x6e, // "offchain"
+]);
+
+const HEADER_VERSION = 0;
+const FORMAT_UTF8 = 1;
+const HEADER_SIZE = SIGNING_DOMAIN.length + 1 + 1 + 2; // domain + version + format + len
+
+/**
+ * Wrap raw message bytes with the Solana off-chain message header so the
+ * resulting signature cannot be re-interpreted as a transaction signature.
+ *
+ * Verifiers must apply the same wrapping before checking the signature.
+ */
+export function withOffchainMessagePrefix(messageBytes: Uint8Array): Uint8Array {
+ if (messageBytes.length > 0xffff) {
+ throw new Error(
+ `Off-chain message too large: ${messageBytes.length} bytes (max ${0xffff})`,
+ );
+ }
+
+ const out = new Uint8Array(HEADER_SIZE + messageBytes.length);
+ out.set(SIGNING_DOMAIN, 0);
+ out[SIGNING_DOMAIN.length] = HEADER_VERSION;
+ out[SIGNING_DOMAIN.length + 1] = FORMAT_UTF8;
+ // length, little-endian u16
+ out[SIGNING_DOMAIN.length + 2] = messageBytes.length & 0xff;
+ out[SIGNING_DOMAIN.length + 3] = (messageBytes.length >> 8) & 0xff;
+ out.set(messageBytes, HEADER_SIZE);
+ return out;
+}
diff --git a/packages/react-core/CHANGELOG.md b/packages/react-core/CHANGELOG.md
index 1276e34..bb81909 100644
--- a/packages/react-core/CHANGELOG.md
+++ b/packages/react-core/CHANGELOG.md
@@ -1,5 +1,57 @@
# @solana/headless-session
+## 2.1.0
+
+### Minor Changes
+
+- b0d2b24: P1 security & performance hardening.
+
+ ⚠️ **Behaviour changes worth checking before upgrading from 2.0.x:**
+
+ - `getStandardWalletAdapters(adapters, endpoint, network)` and
+ `createWalletConnectionManager(adapters, network, ...)` now require an
+ explicit `network` argument — RPC-URL substring inference was removed.
+ - Local-key `signMessage` now wraps the message with the Solana off-chain
+ domain separator (`0xff` + `"solana offchain"` + header). Existing
+ verifiers must apply the same wrapper. External wallet adapters
+ (Phantom/Backpack/Solflare/any Standard Wallet) are unaffected.
+ - The alternative 3-arg `sendTransaction(tx, wallet, options)` form is
+ removed; use the 4-arg form with an explicit connection.
+
+ See `docs/migration/v1-to-v2.mdx` for migration steps and verifier-side
+ sample code.
+
+ **Security / correctness**
+
+ - Fix Kit transaction-sending signer to `bs58.decode` signature bytes
+ (previously returned UTF-8 of the base58 string).
+ - Apply the Solana off-chain message domain separator on local-key
+ `signMessage` paths.
+ - Require explicit `network` for cluster identification; remove URL
+ substring inference.
+ - Remove `sendTransaction` / `signAndSendTransaction` alt overload that
+ silently routed to a hard-coded devnet connection.
+ - Fix `useStandardWalletAdapters` / `HermisProvider` memoisation that
+ missed adapter swaps with the same array length.
+ - Strip debug `console.log` calls leaking unsigned transaction bytes
+ from `StandardWalletAdapter`.
+
+ **Performance**
+
+ - Memoise `WalletContext.Provider` value to eliminate full-tree
+ re-renders on `WalletProvider` re-renders.
+ - Bound the `wallet-standard:app-ready` dispatch loop: 10×1s → 4-shot
+ backoff (100ms, 300ms, 1s, 3s).
+ - `useSolanaTransaction` polling: stop on `finalized`/`failed`,
+ exponential backoff (1s → 30s).
+
+### Patch Changes
+
+- Updated dependencies [b0d2b24]
+ - @hermis/solana-headless-core@2.1.0
+ - @hermis/solana-headless-adapter-base@2.1.0
+ - @hermis/wallet-standard-base@1.1.0
+
## 2.0.0
### Major Changes
diff --git a/packages/react-core/README.md b/packages/react-core/README.md
index d1a7f8d..b3fa701 100644
--- a/packages/react-core/README.md
+++ b/packages/react-core/README.md
@@ -117,7 +117,7 @@ function NetworkInfo() {
## Documentation
-For complete documentation, examples, and API reference, visit [docs.hermis.io/quickstart/react](https://docs.hermis.io/quickstart/react)
+For complete documentation, examples, and API reference, visit [docs.hermis.dev/quickstart/react](https://docs.hermis.dev/quickstart/react)
## License
diff --git a/packages/react-core/package.json b/packages/react-core/package.json
index d02c7b3..e721535 100644
--- a/packages/react-core/package.json
+++ b/packages/react-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@hermis/solana-headless-react",
- "version": "2.0.0",
+ "version": "2.1.0",
"description": "React hooks and components for Hermis Solana Headless SDK",
"main": "dist/index.cjs",
"module": "dist/index.js",
@@ -27,15 +27,15 @@
],
"dependencies": {
"@hermis/errors": "1.0.0",
- "@hermis/solana-headless-adapter-base": "2.0.0",
- "@hermis/solana-headless-core": "2.0.0",
- "@hermis/wallet-standard-base": "1.0.0",
+ "@hermis/solana-headless-adapter-base": "2.1.0",
+ "@hermis/solana-headless-core": "2.1.0",
+ "@hermis/wallet-standard-base": "1.1.0",
"@solana/kit": "^3.0.0",
"react": "^19.0.1"
},
"peerDependencies": {
"react": ">=16.8.0",
- "@hermis/solana-headless-core": ">=2.0.0"
+ "@hermis/solana-headless-core": ">=2.1.0"
},
"keywords": [
"solana",
diff --git a/packages/react-core/src/components/ContextProvider.tsx b/packages/react-core/src/components/ContextProvider.tsx
index 1702eeb..18dfb12 100644
--- a/packages/react-core/src/components/ContextProvider.tsx
+++ b/packages/react-core/src/components/ContextProvider.tsx
@@ -1,9 +1,8 @@
import React, { FC, ReactNode, useMemo } from 'react';
-import { Adapter, ConnectionConfig, WalletAdapterNetwork, WalletError } from '@hermis/solana-headless-core';
+import { Adapter, ConnectionConfig, WalletError } from '@hermis/solana-headless-core';
import { WalletProvider as BaseWalletProvider } from '../providers/WalletProvider.js';
import { ConnectionProvider } from '../providers/ConnectionProvider.js';
import { StorageProviderFactory } from '../hooks/useLocalStorage.js';
-import { getInferredNetworkFromEndpoint } from '@hermis/solana-headless-adapter-base';
import { TWalletAdapterNetwork } from '../index.js';
/**
@@ -43,17 +42,12 @@ export const ContextProvider: FC = ({
children,
adapters,
rpcEndpoint,
- network: explicitNetwork,
+ network,
autoConnect = false,
storageKey = 'walletName',
storageFactory,
onError,
}) => {
-
- // Infer network if not explicitly provided
- const network = explicitNetwork || getInferredNetworkFromEndpoint(rpcEndpoint);
-
-
const connectionConfig = useMemo(() => ({
commitment: 'confirmed',
}) as ConnectionConfig, []);
diff --git a/packages/react-core/src/components/HermisProvider.tsx b/packages/react-core/src/components/HermisProvider.tsx
index 070f3b8..1fa346c 100644
--- a/packages/react-core/src/components/HermisProvider.tsx
+++ b/packages/react-core/src/components/HermisProvider.tsx
@@ -4,6 +4,20 @@ import { ContextProvider } from './ContextProvider.js';
import { useStandardWalletAdapters } from '../hooks/useStandardWalletAdapters.js';
import { StorageProviderFactory } from '../hooks/useLocalStorage.js';
+/**
+ * Convert {@link TWalletAdapterNetwork} (which includes Localnet) into the
+ * upstream {@link WalletAdapterNetwork} enum understood by wallet adapters.
+ * Localnet is mapped to Devnet for cluster-aware features since wallets do
+ * not have a chain identifier for local validators.
+ */
+export function toWalletAdapterNetwork(network: TWalletAdapterNetwork): WalletAdapterNetwork {
+ if (network === TWalletAdapterNetwork.Localnet) {
+ return WalletAdapterNetwork.Devnet;
+ }
+ // Mainnet/Devnet/Testnet share string values with WalletAdapterNetwork.
+ return network as unknown as WalletAdapterNetwork;
+}
+
/**
* Props for the ContextProvider component
*/
@@ -55,19 +69,19 @@ export const HermisProvider = ({
}: HermisWalletProviderProps) => {
const [adapters, setAdapters] = useState([]);
- const memoizedAdapters = useMemo(() =>
- wallets, [
-
- wallets.length,
- ]);
+ // Identity-stable key over the wallet set. Memoising on `.length` would
+ // miss swaps where the array length stays the same but the wallets differ.
+ const walletNamesKey = useMemo(
+ () => wallets.map(w => w.name).join('|'),
+ [wallets],
+ );
useEffect(() => {
- const baseAdapters: Adapter[] = [];
-
- setAdapters([...baseAdapters, ...wallets]);
- }, [memoizedAdapters]);
+ setAdapters([...wallets]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [walletNamesKey]);
- const allAdapters = useStandardWalletAdapters(adapters, endpoint);
+ const allAdapters = useStandardWalletAdapters(adapters, endpoint, toWalletAdapterNetwork(network));
return (
(null);
useEffect(() => {
- if (autoFetch && signature) {
- fetchStatus();
+ txStatusRef.current = txStatus;
+ }, [txStatus]);
- const interval = setInterval(fetchStatus, 2000);
- return () => clearInterval(interval);
- }
+ // Poll with exponential backoff and stop on terminal status.
+ // Previously this was setInterval(fetchStatus, 2000) — it kept hitting
+ // the RPC twice every 2s forever, including after finalisation, which
+ // tripped rate limits on hosted RPC providers.
+ useEffect(() => {
+ if (!autoFetch || !signature) return;
+
+ const BACKOFF_MS = [1000, 2000, 5000, 10000, 30000] as const;
+ let cancelled = false;
+ let timeoutId: ReturnType | null = null;
+ let attempt = 0;
+
+ const tick = async () => {
+ if (cancelled) return;
+ await fetchStatus();
+ if (cancelled) return;
+
+ const current = txStatusRef.current;
+ if (current && (current.status === 'finalized' || current.status === 'failed')) {
+ return;
+ }
+
+ const delay = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
+ attempt += 1;
+ timeoutId = setTimeout(tick, delay);
+ };
+
+ void tick();
+
+ return () => {
+ cancelled = true;
+ if (timeoutId !== null) clearTimeout(timeoutId);
+ };
}, [autoFetch, fetchStatus, signature]);
return {
diff --git a/packages/react-core/src/hooks/useStandardWalletAdapters.ts b/packages/react-core/src/hooks/useStandardWalletAdapters.ts
index 58b87de..ce07c35 100644
--- a/packages/react-core/src/hooks/useStandardWalletAdapters.ts
+++ b/packages/react-core/src/hooks/useStandardWalletAdapters.ts
@@ -1,32 +1,40 @@
-import { Adapter } from '@hermis/solana-headless-core';
-import {
+import { Adapter, WalletAdapterNetwork } from '@hermis/solana-headless-core';
+import {
getStandardWalletAdapters as getBaseStandardWalletAdapters,
- subscribeToWalletAdapterChanges
+ subscribeToWalletAdapterChanges
} from '@hermis/wallet-standard-base';
import { useEffect, useMemo, useState } from 'react';
/**
* Hook for getting standard wallet adapters
- *
- * This hook integrates with @hermis/solana-headless-adapter-base's
+ *
+ * This hook integrates with @hermis/wallet-standard-base's
* getStandardWalletAdapters function to discover and initialize
* wallet adapters including standard wallets.
- *
+ *
* The base library handles all initialization and change detection automatically.
- *
+ *
+ * `network` must be supplied explicitly — we do not infer the cluster from the
+ * RPC endpoint URL (substring matching is unsafe).
+ *
* @param existingAdapters Existing adapters to include
* @param endpoint Optional RPC endpoint for mobile wallet adapter
+ * @param network Active Solana network for the cluster hint
* @returns Array of Adapters
*/
export function useStandardWalletAdapters(
existingAdapters: Adapter[] = [],
- endpoint?: string
+ endpoint: string | undefined,
+ network: WalletAdapterNetwork,
): Adapter[] {
const [adapters, setAdapters] = useState(existingAdapters);
- const memoizedAdapters = useMemo(() => existingAdapters, [
- existingAdapters.length,
- ]);
+ // Identity-stable key over the adapter set. Memoising on `.length` would
+ // miss swaps where the array length stays the same but the adapters differ.
+ const adapterNamesKey = useMemo(
+ () => existingAdapters.map(a => a.name).join('|'),
+ [existingAdapters],
+ );
// Initial fetch of adapters
useEffect(() => {
@@ -37,7 +45,8 @@ export function useStandardWalletAdapters(
// The base library handles initialization and change detection automatically
const standardAdapters = await getBaseStandardWalletAdapters(
existingAdapters,
- endpoint
+ endpoint,
+ network,
);
if (mounted) {
@@ -57,7 +66,8 @@ export function useStandardWalletAdapters(
return () => {
mounted = false;
};
- }, [memoizedAdapters, endpoint]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [adapterNamesKey, endpoint, network]);
// Subscribe to dynamic adapter changes
useEffect(() => {
@@ -82,7 +92,8 @@ export function useStandardWalletAdapters(
mounted = false;
unsubscribe();
};
- }, [existingAdapters]); // Add existingAdapters as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [adapterNamesKey]);
return adapters;
}
\ No newline at end of file
diff --git a/packages/react-core/src/providers/WalletProvider.tsx b/packages/react-core/src/providers/WalletProvider.tsx
index 6f8321e..7ddb447 100644
--- a/packages/react-core/src/providers/WalletProvider.tsx
+++ b/packages/react-core/src/providers/WalletProvider.tsx
@@ -7,6 +7,7 @@ import {
signMessage,
signTransaction,
Transaction,
+ WalletAdapterNetwork,
WalletError,
WalletName,
WalletNotReadyError,
@@ -23,6 +24,7 @@ import {
DualConnection,
DualArchitectureOptions
} from '@hermis/solana-headless-core';
+import { toWalletAdapterNetwork } from '../components/HermisProvider.js';
import { HermisError, HERMIS_ERROR__STANDARD_WALLET__FEATURE_NOT_FOUND } from '@hermis/errors';
import {
getIsMobile,
@@ -111,10 +113,14 @@ export function WalletProvider({
storageFactory,
onError,
}: WalletProviderProps) {
- const { connection } = useConnection();
+ const { connection, network } = useConnection();
// Safely access rpcEndpoint - only exists on legacy Connection, not Kit Rpc
const rpcEndpoint = connection && 'rpcEndpoint' in connection ? (connection.rpcEndpoint as string) : undefined;
- const adaptersWithStandardAdapters = useStandardWalletAdapters(adapters, rpcEndpoint);
+ const adaptersWithStandardAdapters = useStandardWalletAdapters(
+ adapters,
+ rpcEndpoint,
+ toWalletAdapterNetwork(network),
+ );
const [walletName, setWalletName] = useLocalStorage(
storageKey,
@@ -679,37 +685,69 @@ export function WalletProvider({
}
}, [adapterState.adapter]);
+ // Resolve the public key once per render from the most reliable source.
+ // Reading refs during render is fine for derivation — they're stable for the
+ // life of this render and the memo below only re-runs when the actual fields
+ // changed.
+ const publicKey =
+ wallet?.adapter.publicKey ||
+ adapterState.adapter?.publicKey ||
+ adapterState.publicKey ||
+ null;
+
+ const contextValue = useMemo(
+ () => ({
+ autoConnect: !!autoConnect,
+ wallets,
+ wallet,
+ publicKey,
+ connecting: adapterState.connecting,
+ connected: adapterState.connected,
+ disconnecting: adapterState.disconnecting,
+ // Kit-friendly properties
+ address: kitSigners.address,
+ addressString: kitSigners.addressString,
+ chain: currentChain,
+ messageSigner: kitSigners.messageSigner,
+ transactionSigner: kitSigners.transactionSigner,
+ getChainId: handleGetChainId,
+ select: updateWalletNameAsync,
+ connect: handleConnect,
+ disconnect: handleDisconnect,
+ signTransaction: handleSignTransactionExtended,
+ sendTransaction: handleSendTransactionExtended,
+ signAndSendTransaction: handleSignAndSendTransactionExtended,
+ signAllTransactions: handleSignAllTransactionsExtended,
+ signMessage: handleSignMessage,
+ signIn: handleSignIn,
+ hasFeature,
+ }),
+ [
+ autoConnect,
+ wallets,
+ wallet,
+ publicKey,
+ adapterState.connecting,
+ adapterState.connected,
+ adapterState.disconnecting,
+ kitSigners,
+ currentChain,
+ handleGetChainId,
+ updateWalletNameAsync,
+ handleConnect,
+ handleDisconnect,
+ handleSignTransactionExtended,
+ handleSendTransactionExtended,
+ handleSignAndSendTransactionExtended,
+ handleSignAllTransactionsExtended,
+ handleSignMessage,
+ handleSignIn,
+ hasFeature,
+ ],
+ );
+
return (
-
+
{children}
);
diff --git a/packages/wallet-standard/CHANGELOG.md b/packages/wallet-standard/CHANGELOG.md
index f696e26..d9046d5 100644
--- a/packages/wallet-standard/CHANGELOG.md
+++ b/packages/wallet-standard/CHANGELOG.md
@@ -1,5 +1,55 @@
# @hermis/wallet-standard-base
+## 1.1.0
+
+### Minor Changes
+
+- b0d2b24: P1 security & performance hardening.
+
+ ⚠️ **Behaviour changes worth checking before upgrading from 2.0.x:**
+
+ - `getStandardWalletAdapters(adapters, endpoint, network)` and
+ `createWalletConnectionManager(adapters, network, ...)` now require an
+ explicit `network` argument — RPC-URL substring inference was removed.
+ - Local-key `signMessage` now wraps the message with the Solana off-chain
+ domain separator (`0xff` + `"solana offchain"` + header). Existing
+ verifiers must apply the same wrapper. External wallet adapters
+ (Phantom/Backpack/Solflare/any Standard Wallet) are unaffected.
+ - The alternative 3-arg `sendTransaction(tx, wallet, options)` form is
+ removed; use the 4-arg form with an explicit connection.
+
+ See `docs/migration/v1-to-v2.mdx` for migration steps and verifier-side
+ sample code.
+
+ **Security / correctness**
+
+ - Fix Kit transaction-sending signer to `bs58.decode` signature bytes
+ (previously returned UTF-8 of the base58 string).
+ - Apply the Solana off-chain message domain separator on local-key
+ `signMessage` paths.
+ - Require explicit `network` for cluster identification; remove URL
+ substring inference.
+ - Remove `sendTransaction` / `signAndSendTransaction` alt overload that
+ silently routed to a hard-coded devnet connection.
+ - Fix `useStandardWalletAdapters` / `HermisProvider` memoisation that
+ missed adapter swaps with the same array length.
+ - Strip debug `console.log` calls leaking unsigned transaction bytes
+ from `StandardWalletAdapter`.
+
+ **Performance**
+
+ - Memoise `WalletContext.Provider` value to eliminate full-tree
+ re-renders on `WalletProvider` re-renders.
+ - Bound the `wallet-standard:app-ready` dispatch loop: 10×1s → 4-shot
+ backoff (100ms, 300ms, 1s, 3s).
+ - `useSolanaTransaction` polling: stop on `finalized`/`failed`,
+ exponential backoff (1s → 30s).
+
+### Patch Changes
+
+- Updated dependencies [b0d2b24]
+ - @hermis/solana-headless-core@2.1.0
+
## 1.0.0
### Major Changes
diff --git a/packages/wallet-standard/package.json b/packages/wallet-standard/package.json
index 33a32fa..b99053c 100644
--- a/packages/wallet-standard/package.json
+++ b/packages/wallet-standard/package.json
@@ -1,6 +1,6 @@
{
"name": "@hermis/wallet-standard-base",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "The framework agnostic wallet standard implementation. This package is completely framework-agnostic, allowing you to integrate Solana wallets with any UI framework or vanilla JavaScript.",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -33,7 +33,7 @@
"packageManager": "pnpm@10.5.2",
"dependencies": {
"@hermis/errors": "1.0.0",
- "@hermis/solana-headless-core": "2.0.0",
+ "@hermis/solana-headless-core": "2.1.0",
"@solana/wallet-adapter-base": "^0.9.23",
"@solana/web3.js": "^1.98.0",
"@solana-mobile/wallet-adapter-mobile": "^2.0.0",
diff --git a/packages/wallet-standard/src/chain.ts b/packages/wallet-standard/src/chain.ts
new file mode 100644
index 0000000..7b491f6
--- /dev/null
+++ b/packages/wallet-standard/src/chain.ts
@@ -0,0 +1,26 @@
+import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
+
+/**
+ * Wallet Standard chain identifiers for Solana networks.
+ *
+ * Format: `solana:`.
+ * @see https://github.com/wallet-standard/wallet-standard
+ */
+export const SOLANA_MAINNET_CHAIN = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const;
+export const SOLANA_DEVNET_CHAIN = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1' as const;
+export const SOLANA_TESTNET_CHAIN = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z' as const;
+
+/**
+ * Convert a {@link WalletAdapterNetwork} into the canonical Wallet Standard
+ * chain identifier expected by wallet features like `signAndSendTransaction`.
+ */
+export function networkToChainId(network: WalletAdapterNetwork): `solana:${string}` {
+ switch (network) {
+ case WalletAdapterNetwork.Mainnet:
+ return SOLANA_MAINNET_CHAIN;
+ case WalletAdapterNetwork.Devnet:
+ return SOLANA_DEVNET_CHAIN;
+ case WalletAdapterNetwork.Testnet:
+ return SOLANA_TESTNET_CHAIN;
+ }
+}
diff --git a/packages/wallet-standard/src/environment.ts b/packages/wallet-standard/src/environment.ts
index 01589e3..8dbb013 100644
--- a/packages/wallet-standard/src/environment.ts
+++ b/packages/wallet-standard/src/environment.ts
@@ -129,20 +129,3 @@ export function isIosAndRedirectable(): boolean {
return isIos && isSafari;
}
-/**
- * Infer Solana cluster from endpoint
- * @param endpoint RPC endpoint URL
- * @returns The inferred cluster/network
- */
-export function getInferredNetworkFromEndpoint(endpoint?: string): WalletAdapterNetwork {
- if (!endpoint) {
- return WalletAdapterNetwork.Mainnet;
- }
- if (/devnet/i.test(endpoint)) {
- return WalletAdapterNetwork.Devnet;
- } else if (/testnet/i.test(endpoint)) {
- return WalletAdapterNetwork.Testnet;
- } else {
- return WalletAdapterNetwork.Mainnet;
- }
-}
\ No newline at end of file
diff --git a/packages/wallet-standard/src/utils.ts b/packages/wallet-standard/src/utils.ts
index 1bbfb45..d097786 100644
--- a/packages/wallet-standard/src/utils.ts
+++ b/packages/wallet-standard/src/utils.ts
@@ -16,7 +16,7 @@ import {
toUiWallet,
toUiWalletAccount,
} from './types.js';
-import { getEnvironment, getUriForAppIdentity, getUserAgent, getInferredNetworkFromEndpoint } from './environment.js';
+import { getEnvironment, getUriForAppIdentity, getUserAgent } from './environment.js';
import { SolanaMobileWalletAdapterWalletName } from './constants.js';
import {
@@ -28,52 +28,37 @@ import {
// isWalletAdapterCompatibleStandardWallet is imported from types.js
/**
- * Create a Mobile Wallet Adapter
- * @param endpoint Optional RPC endpoint
+ * Create a Mobile Wallet Adapter for the given network.
+ *
+ * Network must be supplied explicitly by the caller — inferring a cluster from
+ * the RPC endpoint URL is unsafe (URLs whose path happens to contain "mainnet"
+ * would be misclassified), so this function deliberately does not accept that fallback.
+ *
+ * @param endpoint Optional RPC endpoint (unused for cluster selection; kept for future use)
+ * @param network Solana network the mobile adapter should authorize against
* @returns Mobile wallet adapter or null if not available
*/
-export async function createMobileWalletAdapter(endpoint?: string): Promise {
+export async function createMobileWalletAdapter(
+ endpoint: string | undefined,
+ network: WalletAdapterNetwork,
+): Promise {
// Skip if not in browser environment
if (typeof window === 'undefined' || !window.navigator) {
- console.log('not in a browser');
return null;
}
-
- // Try to import the mobile wallet adapter
- // let SolanaMobileWalletAdapterState;
- const {
+
+ const {
SolanaMobileWalletAdapter,
- } = await import( '@solana-mobile/wallet-adapter-mobile')
- try {
- console.log(SolanaMobileWalletAdapter);
- // SolanaMobileWalletAdapterState = SolanaMobileWalletAdapter;
- } catch (error) {
- console.warn('Mobile wallet adapter not available:', error);
- console.log('null returned');
- return null;
- }
-
- // Check if adapter class was found
+ } = await import('@solana-mobile/wallet-adapter-mobile');
+
if (!SolanaMobileWalletAdapter) {
- console.log('null returned');
return null;
}
-
- // Try to create the adapter
+
try {
- console.log('Mobile wallet');
- const network = getInferredNetworkFromEndpoint(endpoint);
-
- // const {
- // SolanaMobileWalletAdapter,
- // createDefaultAuthorizationResultCache,
- // createDefaultWalletNotFoundHandler
- // } = await import( '@solana-mobile/wallet-adapter-mobile')
-
const adapter = new SolanaMobileWalletAdapter({
addressSelector: {
select: (addresses: string[]) => Promise.resolve(addresses[0]),
- // resolveImmediately: true,
},
appIdentity: {
name: document.title || 'Solana dApp',
@@ -83,11 +68,10 @@ export async function createMobileWalletAdapter(endpoint?: string): Promise void>();
// Store for existing adapters from different consumers
-const existingAdaptersStore = new Map();
+const existingAdaptersStore = new Map();
/**
* Generate a unique key for a consumer based on their adapters
*/
-function generateConsumerKey(adapters: (Adapter | SolanaMobileWalletAdapter)[], endpoint?: string): string {
+function generateConsumerKey(adapters: (Adapter | SolanaMobileWalletAdapter)[], endpoint: string | undefined, network: WalletAdapterNetwork): string {
const adapterNames = adapters.map(a => a.name).sort().join(',');
- return `${adapterNames}:${endpoint || 'default'}`;
+ return `${adapterNames}:${endpoint || 'default'}:${network}`;
}
/**
@@ -169,66 +153,77 @@ async function updateAdapters(consumerKey?: string) {
try {
// Get all existing adapters from all consumers
const allExistingAdapters = getAllExistingAdapters();
-
- // Determine endpoint - use the first available endpoint
+
+ // Determine endpoint and network from the first stored consumer entry.
+ // Most setups have a single consumer; for multi-consumer cases the first
+ // win is good enough — adapters created here will have their network
+ // overridden by `setNetwork` below before the call returns.
let endpoint: string | undefined;
- for (const { endpoint: consumerEndpoint } of existingAdaptersStore.values()) {
- if (consumerEndpoint) {
- endpoint = consumerEndpoint;
- break;
- }
+ let network: WalletAdapterNetwork | undefined;
+ for (const entry of existingAdaptersStore.values()) {
+ if (!endpoint && entry.endpoint) endpoint = entry.endpoint;
+ if (!network) network = entry.network;
+ if (endpoint && network) break;
}
-
+
const userAgentString = getUserAgent();
let adaptersToUse = [...allExistingAdapters];
-
+
// Check if we're in a mobile environment
const isMobileEnv = getEnvironment({
adapters: allExistingAdapters,
userAgentString
}) === Environment.MOBILE_WEB;
-
+
// Check if mobile wallet adapter is already included
const hasMobileWalletAdapter = allExistingAdapters.some(
adapter => adapter.name === SolanaMobileWalletAdapterWalletName
);
-
+
// Separate mobile wallet adapter from regular adapters
- const regularAdapters = allExistingAdapters.filter(adapter =>
+ const regularAdapters = allExistingAdapters.filter(adapter =>
adapter.name !== SolanaMobileWalletAdapterWalletName
) as Adapter[];
-
+
let mobileAdapter: SolanaMobileWalletAdapter | null = null;
-
+
// Add mobile wallet adapter if in mobile environment and not already included
- if (isMobileEnv && !hasMobileWalletAdapter) {
- console.log("Created Mobile Adapter");
-
- const createdMobileAdapter = await createMobileWalletAdapter(endpoint);
-
+ if (isMobileEnv && !hasMobileWalletAdapter && network) {
+ const createdMobileAdapter = await createMobileWalletAdapter(endpoint, network);
+
if (createdMobileAdapter && isMobileWalletAdapter(createdMobileAdapter)) {
mobileAdapter = createdMobileAdapter;
}
} else if (hasMobileWalletAdapter) {
// Find existing mobile adapter
- const existingMobileAdapter = allExistingAdapters.find(adapter =>
+ const existingMobileAdapter = allExistingAdapters.find(adapter =>
adapter.name === SolanaMobileWalletAdapterWalletName
);
if (existingMobileAdapter && isMobileWalletAdapter(existingMobileAdapter)) {
mobileAdapter = existingMobileAdapter;
}
}
-
+
// Get detected wallets merged with regular adapters only
const mergedRegularAdapters = getDetectedWalletAdapters(regularAdapters);
// Combine all adapters: mobile adapter first (if exists), then regular adapters
adaptersToUse = mobileAdapter ? [mobileAdapter, ...mergedRegularAdapters] : mergedRegularAdapters;
+ // Tag every standard wallet adapter with the active network so its chain
+ // hint is correct on wallet feature calls.
+ if (network) {
+ for (const adapter of adaptersToUse) {
+ if (hasSetNetwork(adapter)) {
+ adapter.setNetwork(network);
+ }
+ }
+ }
+
// Update current adapters if they've changed
- const hasChanged = currentAdapters.length !== adaptersToUse.length ||
+ const hasChanged = currentAdapters.length !== adaptersToUse.length ||
!currentAdapters.every((adapter, index) => adapter.name === adaptersToUse[index]?.name);
-
+
if (hasChanged) {
currentAdapters = adaptersToUse;
notifyAdapterChanges();
@@ -241,16 +236,33 @@ async function updateAdapters(consumerKey?: string) {
}
}
+function hasSetNetwork(adapter: unknown): adapter is { setNetwork(network: WalletAdapterNetwork): void } {
+ return (
+ typeof adapter === 'object' &&
+ adapter !== null &&
+ 'setNetwork' in adapter &&
+ typeof (adapter as { setNetwork: unknown }).setNetwork === 'function'
+ );
+}
+
/**
* Get wallet adapters for all available standard wallets
- * This function now handles initialization and change detection automatically
+ *
+ * This function handles initialization and change detection automatically.
+ * The `network` parameter is required — every adapter returned will have its
+ * cluster set explicitly. We do **not** infer the cluster from `endpoint` by
+ * URL substring matching, which is unsafe (untrusted URLs containing the
+ * substring "mainnet" would be misclassified as mainnet).
+ *
* @param existingAdapters Existing (non-standard) adapters to include in result
- * @param endpoint Optional endpoint for mobile wallet adapter
+ * @param endpoint Optional endpoint (used by mobile wallet adapter)
+ * @param network Active Solana network for the cluster hint
* @returns Array of all adapters including standard wallet adapters
*/
export async function getStandardWalletAdapters(
- existingAdapters: (Adapter | SolanaMobileWalletAdapter)[] = [],
- endpoint?: string
+ existingAdapters: (Adapter | SolanaMobileWalletAdapter)[] = [],
+ endpoint: string | undefined,
+ network: WalletAdapterNetwork,
): Promise<(Adapter | SolanaMobileWalletAdapter)[]> {
// Skip if not in browser environment
if (typeof window === 'undefined' || !window.navigator) {
@@ -258,10 +270,10 @@ export async function getStandardWalletAdapters(
}
// Generate a unique key for this consumer
- const consumerKey = generateConsumerKey(existingAdapters, endpoint);
-
+ const consumerKey = generateConsumerKey(existingAdapters, endpoint, network);
+
// Store the existing adapters for this consumer
- existingAdaptersStore.set(consumerKey, { adapters: existingAdapters, endpoint });
+ existingAdaptersStore.set(consumerKey, { adapters: existingAdapters, endpoint, network });
// Initialize wallet detection system only once
if (!isWalletDetectionInitialized) {
@@ -473,26 +485,6 @@ export function isSameWallet(wallet1: TypedStandardWallet | Adapter, wallet2: Ty
return getWalletIdentifier(wallet1) === getWalletIdentifier(wallet2);
}
-/**
- * Detect cluster/network from RPC endpoint
- */
-export function detectClusterFromEndpoint(endpoint: string): string {
- const url = endpoint.toLowerCase();
-
- if (url.includes('mainnet') || url.includes('api.solana.com')) {
- return 'solana:mainnet-beta';
- } else if (url.includes('devnet')) {
- return 'solana:devnet';
- } else if (url.includes('testnet')) {
- return 'solana:testnet';
- } else if (url.includes('localhost') || url.includes('127.0.0.1')) {
- return 'solana:localnet';
- }
-
- return 'solana:mainnet-beta';
- // return 'unknown';
-}
-
/**
* Get cluster identifier for wallet standard
*/
diff --git a/packages/wallet-standard/src/wallet-adapter.ts b/packages/wallet-standard/src/wallet-adapter.ts
index 4e038a8..bb58ab8 100644
--- a/packages/wallet-standard/src/wallet-adapter.ts
+++ b/packages/wallet-standard/src/wallet-adapter.ts
@@ -1,4 +1,5 @@
import {
+ WalletAdapterNetwork,
WalletReadyState,
WalletName,
WalletAdapterEvents,
@@ -58,10 +59,8 @@ import {
StandardEventsChangeEvent
} from './types.js';
import { WalletAccount } from '@wallet-standard/base';
-import {
- detectClusterFromEndpoint,
- ClusterMismatchError
-} from './utils.js';
+import { ClusterMismatchError } from './utils.js';
+import { networkToChainId } from './chain.js';
import bs58 from 'bs58';
@@ -111,10 +110,10 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
private _eventEmitter = new EventEmitter();
private _removeAccountChangeListener: (() => void) | null = null;
private _connectedAccount: StandardWalletAccount[] | null = null;
- private _currentCluster: string | null = null;
+ private _currentCluster: `solana:${string}` | null = null;
private _rpcEndpoint: string | null = null;
- constructor(wallet: TypedStandardWallet) {
+ constructor(wallet: TypedStandardWallet, network?: WalletAdapterNetwork) {
if (!isWalletAdapterCompatibleStandardWallet(wallet)) {
throw new Error('Wallet is not adapter compatible');
}
@@ -124,6 +123,10 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
this.url = wallet.website || '';
this.icon = wallet.icon || '';
+ if (network) {
+ this._currentCluster = networkToChainId(network);
+ }
+
// Setup account change listener - following official wallet-standard pattern
if (StandardEventsMethod in wallet.features) {
try {
@@ -331,8 +334,6 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
async connect(): Promise {
try {
if (this.connected || this.connecting) {
- console.log("DEBUG connected:", this.connected);
- console.log("DEBUG connecting:", this.connecting);
return;
}
this._connecting = true;
@@ -349,8 +350,6 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
const connectResult = await connectFeature.connect();
- console.log("DEBUG connectResult", connectResult);
-
if (!connectResult || !connectResult.accounts || !Array.isArray(connectResult.accounts)) {
throw new Error('Invalid connect result from wallet');
}
@@ -427,10 +426,6 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
try {
// Path 1: If connection is provided, delegate to core for sign + send
if (connection) {
- console.log('🔍 [StandardWalletAdapter.sendTransaction] Received transaction:', transaction);
- console.log('🔍 [StandardWalletAdapter.sendTransaction] Transaction type:', transaction.constructor.name);
- // console.log('🔍 [StandardWalletAdapter.sendTransaction] Transaction.signatures:', transaction.signatures);
-
// Set recent blockhash if not already set (for legacy Transaction)
if (transaction instanceof Transaction && !transaction.recentBlockhash) {
const { blockhash } = await getLatestBlockhash(connection, options.preflightCommitment);
@@ -439,14 +434,11 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
// Check if transaction is already signed
// Note: Don't use instanceof - it fails with bundling/prototype issues
- console.log('🔍 [StandardWalletAdapter.sendTransaction] About to check if signed...');
const isSigned = isTransactionSigned(transaction as any);
- console.log(`🔍 [StandardWalletAdapter.sendTransaction] isSigned result: ${isSigned}`);
let rawTransaction: Uint8Array;
if (isSigned) {
- console.log('✅ [StandardWalletAdapter.sendTransaction] Transaction is SIGNED - sending directly without wallet prompt');
// Transaction is already signed - serialize and send directly (no wallet prompt)
if (transaction instanceof Transaction) {
rawTransaction = transaction.serialize();
@@ -458,7 +450,6 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
throw new Error('Signed transaction does not have a serialize method');
}
} else {
- console.log('⚠️ [StandardWalletAdapter.sendTransaction] Transaction is UNSIGNED - signing first (will prompt wallet)');
// Transaction is unsigned - sign it first using core
const dualOptions = {
chain: this._currentCluster || 'solana:mainnet'
@@ -659,7 +650,6 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
}
async signMessage(message: Uint8Array): Promise {
- console.log("DEBUG wallet", this._wallet);
if (!this.connected) {
throw new HermisError(
HERMIS_ERROR__WALLET_CONNECTION__NOT_CONNECTED,
@@ -731,11 +721,24 @@ export class StandardWalletAdapter implements IStandardWalletAdapter {
}
/**
- * Set the current RPC endpoint for cluster detection
+ * Record the RPC endpoint this adapter is currently connected to.
+ *
+ * Does not touch the active cluster — call {@link setNetwork} for that.
+ * Inferring the cluster from a URL by substring match is unsafe because
+ * untrusted URLs ("example.com/mainnet-rpc") would be classified as mainnet
+ * and produce mismatched chain hints to wallets. Networks must be supplied
+ * explicitly by the consumer.
*/
setRpcEndpoint(endpoint: string): void {
this._rpcEndpoint = endpoint;
- this._currentCluster = detectClusterFromEndpoint(endpoint);
+ }
+
+ /**
+ * Set the active Solana network so chain-aware features (signAndSendTransaction
+ * etc.) tag their requests with the right chain identifier.
+ */
+ setNetwork(network: WalletAdapterNetwork): void {
+ this._currentCluster = networkToChainId(network);
}
/**
diff --git a/packages/wallet-standard/src/wallet-detection.ts b/packages/wallet-standard/src/wallet-detection.ts
index 8ad700e..c14669d 100644
--- a/packages/wallet-standard/src/wallet-detection.ts
+++ b/packages/wallet-standard/src/wallet-detection.ts
@@ -317,29 +317,34 @@ export function initializeWalletDetection(): () => void {
console.warn("Failed to dispatch app ready event:", error);
}
- // Set up periodic check for delayed wallet registration
- const periodicCheck = setInterval(() => {
- try {
- // Re-dispatch app ready event to catch any wallets that registered late
- const appReadyEvent = new CustomEvent("wallet-standard:app-ready", {
- detail: api,
- bubbles: false,
- cancelable: false,
- composed: false,
- });
- window.dispatchEvent(appReadyEvent);
- } catch (error) {
- console.warn("Failed to dispatch periodic app ready event:", error);
- }
- }, 1000); // Check every second for the first 10 seconds
-
- // Clear periodic check after 10 seconds
- setTimeout(() => {
- clearInterval(periodicCheck);
- }, 10000);
+ // Re-dispatch `wallet-standard:app-ready` on a short backoff schedule to
+ // catch wallets that register slightly after the page loads. Previously
+ // this fired every second for 10 seconds — 10 dispatches, each of which
+ // re-registered every wallet and re-rendered every subscriber even after
+ // the wallet set had stabilized. Backed-off 4-shot schedule covers the
+ // same window with materially less churn.
+ const RETRY_DELAYS = [100, 300, 1000, 3000];
+ const retryTimeouts: ReturnType[] = [];
+
+ RETRY_DELAYS.forEach((delay) => {
+ const id = setTimeout(() => {
+ try {
+ const appReadyEvent = new CustomEvent("wallet-standard:app-ready", {
+ detail: api,
+ bubbles: false,
+ cancelable: false,
+ composed: false,
+ });
+ window.dispatchEvent(appReadyEvent);
+ } catch (error) {
+ console.warn("Failed to dispatch periodic app ready event:", error);
+ }
+ }, delay);
+ retryTimeouts.push(id);
+ });
cleanupFunctions.push(() => {
- clearInterval(periodicCheck);
+ retryTimeouts.forEach((id) => clearTimeout(id));
});
} catch (error) {