Skip to content

Latest commit

 

History

History
721 lines (575 loc) · 22.2 KB

File metadata and controls

721 lines (575 loc) · 22.2 KB

@proton/web-sdk Frontend Integration

This guide covers wallet connection and transaction signing for web applications using @proton/web-sdk.

Installation

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-sdk 5.1.0 is GA and is the npm latest. The examples in this module still use the 4.x options shape. 5.x changes that shape: appName/appLogo move to uiOptions.appInfo.{name,logo,logoRounded}, customStyleOptions is replaced by uiOptions.theme/themes, and selectorOptions keeps only walletType and enabledWalletTypes. Either major works. Install @proton/web-sdk@5.1.0 @proton/link@5.1.0, or pin both to @4 to use the examples below unchanged. Always keep @proton/link on 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 } }
});

Quick Start

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 });

ProtonWebSDK Options

linkOptions (required)

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

transportOptions (required for mobile)

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 storagePrefix when 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 on localhost (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
});

selectorOptions (optional, 4.x shape)

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

Complete Service Class Example

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();

Common Actions

Token Transfer

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 Contracts

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

Custom Contract Action

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;
}

Multiple Actions in One Transaction

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;
}

Custom Styling

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'
    }
  }
});

Custom Storage

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()
  },
  // ...
});

React Integration

Context Provider

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;
}

Usage in Components

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>;
}

Error Handling

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 no session at all, so check res?.session before you destructure it. @proton/link cancels throw an error with code '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.


Mobile Wallet Support

For mobile wallet signing to work (WebAuth iOS/Android app), you must install and import @proton/link.

Why @proton/link is Required

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.

Installation

npm install @proton/web-sdk@4 @proton/link@4

Import Pattern

import ProtonWebSDK from '@proton/web-sdk';
import '@proton/link'; // Required - enables mobile deep linking transport

Note: 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.

Dynamic Import Pattern (Required for Mobile)

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.

Critical Settings Checklist for Mobile

For mobile wallet to work correctly, ensure ALL of these:

  1. Install both packages: npm install @proton/web-sdk@4 @proton/link@4

  2. 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(() => {});
  3. Set requestAccount to your dApp/contract name:

    transportOptions: {
      requestAccount: 'mycontract',  // Required for mobile callback
    }
  4. Enable wallet types explicitly:

    selectorOptions: {
      appName: 'My dApp',
      enabledWalletTypes: ['webauth', 'proton'],
    }

Symptoms and Causes

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 Popup Blocker

Safari iOS blocks popups by default, which prevents the WebAuth browser wallet from opening. Users on Safari iOS should either:

  1. Disable popup blocker: Settings > Safari > Block Pop-ups OFF
  2. Use the WebAuth mobile app instead of the browser wallet (recommended)
  3. 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.

Recommended Versions

  • @proton/web-sdk@5.1.0 with @proton/link@5.1.0 (current GA, 5.x options shape: see the version note at the top), or
  • @proton/web-sdk@^4.4.1 with @proton/link@^4.4.1 (4.x options shape used by the examples in this module)
  • Keep @proton/link on the same major as @proton/web-sdk. Do not pin 3.2.3-x

Login and transact in separate clicks

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's check() reject stale cases.

  • A blocked window never settles the promise. In web-sdk 5.1.0 the WebAuth browser link calls window.open() synchronously inside transact(). A blocked popup leaves the link's childWindow null, 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.');
    }

    link is the one ProtonWebSDK() returned. Links without a childWindow property (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.


Known Issues

WebAuth iOS App Session Caching

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)

Session Not Restoring

If restoreSession: true doesn't restore the session:

  • Check that storagePrefix (and chainId) match the values used at login. A different prefix looks in different localStorage keys
  • Clear localStorage keys starting with proton-storage or your custom prefix
  • Ensure you're on the same domain where the session was created
  • Check if storage is being blocked (private browsing, etc.)

Next.js Considerations

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');
  // ...
}

Wallet Types

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
}