This quickstart demonstrates the Portal Wallets Integration - enabling Portal users to connect and utilize wallets from other Story ecosystem projects. This implementation shows how Portal can integrate with external DApps through delegated signers using iframe communication.
High-level Flow:
- User logs in to Portal → gets a non-custodial signer and smart wallet created
- User clicks "Connect to DApp" button → Portal opens popup to DApp's main page
- Portal sends its non-custodial signer as a delegated signer to the DApp via popup
- DApp user logs in and adds the delegated signer to their smart wallet
- DApp sends its wallet address back to Portal
- Portal can now use the DApp's smart wallet using Portal's signer
What you'll learn:
- How to implement popup communication between Portal and DApps
- Delegated signer flow for cross-application wallet usage
- Crossmint wallet integration in a monorepo structure
- Message-based communication between parent and popup windows
This is a pnpm monorepo with two Next.js applications:
-
Portal App (
apps/portal/
) - Runs onhttp://localhost:3000
- Main Portal platform where users log in
- "Connect to DApp" button opens popup window to DApp
- Sends delegated signer via postMessage
- Receives wallet address from DApp
-
DApp App (
apps/dapp/
) - Runs onhttp://localhost:3001
- Represents an external DApp (like Magma)
- Main page detects if it's in popup and adapts UI accordingly
- Receives delegated signers from Portal via postMessage
- Sends wallet address back to Portal after adding delegated signer
- Clone the repository and navigate to the project folder:
git clone https://github.com/Crossmint/evm-wallet-delegation-quickstart.git && cd evm-wallet-delegation-quickstart
- Install all dependencies:
pnpm install
- Get a Crossmint client API key from here and set it in both apps. Make sure your API key has the following scopes:
users.create
,users.read
,wallets.read
,wallets.create
,wallets:transactions.create
,wallets:transactions.sign
,wallets:balance.read
,wallets.fund
.
Create .env.local
files in both app directories:
# apps/portal/.env.local
NEXT_PUBLIC_CROSSMINT_API_KEY=your_api_key
NEXT_PUBLIC_CHAIN=story-testnet
# apps/dapp/.env.local
NEXT_PUBLIC_CROSSMINT_API_KEY=your_api_key
NEXT_PUBLIC_CHAIN=story-testnet
- Run both applications:
Option A: Run both apps simultaneously
# Terminal 1 - Portal
pnpm portal:dev
# Terminal 2 - DApp
pnpm dapp:dev
Option B: Use individual commands
# Portal only
pnpm portal:dev
# DApp only
pnpm dapp:dev
- User logs in to Portal with their wallet
- Portal creates a Crossmint smart wallet
- User clicks "Connect to DApp" button
- Portal opens popup window showing DApp (localhost:3001)
- Portal sends delegated signer to DApp via postMessage
- Portal waits for DApp wallet address response
- Shows "Connected DApp" card when successful
- When accessed directly: shows normal DApp interface
- When accessed in popup: shows "Connect DApp to Portal" interface
- User logs in to create DApp wallet
- DApp receives delegated signer from Portal
- User clicks "Add Delegated Signer" to confirm
- DApp sends its wallet address back to Portal
- Shows success message when connected
- Start both apps
- Open Portal at
http://localhost:3000
- Log in to Portal
- Click "Connect to DApp" button
- In the popup, log in to DApp
- Click "Add Delegated Signer" when prompted
- See success messages on both sides
- Portal now shows "Connected DApp" card with wallet address
The implementation uses native postMessage API for secure cross-origin communication:
Portal (Parent) - manages popup window:
// Open popup window
const popup = window.open(
DAPP_URL,
"dapp-connection",
"width=500,height=600,scrollbars=yes,resizable=yes,top=100,left=100"
);
// Handle incoming messages from popup
const handleMessage = (event: MessageEvent) => {
if (event.origin !== DAPP_ORIGIN) return;
if (isValidReadyMessage(event.data)) {
setIsPopupReady(true);
} else if (isValidPopupMessage(event.data)) {
setConnectedWallet(event.data.wallet);
}
};
// Send delegated signer when popup is ready
useEffect(() => {
if (isPopupReady && popupWindow && signerAddress) {
const message: ParentToPopupMessage = { delegatedSigner: signerAddress };
const interval = setInterval(() => {
popupWindow.postMessage(message, DAPP_ORIGIN);
}, 1000);
return () => clearInterval(interval);
}
}, [isPopupReady, popupWindow, signerAddress]);
DApp (Popup) - communicates with parent:
// Send ready message to parent on mount
useEffect(() => {
const readyMessage: ReadyMessage = { type: "ready" };
window.opener?.postMessage(readyMessage, PORTAL_ORIGIN);
}, []);
// Handle messages from parent window
const handleMessage = (event: MessageEvent) => {
if (event.origin !== PORTAL_ORIGIN) return;
if (isValidParentMessage(event.data)) {
setReceivedSigner(event.data.delegatedSigner);
}
};
// Send wallet address back to parent
const response: PopupToParentMessage = { wallet: walletAddress };
window.opener?.postMessage(response, PORTAL_ORIGIN);
Message Types (Type-Safe with TypeScript):
interface ParentToPopupMessage {
delegatedSigner: string;
}
interface PopupToParentMessage {
wallet: string;
}
interface ReadyMessage {
type: 'ready';
}
DApp detects if it's running in a popup:
const isInPopup = window.opener !== null;
- Both apps are fully responsive
- iframe modal adapts to different screen sizes
- Clean, modern UI following best practices
# Build both apps
pnpm portal:build
pnpm dapp:build
# Or build individually
pnpm portal:build
pnpm dapp:build
- Create a production API key.
- Update the popup URLs in Portal to point to your production DApp URL.
- Update the origin validation in both Portal and DApp for your production domains.
- Deploy both applications to your preferred hosting platform.
apps/portal/app/page.tsx
- Portal home with Connect button and popup window using native postMessageapps/dapp/app/page.tsx
- DApp main page with popup detection and postMessage communicationpnpm-workspace.yaml
- Monorepo configuration with shared dependencies
This implementation uses native postMessage API for secure, type-safe popup communication between Portal and external DApps. The implementation provides:
- Type Safety: TypeScript interfaces with runtime validation for all messages
- Ready Protocol: Secure connection establishment with ready handshake
- Error Handling: Built-in popup lifecycle monitoring and error handling
- Cross-Origin Security: Proper origin validation for all messages
- Event Management: Clean event listener management with automatic cleanup