DOCS / QUICKSTART: PROGRAM

Your program. Your signing rules.

Your program defines the rules. PairKey evaluates them before two independent signers produce the signature.

Try the example program

Use the deployment’s example program or enter a program built with the v2 template below. Each wallet controls its own ["controller", owner] authority PDA and policy account. No namespace registration transaction is needed.

Policy setup needs devnet SOL. Off-chain approval has no per-request transaction. On-chain approval also needs transaction fees and approval-account rent for each message. The hosted example selects on-chain approval so its code can remain upgradeable.

Choose a program and connect your wallet.

The policy permits the selected destination and path prefix, with up to 1,232 canonical payload bytes. Updating it replaces the previous scope and allowance. Off-chain mode ignores this counter.

Derived public key

Your key will appear here.

Signature

Your signature will appear here.

Connect your application

Download the browser SDK bundle (keep all three modules together), and provide @solana/web3.js v1. Your application uses our hosted signers; no PairKey processes run locally.

import * as web3 from '@solana/web3.js';
import { PairKey } from './hosted-v2.mjs';

const wallet = window.phantom.solana;
await wallet.connect();
const pairkey = new PairKey({ web3 });
const config = await pairkey.config();
const program = config.example_program; // or your deployed v2 program
const mode = 'Onchain'; // use 'Offchain' for immutable, read-only evaluation
const { identity } = await pairkey.programSource(program, wallet.publicKey, mode);
const namespace = await pairkey.onboard(wallet, { identity });

// Set policy deliberately. Do not repeat this update for every signing request.
const current = await pairkey.programPolicy(program, wallet.publicKey);
await pairkey.setProgramPolicy(wallet, {
  program, owner: wallet.publicKey.toBase58(), revision: (current?.revision ?? 0) + 1,
  enabled: true, chain: 'solana', path_prefix: [0], max_payload_bytes: 1232,
  require_commit: mode === 'Onchain', remaining_approvals: 5
});
const key = await pairkey.key(namespace, 'solana', [0]);
const prepared = await pairkey.prepare(namespace, wallet, {
  chain: 'solana', path: [0], message: 'Approved by my program.'
});
const recovery = await pairkey.recovery(prepared);
sessionStorage.setItem('my-pairkey-job', JSON.stringify(recovery));
// Authenticate the caller independently of RPC simulation.
const submission = await pairkey.approve(namespace, wallet, prepared);
if (mode === 'Onchain') await pairkey.commitProgram(namespace, wallet, prepared);
const { job_id } = await pairkey.submit(submission);
const result = await pairkey.wait(job_id, { prepared, key });

How the program authorizes signing

The example has three instructions. The policy update requires the owner’s transaction signature. Read-only approval authenticates the signer-attested caller and exact request, then returns a decision bound to that input. On-chain approval authenticates the wallet, checks the same rules, decrements an allowance and approves the exact request through gateway CPI in one atomic Solana transaction.

pairkey_policy/v1           // create or edit this owner's rules
pairkey_approve/v1          // evaluate rules; return an input-bound decision
pairkey_approve_commit/v1   // consume allowance + approve through PDA CPI

AppPolicy::allows is where you add application logic. This example checks the designated caller, enabled flag, destination, path prefix and canonical payload length. The complete implementation is included in the downloadable template.

View the application policy implementation
//! Example application rules: designated caller, destination/path scope and an optional
//! persistent approval counter. Developers can replace these rules with their own logic.
use super::*;
use pairkey_types::program_approval::{CommitCall, Input, Output, APPROVE_PREFIX, COMMIT_PREFIX};
use solana_program::{program::set_return_data, rent::Rent, sysvar::Sysvar};
pub const POLICY_PREFIX: &[u8] = b"pairkey_policy/v1\0";
pub const POLICY_SEED: &[u8] = b"pairkey-policy";
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)]
pub struct AppPolicy {
    pub owner: [u8; 32],
    pub enclave_set: [u8; 32],
    pub revision: u64,
    pub enabled: bool,
    pub destination: [u8; 32],
    pub path_prefix: Vec<u32>,
    pub max_payload_bytes: u16,
    pub require_commit: bool,
    pub remaining_approvals: u64,
}
pub fn policy_address(program: &Pubkey, owner: &Pubkey) -> (Pubkey, u8) {
    Pubkey::find_program_address(&[POLICY_SEED, owner.as_ref()], program)
}
fn bad() -> ProgramError {
    ProgramError::InvalidArgument
}
fn policy(program: &Pubkey, info: &AccountInfo) -> Result<AppPolicy, ProgramError> {
    let p: AppPolicy = pair_registry::read(info, program)?;
    if policy_address(program, &Pubkey::new_from_array(p.owner)).0 != *info.key {
        return Err(bad());
    }
    Ok(p)
}
impl AppPolicy {
    fn validate(&self) -> ProgramResult {
        if self.owner == [0; 32]
            || self.revision == 0
            || self.path_prefix.len() > pairkey_types::MAX_PATH
            || self.max_payload_bytes == 0
            || self.max_payload_bytes as usize > pairkey_types::MAX_MESSAGE
        {
            return Err(bad());
        }
        Ok(())
    }
    fn allows(
        &self,
        caller: [u8; 32],
        account: &pairkey_types::AccountPath,
        payload: &[u8],
    ) -> bool {
        self.enabled
            && self.owner == caller
            && account.destination == self.destination
            && account.path.starts_with(&self.path_prefix)
            && !payload.is_empty()
            && payload.len() <= self.max_payload_bytes as usize
    }
}
pub fn process(program: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
    if let Some(bytes) = data.strip_prefix(POLICY_PREFIX) {
        return set_policy(program, accounts, bytes);
    }
    if let Some(bytes) = data.strip_prefix(APPROVE_PREFIX) {
        let input = Input::try_from_slice(bytes).map_err(|_| bad())?;
        if accounts.len() != 3 {
            return Err(bad());
        }
        let p = policy(program, &accounts[2])?;
        pair_registry::program_approval::authenticate_input(
            accounts,
            &input,
            &pair_registry::set_address(&pair_registry::id(), &p.enclave_set).0,
        )?;
        let allowed = input.authority
            == authority(program, &Pubkey::new_from_array(p.owner))
                .0
                .to_bytes()
            && !p.require_commit
            && p.allows(input.caller, &input.account, &input.payload);
        set_return_data(
            &borsh::to_vec(&Output {
                input_digest: input.digest(),
                allowed,
            })
            .map_err(|_| bad())?,
        );
        return Ok(());
    }
    let bytes = data.strip_prefix(COMMIT_PREFIX).ok_or_else(bad)?;
    let call = CommitCall::try_from_slice(bytes).map_err(|_| bad())?;
    let source = call
        .validate(solana_program::clock::Clock::get()?.unix_timestamp)
        .map_err(|_| bad())?;
    if accounts.len() != 8 {
        return Err(bad());
    }
    let [owner, authority_info, policy_info, registry, set, namespace, target, system] = accounts
    else {
        return Err(bad());
    };
    if !owner.is_signer || *registry.key != pair_registry::id() || !registry.executable {
        return Err(ProgramError::MissingRequiredSignature);
    }
    let (pda, bump) = authority(program, owner.key);
    if source.program != program.to_bytes()
        || source.authority != pda.to_bytes()
        || pair_registry::program_approval::validate_source(source)? != pda
        || *authority_info.key != pda
        || source.accounts != vec![policy_info.key.to_bytes()]
    {
        return Err(bad());
    }
    let mut p = policy(program, policy_info)?;
    if p.enclave_set != call.namespace.enclave_set {
        return Err(bad());
    }
    if !p.allows(
        owner.key.to_bytes(),
        &call.request.commit.account,
        &call.payload,
    ) || p.remaining_approvals == 0
    {
        return Err(bad());
    }
    p.remaining_approvals -= 1;
    let bytes = borsh::to_vec(&p).map_err(|_| bad())?;
    policy_info.try_borrow_mut_data()?.copy_from_slice(&bytes);
    let ix = instruction(
        *registry.key,
        vec![
            AccountMeta::new(*owner.key, true),
            AccountMeta::new_readonly(pda, true),
            AccountMeta::new_readonly(*set.key, false),
            AccountMeta::new_readonly(*namespace.key, false),
            AccountMeta::new(*target.key, false),
            AccountMeta::new_readonly(*system.key, false),
        ],
        RegistryInstruction::ApproveProgram {
            approval: call.approval(),
        },
    );
    invoke_signed(
        &ix,
        &[
            owner.clone(),
            authority_info.clone(),
            set.clone(),
            namespace.clone(),
            target.clone(),
            system.clone(),
            registry.clone(),
        ],
        &[&[AUTHORITY_SEED, owner.key.as_ref(), &[bump]]],
    )
}
fn set_policy(program: &Pubkey, accounts: &[AccountInfo], bytes: &[u8]) -> ProgramResult {
    let p = AppPolicy::try_from_slice(bytes).map_err(|_| bad())?;
    p.validate()?;
    let [owner, target, system] = accounts else {
        return Err(bad());
    };
    let (pda, bump) = policy_address(program, owner.key);
    if !owner.is_signer
        || p.owner != owner.key.to_bytes()
        || *target.key != pda
        || *system.key != solana_program::system_program::id()
    {
        return Err(bad());
    }
    let rent = Rent::get()?.minimum_balance(bytes.len());
    if target.owner == program {
        let old = policy(program, target)?;
        if old.owner != p.owner || old.revision.checked_add(1) != Some(p.revision) {
            return Err(bad());
        }
        if target.lamports() < rent {
            solana_program::program::invoke(
                &solana_program::system_instruction::transfer(
                    owner.key,
                    target.key,
                    rent - target.lamports(),
                ),
                &[owner.clone(), target.clone(), system.clone()],
            )?;
        }
        target.realloc(bytes.len(), false)?;
    } else {
        if p.revision != 1
            || target.owner != &solana_program::system_program::id()
            || !target.data_is_empty()
        {
            return Err(bad());
        }
        let seeds: &[&[u8]] = &[POLICY_SEED, owner.key.as_ref(), &[bump]];
        if target.lamports() == 0 {
            invoke_signed(
                &solana_program::system_instruction::create_account(
                    owner.key,
                    target.key,
                    rent,
                    bytes.len() as u64,
                    program,
                ),
                &[owner.clone(), target.clone(), system.clone()],
                &[seeds],
            )?;
        } else {
            if target.lamports() < rent {
                solana_program::program::invoke(
                    &solana_program::system_instruction::transfer(
                        owner.key,
                        target.key,
                        rent - target.lamports(),
                    ),
                    &[owner.clone(), target.clone(), system.clone()],
                )?;
            }
            invoke_signed(
                &solana_program::system_instruction::allocate(target.key, bytes.len() as u64),
                &[target.clone(), system.clone()],
                &[seeds],
            )?;
            invoke_signed(
                &solana_program::system_instruction::assign(target.key, program),
                &[target.clone(), system.clone()],
                &[seeds],
            )?;
        }
    }
    target.try_borrow_mut_data()?.copy_from_slice(bytes);
    Ok(())
}

Deploy your application logic

Download the self-contained program template. It bundles the typed gateway ABI and this exact policy implementation. Edit vendor/programs/example-controller/src/approval.rs. You deploy only your application program; PairKey runs the signers and gateway.

unzip hosted-controller.zip
cd hosted-controller
# Copy registry from your hosted /api/v2/config and pin it at build time.
PAIRKEY_REGISTRY_ID=YOUR_HOSTED_GATEWAY cargo build-sbf -- --locked
solana program deploy --url devnet --keypair YOUR_DEVNET_WALLET.json \
  target/deploy/pairkey_hosted_controller_example.so

Start with on-chain approval while developing an upgradeable program. Off-chain evaluation currently requires immutable deployed code. Removing upgrade authority is irreversible: test and review your program first. Standard RPC cannot simulate code stored only on your server.

Integrate another program

The example helpers use the template’s PDA seeds, accounts and ABI. For another program, supply an explicit Program identity containing its program ID, authority PDA and seeds, application accounts, approval mode and pinned code hash for off-chain evaluation. Implement the typed pairkey_approve ABI or your own instruction that invokes the gateway’s ApproveProgram through invoke_signed. The generic path must validate your program’s own exact operation; the example helper cannot wrap arbitrary programs.

Both signers independently verify the caller proof. Each signer authenticates its own simulation context; an unsigned simulated transaction’s signer flags are not accepted as proof of a wallet signature. See Solana program integration for the ABI and trust model.

Choose which rules need on-chain approval

Use pairkey_approve for read-only rules: allowed destinations and paths, membership checks or current application state. Both signers run the evaluation independently. No transaction is broadcast, account written or counter consumed.

Use pairkey_approve_commit for one-time allowances, cumulative budgets and nonce consumption. The example consumes one allowance and records approval atomically; failing approval rolls back the decrement. It does not parse financial transfers or implement a spending budget. A later signing failure does not refund a committed allowance automatically.

The application policy is shared by this owner’s namespaces under the program. Optional standard PairKey rules are namespace-specific and apply in addition. Policy updates are explicit transactions; reconnect after changing the program or approval mode. See the Policy demo for both code paths and setup steps, or program policy guidance for the full model.

Verify and recover

The Verify button asks the service to verify the displayed message and signature. The browser also checks exact approval context and Ed25519 signatures locally. Use the offline Rust verifier for independent verification of all signature suites, key certificates and both signer receipts; service verification alone does not provide that independence.

// Save this public transcript for the offline Rust verifier.
// Check signer identities against a trusted deployment configuration.
const transcript = { request: prepared.signing_request, approval: prepared.request,
  key, result, identities: namespace.config.signer_identities };
// cargo run -p pairkey-sdk --example verify_hosted -- transcript.json

The client saves the public prepared request, key, deployment and deterministic job ID before submission. Keep that recovery record; it contains no wallet signature, JWT or passkey assertion, but it does contain your message. “Recover last job” only retrieves the original attempt. It never asks for another approval, submits another job or consumes another allowance. If the original submission never reached the service, recovery reports an unknown job. An uncertain outcome is not proof that no signature exists.

How addresses are scoped

The authority scopes the namespace. Destination, signature scheme and numeric path select keys inside it. The default namespace ID is 32 zero bytes; use a different 32-byte ID for another namespace. Keep the deployment and namespace metadata to recover the same keys. Policy edits and program approval-mode changes preserve the authority.

The UI displays a Solana public key, a NEAR ed25519: public-key identifier or an EVM address where applicable. Zcash and Bitcoin display signing public keys. These guides do not construct shielded addresses, named NEAR accounts or destination transactions.