This guide covers wallet connection and transaction signing for web applications using @proton/web-sdk.
npm install @proton/web-sdk@4 @proton/link@4 # 4.x shape, matches the examples below
npm install @proton/web-sdk@5.1.0 @proton/link@5.1.0 # current GA, 5.x options shape (see version note)Important: The @proton/link package is required for mobile wallet support. See Mobile Wallet Support for details.
Version note:
@proton/web-sdk5.1.0 is GA and is the npmlatest. The examples in this module still use the 4.x options shape. 5.x changes that shape:appName/appLogomove touiOptions.appInfo.{name,logo,logoRounded},customStyleOptionsis replaced byuiOptions.theme/themes, andselectorOptionskeeps onlywalletTypeandenabledWalletTypes. Either major works. Install@proton/web-sdk@5.1.0 @proton/link@5.1.0, or pin both to@4to use the examples below unchanged. Always keep@proton/linkon the same major as the SDK.// 5.x options shape const { link, session } = await ProtonWebSDK({ linkOptions: { chainId, endpoints: ['https://proton.eosusa.io'] }, transportOptions: { requestAccount: 'mycontract' }, selectorOptions: { enabledWalletTypes: ['proton', 'webauth', 'anchor'] }, uiOptions: { appInfo: { name: 'My dApp', logo: 'https://myapp.com/logo.png', logoRounded: true } } });
import ProtonWebSDK from '@proton/web-sdk';
import '@proton/link'; // Required for mobile wallet support
// Login
const { link, session } = await ProtonWebSDK({
linkOptions: {
chainId: '384da888112027f0321850a169f737c33e53b388aad48b5adace4bab97f437e0',
endpoints: ['https://proton.eosusa.io']
},
selectorOptions: { appName: 'My dApp' }
});
// session.auth = { actor: 'username', permission: 'active' }
console.log('Logged in as:', session.auth.actor);
// Send transaction
const result = await session.transact({
actions: [{
account: 'eosio.token',
name: 'transfer',
authorization: [session.auth],
data: {
from: session.auth.actor,
to: 'recipient',
quantity: '1.0000 XPR',
memo: 'Hello!'
}
}]
}, { broadcast: true });| Key | Type | Required | Description |
|---|---|---|---|
endpoints |
string[] |
Yes | Array of RPC endpoints (multiple for fault tolerance) |
chainId |
string |
No | Chain ID; if omitted it is fetched from the first endpoint's get_info |
storage |
LinkStorage |
No | Custom storage adapter |
storagePrefix |
string |
No | Prefix for storage keys (default: proton-storage). Keys are ${storagePrefix}-${key} in localStorage (user-auth, wallet-type, link sessions) |
restoreSession |
boolean |
No | Restore previous session without wallet selector |
| Key | Type | Description |
|---|---|---|
requestAccount |
string |
Required for mobile. Your dApp's account name - used for deep link callbacks so mobile app knows where to return after signing |
requestStatus |
boolean |
Show request status UI while signing (default: true) |
Important: Without
requestAccount, the WebAuth mobile app will sign transactions but won't return to your browser. Always set this to your contract or dApp account name.
Scope saved sessions with
storagePrefixwhen one origin serves more than one chain or contract. With the default prefix, every build on the same origin shares one saved session. A testnet build onlocalhost(or a preview URL) then restores a testnet session into a mainnet build, and the reverse. Derive the prefix from the chain and contract, and use the same value when you check for a saved session before loading the SDK:const storagePrefix = `myapp-${chainId.slice(0, 12)}-${contract}`; const hasSession = !!localStorage.getItem(`${storagePrefix}-user-auth`); // skip loading the SDK if false await ProtonWebSDK({ linkOptions: { chainId, endpoints, restoreSession: true, storagePrefix }, transportOptions: { requestAccount: contract }, uiOptions: { appInfo: { name: 'My dApp', logo: 'https://myapp.com/logo.png', logoRounded: true } }, // 5.x });
| Key | Type | Description |
|---|---|---|
appName |
string |
Your app name (shown in wallet selector) |
appLogo |
string |
URL to your app logo |
enabledWalletTypes |
string[] |
Wallet types to show: proton, webauth, anchor |
customStyleOptions |
object |
Custom styling for modal |
import ProtonWebSDK from '@proton/web-sdk';
const CHAIN_ID = '384da888112027f0321850a169f737c33e53b388aad48b5adace4bab97f437e0';
const ENDPOINTS = ['https://proton.eosusa.io', 'https://proton.protonuk.io'];
class ProtonService {
private link: any = null;
private session: any = null;
get isLoggedIn(): boolean {
return this.session !== null;
}
get actor(): string {
return this.session?.auth?.actor ?? '';
}
get permission(): string {
return this.session?.auth?.permission ?? 'active';
}
async login(): Promise<{ actor: string; permission: string } | null> {
try {
const { link, session } = await ProtonWebSDK({
linkOptions: {
chainId: CHAIN_ID,
endpoints: ENDPOINTS
},
transportOptions: {
requestAccount: 'myapp'
},
selectorOptions: {
appName: 'My dApp',
appLogo: 'https://myapp.com/logo.png',
enabledWalletTypes: ['proton', 'webauth', 'anchor']
}
});
this.link = link;
this.session = session;
return session.auth;
} catch (error) {
console.error('Login failed:', error);
return null;
}
}
async restoreSession(): Promise<boolean> {
try {
const { link, session } = await ProtonWebSDK({
linkOptions: {
chainId: CHAIN_ID,
endpoints: ENDPOINTS,
restoreSession: true
},
transportOptions: {
requestAccount: 'myapp'
},
selectorOptions: {
appName: 'My dApp'
}
});
if (session) {
this.link = link;
this.session = session;
return true;
}
return false;
} catch (error) {
console.error('Session restore failed:', error);
return false;
}
}
async logout(): Promise<void> {
if (this.link && this.session) {
await this.link.removeSession('myapp', this.session.auth, this.session.chainId);
}
this.link = null;
this.session = null;
}
async transact(actions: any[]): Promise<any> {
if (!this.session) {
throw new Error('Not logged in');
}
return this.session.transact(
{ actions },
{ broadcast: true }
);
}
}
export const protonService = new ProtonService();async function transferTokens(to: string, amount: string, memo: string = '') {
const result = await session.transact({
actions: [{
account: 'eosio.token',
name: 'transfer',
authorization: [session.auth],
data: {
from: session.auth.actor,
to: to,
quantity: amount, // e.g., '10.0000 XPR'
memo: memo
}
}]
}, { broadcast: true });
return result;
}| Token | Contract | Decimals | Example |
|---|---|---|---|
| XPR | eosio.token |
4 | 1.0000 XPR |
| XUSDT | xtokens |
6 | 1.000000 XUSDT |
| FOOBAR (testnet only) | xtokens |
6 | 1.000000 FOOBAR |
| LOAN | loan.token |
4 | 1.0000 LOAN |
async function createBattle(amount: string, direction: number, duration: number) {
const result = await session.transact({
actions: [{
account: 'pricebattle',
name: 'create',
authorization: [session.auth],
data: {
creator: session.auth.actor,
amount: amount,
direction: direction, // 1=UP, 2=DOWN
oracle_index: 4, // BTC/USD
duration: duration // seconds
}
}]
}, { broadcast: true });
return result;
}async function depositAndStake(amount: string) {
const result = await session.transact({
actions: [
// First action: transfer
{
account: 'eosio.token',
name: 'transfer',
authorization: [session.auth],
data: {
from: session.auth.actor,
to: 'stakingcontract',
quantity: amount,
memo: 'deposit'
}
},
// Second action: stake
{
account: 'stakingcontract',
name: 'stake',
authorization: [session.auth],
data: {
account: session.auth.actor,
amount: amount
}
}
]
}, { broadcast: true });
return result;
}const { link, session } = await ProtonWebSDK({
linkOptions: { /* ... */ },
selectorOptions: {
appName: 'My dApp',
customStyleOptions: {
modalBackgroundColor: '#1a1a2e',
logoBackgroundColor: '#16213e',
isLogoRound: true,
optionBackgroundColor: '#0f3460',
optionFontColor: '#e94560',
primaryFontColor: '#ffffff',
secondaryFontColor: '#a0a0a0',
linkColor: '#e94560'
}
}
});By default, the SDK uses localStorage. For custom storage (e.g., encrypted storage, server-side):
interface LinkStorage {
write(key: string, data: string): Promise<void>;
read(key: string): Promise<string | null>;
remove(key: string): Promise<void>;
}
class SecureStorage implements LinkStorage {
async write(key: string, data: string): Promise<void> {
// Your secure storage logic
localStorage.setItem(key, encrypt(data));
}
async read(key: string): Promise<string | null> {
const data = localStorage.getItem(key);
return data ? decrypt(data) : null;
}
async remove(key: string): Promise<void> {
localStorage.removeItem(key);
}
}
const { link, session } = await ProtonWebSDK({
linkOptions: {
endpoints: ENDPOINTS,
storage: new SecureStorage()
},
// ...
});import React, { createContext, useContext, useState, useEffect } from 'react';
import ProtonWebSDK from '@proton/web-sdk';
interface ProtonContextType {
session: any;
login: () => Promise<void>;
logout: () => Promise<void>;
transact: (actions: any[]) => Promise<any>;
}
const ProtonContext = createContext<ProtonContextType | null>(null);
export function ProtonProvider({ children }: { children: React.ReactNode }) {
const [link, setLink] = useState<any>(null);
const [session, setSession] = useState<any>(null);
useEffect(() => {
// Restore session on mount
restoreSession();
}, []);
async function restoreSession() {
try {
const { link, session } = await ProtonWebSDK({
linkOptions: {
chainId: CHAIN_ID,
endpoints: ENDPOINTS,
restoreSession: true
},
selectorOptions: { appName: 'My dApp' }
});
if (session) {
setLink(link);
setSession(session);
}
} catch (e) {
console.error('Restore failed:', e);
}
}
async function login() {
const { link, session } = await ProtonWebSDK({
linkOptions: {
chainId: CHAIN_ID,
endpoints: ENDPOINTS
},
selectorOptions: { appName: 'My dApp' }
});
setLink(link);
setSession(session);
}
async function logout() {
if (link && session) {
await link.removeSession('myapp', session.auth, session.chainId);
}
setLink(null);
setSession(null);
}
async function transact(actions: any[]) {
if (!session) throw new Error('Not logged in');
return session.transact({ actions }, { broadcast: true });
}
return (
<ProtonContext.Provider value={{ session, login, logout, transact }}>
{children}
</ProtonContext.Provider>
);
}
export function useProton() {
const context = useContext(ProtonContext);
if (!context) throw new Error('useProton must be used within ProtonProvider');
return context;
}function WalletButton() {
const { session, login, logout } = useProton();
if (session) {
return (
<div>
<span>Connected: {session.auth.actor}</span>
<button onClick={logout}>Disconnect</button>
</div>
);
}
return <button onClick={login}>Connect Wallet</button>;
}async function safeTransact(actions: any[]) {
try {
const result = await session.transact({ actions }, { broadcast: true });
return { success: true, result };
} catch (error: any) {
// User cancelled
if (error.message?.includes('User cancelled')) {
return { success: false, error: 'Transaction cancelled by user' };
}
// Insufficient resources
if (error.message?.includes('insufficient')) {
return { success: false, error: 'Insufficient resources (RAM/CPU/NET)' };
}
// Contract assertion failed
if (error.message?.includes('assertion failure')) {
const match = error.message.match(/assertion failure with message: (.+)/);
return { success: false, error: match?.[1] ?? 'Transaction failed' };
}
return { success: false, error: error.message ?? 'Unknown error' };
}
}5.1.0 cancel and login shapes:
ProtonWebSDK()usually returns{ error }for a failed or cancelled login instead of throwing, and it can resolve with nosessionat all, so checkres?.sessionbefore you destructure it.@proton/linkcancels throw an error withcode'E_CANCEL'or'E_WALLET_TYPE'. The WebAuth browser link rejects with the bare string'Closed', or'Trying to login'when a login replaces a pending transaction. None of these contain "User cancelled". Treat anything that mentions broadcast, network, timeout or connection as an unknown outcome, not a cancel, and re-read chain state before you offer to sign again.
For mobile wallet signing to work (WebAuth iOS/Android app), you must install and import @proton/link.
The @proton/link package provides the transport layer for mobile deep linking. Without it, the SDK cannot communicate with the WebAuth mobile app to request transaction signatures.
npm install @proton/web-sdk@4 @proton/link@4import ProtonWebSDK from '@proton/web-sdk';
import '@proton/link'; // Required - enables mobile deep linking transportNote: The @proton/link import doesn't expose any API you need to call directly. Simply importing it registers the transport handlers needed for mobile wallet communication.
Important: Static imports often fail for mobile wallet support. Use dynamic imports with Promise.all to ensure @proton/link is fully loaded before any wallet operations:
let ConnectWallet: any;
let sdkReady: Promise<void> | null = null;
if (typeof window !== 'undefined') {
sdkReady = Promise.all([
import('@proton/web-sdk').then((mod) => {
ConnectWallet = mod.default;
}),
import('@proton/link') // Critical for mobile deep linking
]).then(() => {});
}
// Helper to ensure SDK is loaded before use
const waitForSdk = async () => {
if (sdkReady) await sdkReady;
};
// Always await before using ConnectWallet
async function login() {
await waitForSdk();
const { link, session } = await ConnectWallet({
// ... options
});
}This pattern ensures both packages are fully loaded and transport handlers are registered before any wallet connection attempts.
For mobile wallet to work correctly, ensure ALL of these:
-
Install both packages:
npm install @proton/web-sdk@4 @proton/link@4 -
Use dynamic imports with Promise.all (not static imports):
sdkReady = Promise.all([ import('@proton/web-sdk').then((mod) => { ConnectWallet = mod.default; }), import('@proton/link') ]).then(() => {});
-
Set
requestAccountto your dApp/contract name:transportOptions: { requestAccount: 'mycontract', // Required for mobile callback }
-
Enable wallet types explicitly:
selectorOptions: { appName: 'My dApp', enabledWalletTypes: ['webauth', 'proton'], }
| Symptom | Cause | Fix |
|---|---|---|
| Mobile stuck on "Processing..." | Missing @proton/link or static import |
Use dynamic import pattern |
| App signs but doesn't return to browser | requestAccount empty or missing |
Set to your contract name |
| Only browser wallet shown | enabledWalletTypes missing proton |
Add ['webauth', 'proton'] |
| "Unknown Requestor" shown in the wallet | transportOptions.requestAccount not set |
Set requestAccount to your dApp/contract account |
Safari iOS blocks popups by default, which prevents the WebAuth browser wallet from opening. Users on Safari iOS should either:
- Disable popup blocker: Settings > Safari > Block Pop-ups OFF
- Use the WebAuth mobile app instead of the browser wallet (recommended)
- Use a different browser (Chrome, Firefox)
Developer tip: Show a help message after several seconds of "processing" to guide users to check their popup blocker settings or switch to the WebAuth mobile app.
@proton/web-sdk@5.1.0with@proton/link@5.1.0(current GA, 5.x options shape: see the version note at the top), or@proton/web-sdk@^4.4.1with@proton/link@^4.4.1(4.x options shape used by the examples in this module)- Keep
@proton/linkon the same major as@proton/web-sdk. Do not pin3.2.3-x
The WebAuth browser wallet opens its signing window from inside session.transact(). Browsers allow that window only during a user gesture. If one click handler awaits the ProtonWebSDK(...) login and then calls session.transact(), the gesture has expired by the time signing starts, and the browser blocks the popup. This happened on a testnet claim flow: the first attempt silently failed to open the wallet.
// ✗ One click: login, then transact. The signing popup gets blocked
claimBtn.onclick = async () => {
const { session } = await ProtonWebSDK({ /* ... */ });
await session.transact({ actions }, { broadcast: true });
};
// ✓ Two clicks: sign in first, then call transact before any other await
signInBtn.onclick = async () => {
({ session } = await ProtonWebSDK({ /* ... */ }));
};
claimBtn.onclick = () => {
const pending = session.transact({ actions }, { broadcast: true }); // first thing in the handler
const hint = setTimeout(() => showHint('No wallet window? Check your popup blocker.'), 4000);
pending.finally(() => clearTimeout(hint));
};Rules that follow from this:
-
Nothing async before
transact()in the click, not even a table read or a "still eligible?" check. Do reads before the click (or on a timer), and let the contract'scheck()reject stale cases. -
A blocked window never settles the promise. In web-sdk 5.1.0 the WebAuth browser link calls
window.open()synchronously insidetransact(). A blocked popup leaves the link'schildWindownull, and the returned promise stays pending forever. You can detect that right after the call:const pending = session.transact({ actions }, { broadcast: true }); if (link && 'childWindow' in link && link.childWindow == null) { pending.catch(() => {}); // it never settles; don't leave it unhandled showHint('The WebAuth window was blocked. Allow popups for this site, then tap again.'); }
linkis the oneProtonWebSDK()returned. Links without achildWindowproperty (Anchor, the WebAuth mobile app) skip the check. -
A window the user closes by hand also leaves the promise pending in 5.1.0. The promise rejects only when webauth.com posts its own close message. If your UI waits on
transact(), add a timeout, and treat the outcome as unknown rather than cancelled: webauth.com broadcasts the transaction itself, so a closed window doesn't prove nothing was signed. Re-read on-chain state before you offer to sign again.
Status: Unresolved - issue is in WebAuth iOS app
Symptom: After logout + login with different account via WebAuth iOS, the UI shows new account but transactions fail with wrong signature.
Workaround:
- Force quit WebAuth iOS app before switching accounts
- Or use webauth.com web wallet (works correctly)
If restoreSession: true doesn't restore the session:
- Check that
storagePrefix(andchainId) match the values used at login. A different prefix looks in differentlocalStoragekeys - Clear localStorage keys starting with
proton-storageor your custom prefix - Ensure you're on the same domain where the session was created
- Check if storage is being blocked (private browsing, etc.)
For SSR frameworks, the SDK must only run client-side:
// Only import client-side
let ProtonWebSDK: any;
if (typeof window !== 'undefined') {
ProtonWebSDK = require('@proton/web-sdk').default;
}
// Check before using
async function login() {
if (!ProtonWebSDK) {
console.error('ProtonWebSDK not available');
return;
}
// ...
}Or use dynamic imports:
async function login() {
const { default: ProtonWebSDK } = await import('@proton/web-sdk');
// ...
}| Type | Description | Use Case |
|---|---|---|
proton |
WebAuth mobile app | Primary mobile wallet |
webauth |
webauth.com browser wallet | Web-based, no app needed |
anchor |
Anchor desktop wallet | Desktop users, power users |
// Show only specific wallets
selectorOptions: {
enabledWalletTypes: ['proton', 'webauth'] // Hide Anchor
}