Skip to main content

A smart wallet is a program deployed on-chain that provides secure, programmable wallet infrastructure. The signer is separate from the wallet and can be anything: passkeys, AWS KMS, MPC, or a multisig threshold. When a PDA owns tokens instead of a keypair, two things change:
Standard walletSmart wallet (PDA)
Create associated token accountcreateAtaInterface(rpc, payer, mint, owner)createAtaInterface(rpc, payer, mint, walletPda, true)
TransfertransferInterface(...) or createTransferInterfaceInstructions(...)createLightTokenTransferInstruction(...)
SigningKeypair signs directlySmart wallet program signs via CPI
transferInterface rejects off-curve addresses. Use createLightTokenTransferInstruction instead. It accepts any PublicKey, including PDAs. Pass allowOwnerOffCurve=true (5th argument) when creating the associated token account. This pattern applies to any PDA-based wallet program. The examples below use Squads Smart Accounts.
The full example includes wallet creation, funding, sync transfer, and async governance flow with an integration test.

Prerequisites

You need an existing smart wallet (PDA) and a ZK Compression-compatible RPC endpoint.
npm install @lightprotocol/compressed-token @lightprotocol/stateless.js @sqds/smart-account
import { createRpc } from "@lightprotocol/stateless.js";
import {
  createAtaInterface,
  getAssociatedTokenAddressInterface,
  createLightTokenTransferInstruction,
} from "@lightprotocol/compressed-token";
import * as smartAccount from "@sqds/smart-account";

// Use a ZK Compression endpoint (Helius, Triton, or self-hosted)
const rpc = createRpc(RPC_ENDPOINT);
If you don’t have a smart wallet yet, create one with Squads. Set timeLock: 0 for sync execution, or a positive value to require async governance.
const programConfig =
  await smartAccount.accounts.ProgramConfig.fromAccountAddress(
    rpc,
    smartAccount.getProgramConfigPda({})[0]
  );
const accountIndex =
  BigInt(programConfig.smartAccountIndex.toString()) + 1n;
const [settingsPda] = smartAccount.getSettingsPda({ accountIndex });

await smartAccount.rpc.createSmartAccount({
  connection: rpc,
  treasury: programConfig.treasury,
  creator: payer,
  settings: settingsPda,
  settingsAuthority: null,
  threshold: 1,
  signers: [
    {
      key: payer.publicKey,
      permissions: smartAccount.types.Permissions.all(),
    },
  ],
  timeLock: 0,
  rentCollector: null,
  sendOptions: { skipPreflight: true },
});
Snippets below assume rpc, payer, mint, settingsPda, and walletPda are defined. See the full example for runnable setup.

Create the wallet’s token account

1

Derive the wallet PDA

The wallet PDA is derived by the smart wallet program. It is off-curve and cannot be used as a keypair.
const [walletPda] = smartAccount.getSmartAccountPda({
  settingsPda,
  accountIndex: 0,
});
2

Create the associated token account with off-curve support

Pass true as the 5th argument to allow the off-curve PDA as the token account owner:
await createAtaInterface(rpc, payer, mint, walletPda, true);
const walletAta = getAssociatedTokenAddressInterface(mint, walletPda, true);

Fund the wallet

Anyone can send Light Tokens to the wallet’s associated token account. No smart wallet approval is needed. You can use transferInterface or createLightTokenTransferInstruction from any standard wallet:
const ix = createLightTokenTransferInstruction(
  payerAta,          // source
  walletAta,         // destination (the smart wallet's associated token account)
  payer.publicKey,   // owner of the source
  amount             // fee payer defaults to owner when omitted
);
The wallet PDA also needs SOL for inner transaction fees. Around 0.01 SOL is sufficient for many transfers:
import { SystemProgram } from "@solana/web3.js";

const fundIx = SystemProgram.transfer({
  fromPubkey: payer.publicKey,
  toPubkey: walletPda,
  lamports: 10_000_000, // 0.01 SOL
});

Send tokens from a smart wallet

1

Build the transfer instruction

Use createLightTokenTransferInstruction to build the inner instruction. The wallet PDA is the owner. The optional 5th argument sets who pays rent top-ups. Pass walletPda so the wallet covers its own top-ups:
const transferIx = createLightTokenTransferInstruction(
  walletAta,       // source: wallet's Light Token associated token account
  recipientAta,    // destination
  walletPda,       // owner: the smart wallet PDA (off-curve)
  amount,
  walletPda        // fee payer for rent top-ups (optional, defaults to owner)
);
2

Execute via the smart wallet

The smart wallet program wraps your instruction and signs via CPI using the wallet PDA’s seeds.Use sync when all signers are available and timeLock=0. The transfer happens in a single transaction. Use async when you need multi-party governance (threshold > 1 or time lock).
import {
  TransactionMessage,
  VersionedTransaction,
} from "@solana/web3.js";

// Compile the inner instruction for synchronous execution
const { instructions, accounts } =
  smartAccount.utils.instructionsToSynchronousTransactionDetails({
    vaultPda: walletPda,
    members: [payer.publicKey],
    transaction_instructions: [transferIx],
  });

const syncIx = smartAccount.instructions.executeTransactionSync({
  settingsPda,
  numSigners: 1,
  accountIndex: 0,
  instructions,
  instruction_accounts: accounts,
});

// Send as a single transaction
const { blockhash } = await rpc.getLatestBlockhash();
const msg = new TransactionMessage({
  payerKey: payer.publicKey,
  recentBlockhash: blockhash,
  instructions: [syncIx],
}).compileToV0Message();

const tx = new VersionedTransaction(msg);
tx.sign([payer]);
const sig = await rpc.sendRawTransaction(tx.serialize(), {
  skipPreflight: true,
});
await rpc.confirmTransaction(sig, "confirmed");

Check balance

Query the wallet’s token balance like any other account:
import { getAtaInterface } from "@lightprotocol/compressed-token/unified";

const account = await getAtaInterface(rpc, walletAta, walletPda, mint);
console.log(account.parsed.amount);

Combine with gasless transactions

You can sponsor the outer transaction fee so that only the wallet PDA’s SOL is used for inner fees (rent top-ups). Set your application as feePayer on the outer transaction and pass it as the first signer:
// Outer transaction: your server pays the Solana network fee
const msg = new TransactionMessage({
  payerKey: sponsor.publicKey,  // sponsor pays outer tx fee
  recentBlockhash: blockhash,
  instructions: [syncIx],
}).compileToV0Message();

const tx = new VersionedTransaction(msg);
tx.sign([sponsor, payer]);  // sponsor + smart wallet signer
See Gasless transactions for the full pattern.

Further reading

Basic payment

Send a single token transfer.

Gasless transactions

Sponsor fees so users never hold SOL.

Integration guide

Full wallet integration reference.

Didn’t find what you were looking for?

Reach out! Telegram | email | Discord