From 03c108bacf10a56ee0cbf8a7b914f9bd2fefd383 Mon Sep 17 00:00:00 2001 From: paulgoleary Date: Mon, 27 Apr 2026 09:52:27 -0700 Subject: [PATCH] fix(examples/secp-ui): use swig wallet address for transfers and airdrops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Swig v3 the swig PDA holds roles/authorities while a separate wallet PDA holds SOL/tokens and is the address the program invoke_signed's for. The example was using the swig PDA as both, so transfers failed with 'from account must sign' — the program correctly signed for the wallet PDA, but the inner SystemProgram::transfer named the swig PDA as 'from'. Adds a useSwigWalletAddress hook that fetches the swig and resolves to the v3 wallet address via getSwigWalletAddress, and routes airdrops, balance reads, and the EVM + passkey transfer flows through it. The card display still shows the swig PDA (informational — that's the control account). Verified end-to-end against swig-wallet@main (v3.0.0): create passkey -> create swig -> airdrop -> transfer 0.1 SOL succeeds. --- .../secp-ui/src/components/PasskeySwig.tsx | 15 +++++-- examples/secp-ui/src/hooks/index.tsx | 43 +++++++++++++++---- examples/secp-ui/src/hooks/passkey.tsx | 5 ++- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/examples/secp-ui/src/components/PasskeySwig.tsx b/examples/secp-ui/src/components/PasskeySwig.tsx index d2bb0f7d..1c657e80 100644 --- a/examples/secp-ui/src/components/PasskeySwig.tsx +++ b/examples/secp-ui/src/components/PasskeySwig.tsx @@ -25,7 +25,12 @@ import { SystemProgram, Transaction, } from '@solana/web3.js'; -import { AuthorityType, getSignInstructions, Role } from '@swig-wallet/classic'; +import { + AuthorityType, + getSignInstructions, + getSwigWalletAddress, + Role, +} from '@swig-wallet/classic'; import { useState } from 'react'; import { toast } from 'sonner'; import { GoToExplorer } from './GoToExplorer'; @@ -102,13 +107,15 @@ export function PasskeySwig() { let capturedSignatureData: StoredSignatureData | null = null; + const walletAddress = await getSwigWalletAddress(swig); + const ixs = await getSignInstructions( swig, signerRole.id, [ SystemProgram.transfer({ lamports: 0.1 * LAMPORTS_PER_SOL, - fromPubkey: swigAddress!, + fromPubkey: walletAddress, toPubkey: Keypair.generate().publicKey, }), ], @@ -210,13 +217,15 @@ export function PasskeySwig() { (currentRole.authority as any).odometer?.(), ); + const replayWalletAddress = await getSwigWalletAddress(swig); + const ixs = await getSignInstructions( swig, currentRole.id, [ SystemProgram.transfer({ lamports: 0.1 * LAMPORTS_PER_SOL, - fromPubkey: swigAddress!, + fromPubkey: replayWalletAddress, toPubkey: Keypair.generate().publicKey, }), ], diff --git a/examples/secp-ui/src/hooks/index.tsx b/examples/secp-ui/src/hooks/index.tsx index 343beb26..7fa22ce4 100644 --- a/examples/secp-ui/src/hooks/index.tsx +++ b/examples/secp-ui/src/hooks/index.tsx @@ -15,6 +15,7 @@ import { getCreateSwigInstruction, getEvmPersonalSignPrefix, getSignInstructions, + getSwigWalletAddress, } from '@swig-wallet/classic'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -64,6 +65,26 @@ export function useSwig() { return { swig: query.data, ...query }; } +/** + * In Swig v3 the swig PDA holds roles/authorities while a separate + * "wallet" PDA holds the actual SOL/tokens and is the address the program + * `invoke_signed`s for. Use this for airdrops, balance reads, and as the + * `fromPubkey` of any inner transfer instruction. + */ +export function useSwigWalletAddress() { + const { swig } = useSwig(); + const { swigAddress } = getSwigAddress(); + + const query = useQuery({ + queryKey: ['swig', 'walletAddress', swigAddress.toBase58()], + queryFn: () => getSwigWalletAddress(swig!), + enabled: !!swig, + staleTime: Infinity, + }); + + return { walletAddress: query.data, ...query }; +} + export function useWalletPublicKey() { const { signMessageAsync } = useSignMessage(); const { address } = useAccount(); @@ -144,11 +165,12 @@ export function useCreateSwig() { export function useSwigBalance() { const { connection } = useConnection(); - const { swigAddress } = getSwigAddress(); + const { walletAddress } = useSwigWalletAddress(); const query = useQuery({ - queryKey: ['swig', 'balance'], - queryFn: () => connection.getBalance(swigAddress, 'processed'), + queryKey: ['swig', 'balance', walletAddress?.toBase58()], + queryFn: () => connection.getBalance(walletAddress!, 'processed'), + enabled: !!walletAddress, refetchInterval: 3 * 1000, }); @@ -156,17 +178,18 @@ export function useSwigBalance() { } export function useSwigTransfer() { - const { swigAddress } = getSwigAddress(); const { connection } = useConnection(); const queryClient = useQueryClient(); const { address } = useAccount(); const { swig } = useSwig(); + const { walletAddress } = useSwigWalletAddress(); const { signMessageAsync } = useSignMessage(); const mutation = useMutation({ mutationFn: async () => { if (!address) throw new Error('Wallet not connected'); if (!swig) throw new Error('Swig not created'); + if (!walletAddress) throw new Error('Swig wallet address not ready yet'); let signerRoles = swig.findRolesBySecp256k1SignerAddress(address); if (!signerRoles) throw new Error(`No roles found from wallet address ${address}`); @@ -177,7 +200,7 @@ export function useSwigTransfer() { [ SystemProgram.transfer({ lamports: 0.1 * LAMPORTS_PER_SOL, - fromPubkey: swigAddress, + fromPubkey: walletAddress, toPubkey: Keypair.generate().publicKey, }), ], @@ -225,14 +248,18 @@ export function useSwigTransfer() { } export function useRequestAirdrop() { - const { swigAddress } = getSwigAddress(); + const { walletAddress } = useSwigWalletAddress(); const { connection } = useConnection(); const queryClient = useQueryClient(); const {} = useSwigBalance(); const mutation = useMutation({ - mutationFn: async () => - connection.requestAirdrop(swigAddress, LAMPORTS_PER_SOL * 100), + mutationFn: async () => { + if (!walletAddress) { + throw new Error('Swig wallet address not ready yet'); + } + return connection.requestAirdrop(walletAddress, LAMPORTS_PER_SOL * 100); + }, onSuccess: (tx) => { toast.success('Transaction successful!', { action: , diff --git a/examples/secp-ui/src/hooks/passkey.tsx b/examples/secp-ui/src/hooks/passkey.tsx index 76e135ae..87da6b1c 100644 --- a/examples/secp-ui/src/hooks/passkey.tsx +++ b/examples/secp-ui/src/hooks/passkey.tsx @@ -17,6 +17,7 @@ import { fetchSwig, getCreateSwigInstruction, getSignInstructions, + getSwigWalletAddress, } from '@swig-wallet/classic'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -212,13 +213,15 @@ export function useSwigTransferWithPasskey() { ); } + const walletAddress = await getSwigWalletAddress(swig); + const ixs = await getSignInstructions( swig, role.id, [ SystemProgram.transfer({ lamports: 0.1 * LAMPORTS_PER_SOL, - fromPubkey: swigAddress, + fromPubkey: walletAddress, toPubkey: Keypair.generate().publicKey, }), ],