Skip to content

Latest commit

 

History

History
967 lines (790 loc) · 34.2 KB

README.md

File metadata and controls

967 lines (790 loc) · 34.2 KB

swig-developer-sdk

Python SDK for preparing Swig wallet operations on a server, with a separate swig_developer_sdk.signers module for application-owned signing. The signer module inserts signatures with solders and makes no hosted API requests. No signing material is sent to the API.

pip install swig-developer-sdk

This is the Python parity package for @swig-wallet/developer-sdk. Hosted operations are async; handle factories such as wallets.use(...) are synchronous and do no network I/O. The transport is httpx.

How it works

  1. Your server creates a SwigClient with an API key.
  2. Your server prepares a wallet operation and receives one or more unsigned transactions.
  3. Your client signs any transaction whose signature_requests is non-empty.
  4. Your app submits the transactions in order, directly or through Swig's paymaster sponsorship.

The API key stays on the server. Browser code calls a route in your own app — see Proxy handler.

Create a client

Create an API key from the Swig dashboard.

from swig_developer_sdk import SwigClient

async with SwigClient(api_key="sk_...", network="devnet") as swig:
    balance = await swig.paymaster.get_balance()

Reuse one client within an event loop. For a server, create it at startup and await swig.aclose() at shutdown; wallet handles share its connections and cannot outlive it. An injected transport is owned and closed by the client. The examples below assume an open swig client.

Requests authenticate with Authorization: Bearer <api-key>. Override the base URL for non-production deployments:

from swig_developer_sdk import RetryOptions, SwigClient

swig = SwigClient(
    api_key="sk_...",
    base_url="http://localhost:8080",
    network="devnet",
    retry_options=RetryOptions(max_retries=3, retry_delay=1.0),
    timeout=10.0,
)

timeout is a positive, finite HTTP inactivity timeout in seconds. It defaults to 5 seconds; None disables it. It is not a total deadline including retries. Use your application cancellation scope for an overall operation deadline.

Retry behavior

  • GET requests use the configured retry policy.
  • POST requests do not retry by default, because replaying a preparation or submission request can duplicate work.
  • Sponsorship POST requests retry only when idempotency_key is set. A matching retry returns the original paymaster response.
  • Ramp order creation retries because its required request_id makes replay safe. Transfer preparation and submission do not retry automatically.
  • DEX transaction and position lists retry like GETs; they only read.
  • 4xx responses raise SwigDeveloperSdkError immediately.

Create a wallet

created = await swig.wallets.create(
    fee_payer=fee_payer,
    initial_user={"ed25519": {"public_key": user_public_key}},
)

Use secp256r1 for a passkey initial user or secp256k1 for an EVM key. Pass policy_id to create from a portal policy instead of an inline authority. A network is required, either on the call or as the client default.

The result splits the prepared transactions:

Field What to do
wallet swig_config_address, wallet_address, and resolved network
transactions submit in this exact order
client_authority_transactions get a client authority signature first
fee_payer_only_transactions send or sponsor without a client authority signature
creation_transaction the create transaction itself

A prepared transaction needs a client authority signature when signature_requests is non-empty.

Attach to an existing wallet

wallet = swig.wallets.use(
    "SWIG_CONFIG_ADDRESS",
    requester_authority={"ed25519": {"public_key": user_public_key}},
)

swig.wallets.use also accepts a WalletReference, and swig.wallets.from_idp_session(session) builds the same handle from an IdP session.

Configure and use a ParticipantSet

from swig_developer_sdk import ProgramAction, SolLimitAction

created_set = await swig.participant_sets.create(
    swig_config_address=swig_config_address,
    fee_payer=fee_payer,
    threshold=2,
    members=(
        {"ed25519": {"public_key": recovery_public_key}},
        {"secp256r1": {"public_key": client_public_key}},
        {"secp256k1": {"public_key": server_public_key}},
    ),
)

wallet = swig.wallets.use(
    swig_config_address,
    requester_authority=requester_authority,
)
add_role = await wallet.roles.add(
    fee_payer=fee_payer,
    authority={
        "participantSet": {"address": created_set.participant_set_address}
    },
    actions=(
        SolLimitAction(amount=1_000_000),
        ProgramAction(program_id=program_id),
    ),
)

After submitting the setup transactions, prepare an action with a ParticipantSet requester and compile detached approvals:

participant_wallet = swig.wallets.use(
    swig_config_address,
    requester_authority={
        "participantSet": {"address": created_set.participant_set_address}
    },
)
prepared = await participant_wallet.transfer.sol(
    fee_payer=fee_payer,
    destination=destination,
    amount=1_000_000,
)
compiled = await swig.transactions.compile_participant_set_approvals(
    prepared_transaction=prepared,
    approvals=approvals,
)

The plan uses one shared nonce, and each member approval signs its returned challenge. Compilation returns compiled.transaction plus compiled.authorization_expiration_slot; it does not sponsor, submit, or broadcast the result.

Prepare an x402 payment

Pass the resource server's 402 Payment Required response directly to the wallet. When accepted_index is omitted, Swig selects the first eligible exact Solana requirement and returns its original array index.

import httpx

from swig_developer_sdk import create_x402_payment
from swig_developer_sdk.signers import sign_prepared_transaction

async with httpx.AsyncClient() as http:
    challenge = await http.get(resource_url)
    prepared = await wallet.x402.prepare_from_response(challenge)
    signed = await sign_prepared_transaction(
        prepared.prepared_transaction,
        sign_transaction=sign_transaction,
    )
    payment = create_x402_payment(prepared, signed)
    response = await http.get(
        resource_url,
        headers=payment.payment_signature_headers,
    )

To choose a specific offer from the original accepts array:

prepared = await wallet.x402.prepare_from_response(
    challenge,
    accepted_index=accepted_index,
)

Use sign_prepared_transaction for Ed25519 authorities and sign_prepared_swig_transaction for Secp256r1/passkey authorities.

Prepare transfers and swaps

prepared = await wallet.transfer.sol(
    fee_payer=fee_payer,
    destination=destination,
    amount=1_000_000,
)

prepared_token = await wallet.transfer.token(
    fee_payer=fee_payer,
    mint=mint,
    destination_owner=destination_owner,
    amount=10_000,
)

prepared_swap = await wallet.swap.jupiter(
    fee_payer=fee_payer,
    input_mint=input_mint,
    output_mint=output_mint,
    amount=10_000,
    slippage_bps=100,
    destination_account=destination_account,
    wrap_and_unwrap_sol=True,
)

wallet.transfer(...) and wallet.swap(...) are callable like their TypeScript counterparts; spl_token is an alias for token.

Prepare grouped operations

from swig_developer_sdk import TransferSolOperation, TransferTokenOperation

prepared = await wallet.prepare(
    fee_payer=fee_payer,
    operations=(
        TransferSolOperation(destination=destination, amount=1_000_000),
        TransferTokenOperation(
            mint=mint,
            destination_owner=destination_owner,
            amount=10_000,
        ),
    ),
)

Build a custom transaction

from swig_developer_sdk import SolanaAccountMeta, SolanaInstructionInput

prepared = await wallet.build_transaction(
    fee_payer=fee_payer,
    instructions=(
        SolanaInstructionInput(
            program_id=program_id,
            accounts=(SolanaAccountMeta(pubkey=destination, is_writable=True),),
            data=instruction_data,
        ),
    ),
    address_lookup_table_accounts=(lookup_table_address,),
)

The returned transaction is still signed and submitted by your application.

Yield

swig.yields earns on an existing Swig wallet (yield is a Python keyword). Native Solana staking to an allowlisted validator is the first yield method. Prepare calls return unsigned transactions to authorize, sign, and submit.

from swig_developer_sdk import PartialYieldExit, PreparedYieldRoleCreation

options = await swig.yields.list_options(swig_config_address=config)
validator = next(option for option in options if option.accepts_deposits)

role = await swig.yields.prepare_role(
    swig_config_address=config,
    fee_payer=fee_payer,
    yield_authority={"ed25519": {"public_key": agent_key}},
    validator_vote_address=validator.vote_address,
    max_deposit=10_000_000_000,
    role_manager_authority={"ed25519": {"public_key": admin_key}},
)
if isinstance(role, PreparedYieldRoleCreation):
    ...  # sign, submit and confirm role.transaction, then call prepare_role again

deposit = await swig.yields.deposit(
    swig_config_address=config,
    fee_payer=fee_payer,
    requester_authority={"ed25519": {"public_key": agent_key}},
    amount=5_000_000_000,
    validator_vote_address=validator.vote_address,
    position_seed="deposit-1",
)
exit = await swig.yields.exit(
    swig_config_address=config,
    fee_payer=fee_payer,
    requester_authority={"ed25519": {"public_key": agent_key}},
    position_address=deposit.position_address,
    amount="all",  # or PartialYieldExit(lamports=2_000_000_000, position_seed="exit-1")
)
withdrawal = await swig.yields.withdraw(
    swig_config_address=config,
    fee_payer=fee_payer,
    requester_authority={"ed25519": {"public_key": agent_key}},
    position_address=exit.exiting_position_address,
)

page = await swig.yields.list_positions(swig_config_address=config, page_size=50)
summary = await swig.yields.get_summary(swig_config_address=config)
rewards = await swig.yields.list_rewards(swig_config_address=config)
projection = await swig.yields.estimate(
    swig_config_address=config,
    validator_vote_address=validator.vote_address,
    principal=5_000_000_000,
    horizon_days=365,
)

Routes, role permissions, pagination, and errors match the TypeScript SDK; see the TypeScript Yield guide.

Read wallet state

usd = await wallet.get_usd_balance()
# usd.swig_config_address, usd.wallet_address, usd.usd_value

tokens = await wallet.list_token_balances()
# tokens.balances[].mint_address, asset_kind, ui_amount, usd_value

activity = await wallet.list_token_transactions(limit=25)
# activity.transactions[].transaction_signature, direction, asset_kind, ui_amount

Roles

list_roles() returns the on-chain roles attached to the Swig, which is how you inspect who currently holds authority and what each authority may do.

result = await wallet.list_roles()

for role in result.roles:
    print(role.role_id, role.authority_type, role.authority_value)
    for action in role.actions:
        print(action.action_index, action.action_code, action.action_data)

authority_value is the authority's public key material and authority_type is the protocol authority type discriminant. action_data is the raw per-action payload, so read it against the protocol's action definitions rather than assuming a fixed shape.

Authorization locks

An authorization lock works like a card authorization hold on part of the wallet's SOL or token balance, until an expiry slot:

  • While it is active, every other role (including All and the owner) can spend only the balance minus the active locks it does not hold.
  • The holding role can spend its locked funds. Each spend from the locked account captures the hold: the program lowers the holder's lock amount by the net outflow, oldest lock first.
  • Creating a lock charges no spend limit; the holder's normal limits are charged when it spends. A limited holder's SignV2 fails if its remaining active locks would exceed what its limits still allow.
  • A lock is active while it is unexpired and its amount is above zero. A fully captured lock stays stored with amount "0" and active=False.

The creating role can remove its lock at any time, which voids whatever is left of it. Once a lock is inactive (expired or fully captured), a role with All, AllButManageAuthority, ManageAuthority, or ManageAuthorizationLocks can prune it too.

result = await wallet.get_authorization_locks(include_expired=True)
# result.locks[].lock_id, role_id, asset_account, mint, amount, expiry_slot, active
# result.current_slot, result.next_lock_id

The backend reads live chain state. mint is None for native SOL. amount is what remains of the hold after captures. amount, expiry_slot, and current_slot are decimal strings, matching the wire. Inactive locks (expired or fully captured) are returned only with include_expired=True.

prepared = await wallet.add_authorization_lock(
    fee_payer=fee_payer,
    mint=usdc_mint,  # omit for native SOL
    amount=25_000_000,  # token base units, or lamports for SOL
    duration_slots=9_000,  # or expiry_slot, never both
)

released = await wallet.remove_authorization_lock(fee_payer=fee_payer, lock_id=0)

amount must be a positive integer or decimal string. Pass exactly one of expiry_slot or duration_slots. A mint, when given, must be a base58 public key. The requester's role needs ManageAuthorizationLocks, whose max_duration_slots caps the lock's duration. The program enforces every lock rule; the SDK only prepares transactions and reads state.

requester_authority may be:

  • ed25519: a direct Ed25519 role key, or the session key of any session role (Ed25519, Secp256k1, Secp256r1, or ProgramExec session). It signs the transaction like any Ed25519 signer, and signature_requests is empty.
  • secp256k1 or secp256r1: a direct role key. The response carries a signature request, which sign_prepared_swig_transaction fulfills the same way it does for other routes. For secp256k1, the signer receives the 64-character hex of message_hash (for example through personal_sign with create_secp256k1_evm_signing_fn), and its signature goes into the lock instruction's authority payload. The backend already places the EIP-191 prefix after the signature, and the SDK writes the signer's prefix there once, so the bytes stay the same. For secp256r1, the signature goes into the precompile instruction just before the lock.
  • participant_set (optionally with role_id): a ProgramExec role bound to the set. The response carries participant_set_approval_plan; collect member approvals and call swig.transactions.compile_participant_set_approvals, as in Configure and use a ParticipantSet.

program_exec_proof requesters raise ValueError before any request is sent.

from swig_developer_sdk.signers import sign_prepared_swig_transaction

prepared = await wallet.add_authorization_lock(
    fee_payer=fee_payer,
    amount=1_000_000_000,
    expiry_slot=expiry_slot,
    requester_authority={"secp256k1": {"public_key": evm_public_key}},
)
signed = await sign_prepared_swig_transaction(
    prepared,
    secp256k1=evm_signer,  # or secp256r1=passkey_signer
)

Both setters return an unsigned prepared transaction with kind add-authorization-lock or remove-authorization-lock. Fulfill any signature requests or compile ParticipantSet approvals, sign with the fee payer (and the Ed25519 requester, if that is the requester), then submit it. The POSTs are not retried automatically.

Policy metadata is a separate read:

policy = await swig.wallets.get_policy(policy_id)

DEX history and positions

swig.dex reads the DEX activity the backend indexed for a Swig your API key's organization owns. The two lists are API-key POSTs that carry their filters in the body, and get is an API-key GET. All three only read, follow the retry policy, and never call Solana themselves.

cursor = None
while True:
    page = await swig.dex.transactions.list(
        swig_config_address=swig_config_address,
        network="mainnet",
        page_size=25,
        cursor=cursor,
        start_slot=357_000_000,
        end_slot=358_000_000,
        protocol="raydium-clmm",
        action_type="swap",
        swig_vault_address=swig_vault_address,
    )
    for transaction in page.transactions:
        for executed in transaction.actions:
            if executed.action.type == "swap":
                print(executed.protocol, executed.action.input.amount_raw)
    if page.next_cursor is None:
        break
    cursor = page.next_cursor

transaction = await swig.dex.transactions.get(
    swig_config_address=swig_config_address,
    network="mainnet",
    transaction_signature=transaction_signature,
)

positions = await swig.dex.positions.list(
    swig_config_address=swig_config_address,
    network="mainnet",
    position_status="open",
)

Transactions come newest first. Every filter is an optional keyword argument, and filters combine with AND. start_slot and end_slot are inclusive, and protocol, action_type and swig_vault_address must all match the same action. A matching transaction still carries every DEX action the Swig executed in it. page_size defaults to 25 and the backend caps it at 100. next_cursor is the last transaction of the page and is None on the last page; pass it back as cursor with the same filters. Positions page the same way.

A position is the latest known on-chain state of a position held by the Swig's vault or any of its sub-accounts, enabled or not: a DexOpenPosition with its pool and decoded account, or a DexClosedPosition once the account no longer exists. updated_slot is the slot of the read the state came from. Positions take position_status, protocol and swig_vault_address, each applied to the position. Latest state is currently recorded for Raydium CLMM positions only.

amount_raw is a decimal integer string, signed for the action's protocol_actor_address (negative amounts left its accounts). That actor is the Swig vault on a direct call and the router's account on a routed trade, so a routed swap is one action per venue hop. A transaction the index could not interpret, or has not processed yet, is omitted, and get answers 404 for it.

Fiat ramps

swig.ramp covers both directions. Direction is carried by the buy or sell order you pass, so there is no separate on-ramp or off-ramp client. get_options, get_quotes, and create_order require a configuration_id and an environment of "sandbox" or "production". get_order, prepare_transfer, and submit_transfer use the stored order and require neither field.

Amounts are integers in the smallest unit the thing has — minor_units for fiat (cents for USD, whole yen for JPY) and base_units for crypto. They cross the wire as decimal strings, so a value above 2^53 survives; pass an int or str and read a str back. Crypto is a NativeSolAsset() or SplTokenAsset(mint=...).

Client method Route
swig.ramp.get_options GET /wallet/api/ramp/options
swig.ramp.get_quotes POST /wallet/api/ramp/quotes
swig.ramp.create_order POST /wallet/api/ramp/orders
swig.ramp.get_order GET /wallet/api/ramp/orders/{order_id}
swig.ramp.prepare_transfer POST /wallet/api/ramp/orders/{order_id}/transfer/prepare
swig.ramp.submit_transfer POST /wallet/api/ramp/orders/{order_id}/transfer/submit

Options and quotes

Read the options first, so currency, payment-method, and asset values come from the API rather than a hardcoded list. RampAssetOption.decimals and RampFiatCurrencyOption.exponent are the scales you need to build a valid amount.

options = await swig.ramp.get_options(
    configuration_id=configuration_id,
    environment="sandbox",
    direction="buy",
    country_code="US",
)

quotes = await swig.ramp.get_quotes(
    configuration_id=configuration_id,
    environment="sandbox",
    location=RampLocation(country_code="US"),
    order=RampBuyOrderRequest(
        spend=FiatAmountInput(currency_code="USD", minor_units=10_000),
        receive=SplTokenAsset(mint=usdc_mint),
    ),
)

Quotes carry no identifier and must never be cached. Pick one and pass its route to create_order; the route is re-priced when the order is created.

Orders

request_id is your idempotency key and is unique within the configuration. Repeating it returns the stored order; repeating it with different inputs is refused, so mint it once and reuse it across retries.

order = await swig.ramp.create_order(
    request_id=str(uuid.uuid4()),
    configuration_id=configuration_id,
    environment="sandbox",
    context=RampOrderContext(
        customer_id=customer_id,
        swig_config_address=swig_config_address,
        location=RampLocation(country_code="US"),
    ),
    route=quotes[0].route,
    order=RampBuyOrderRequest(
        spend=FiatAmountInput(currency_code="USD", minor_units=10_000),
        receive=SplTokenAsset(mint=usdc_mint),
    ),
)

A buy sends the customer to order.launch_url. Poll swig.ramp.get_order until the status is final; a read of a non-final order also reconciles it against the provider. refunded can follow settled.

A launch_url is a user-specific session URL. Hand it to the customer who owns the order and keep it out of logs and analytics.

Selling

A sell is its own order, so quote the sell direction rather than reusing a buy. Create it on mainnet: a transfer prepared on any other network is refused with ramp transfers settle on mainnet only, and RampOrderContext.network wins over the client's default.

sell_quotes = await swig.ramp.get_quotes(
    configuration_id=configuration_id,
    environment="sandbox",
    location=RampLocation(country_code="US"),
    order=RampSellOrderRequest(
        sell=CryptoAmountInput(
            asset=SplTokenAsset(mint=usdc_mint), base_units=10_000_000
        ),
        receive_fiat_currency_code="USD",
    ),
)

sell_order = await swig.ramp.create_order(
    request_id=str(uuid.uuid4()),
    configuration_id=configuration_id,
    environment="sandbox",
    context=RampOrderContext(
        customer_id=customer_id,
        swig_config_address=swig_config_address,
        network="mainnet",
        location=RampLocation(country_code="US"),
    ),
    route=sell_quotes[0].route,
    order=RampSellOrderRequest(
        sell=CryptoAmountInput(
            asset=SplTokenAsset(mint=usdc_mint), base_units=10_000_000
        ),
        receive_fiat_currency_code="USD",
    ),
)

Send the customer to sell_order.launch_url and let them finish the provider's checkout. The provider assigns the deposit there, so poll get_order until the order reaches awaiting-transfer and carries one; preparing earlier is refused with the deposit address is not ready.

FINISHED = {"settled", "declined", "cancelled", "failed", "refunded"}

current = await swig.ramp.get_order(order_id=sell_order.id)
while isinstance(current, RampSellOrder) and current.deposit is None:
    if current.status in FINISHED:
        raise RuntimeError(f"sell order {current.status} before a deposit")
    await asyncio.sleep(5)
    current = await swig.ramp.get_order(order_id=sell_order.id)

Now move the crypto out of the Swig. Your application owns the signing step.

from swig_developer_sdk.signers import sign_prepared_transaction

prepared = await swig.ramp.prepare_transfer(
    order_id=sell_order.id,
    requester_authority={"ed25519": {"public_key": requester}},
    fee_payer=fee_payer,
)

signed = await sign_prepared_transaction(
    prepared.prepared_transaction,
    sign_transaction=sign_transaction,
)

transfer = await swig.ramp.submit_transfer(
    order_id=sell_order.id,
    transfer_id=prepared.transfer.transfer_id,
    signed_transaction=signed.transaction,
)

submit_transfer never retries on its own, and a response only ever comes back landed. Keep sell_order.id and prepared.transfer.transfer_id so you can resolve the attempt you already made: the transfer is still confirming means it is alive and the same call settles it, while the transfer did not land; prepare another means it never will and the order is free again. Do not prepare a replacement until you have seen the second.

The prepared transaction is handed over once. If you broadcast it and then lose it, call submit_transfer again without signed_transaction to resolve the attempt that is already live.

Sign locally

The generic signer helper works with an application-owned Ed25519 signer. The Swig signer helper patches secp256r1 or secp256k1 signatures into both legacy and versioned Solana transactions.

ParticipantSet signers operate on one bound member request and never call the hosted API:

from swig_developer_sdk.signers import (
    create_participant_ed25519_signer,
    create_participant_passkey_signer,
    create_participant_personal_sign_signer,
    sign_participant_set_approval,
)

recovery_signer = create_participant_ed25519_signer(
    public_key=recovery_public_key,
    sign_message=sign_ed25519,
)
recovery_approval = await sign_participant_set_approval(
    prepared.participant_set_approval_plan.members[0],
    recovery_signer,
)

passkey_signer = create_participant_passkey_signer(
    public_key=client_public_key,
    get_assertion=get_webauthn_assertion,
)
client_approval = await sign_participant_set_approval(
    prepared.participant_set_approval_plan.members[1],
    passkey_signer,
)

server_signer = create_participant_personal_sign_signer(
    public_key=server_public_key,
    # personal_sign applies EIP-191 to this exact 64-character lowercase
    # ASCII hex challenge.
    sign_message=personal_sign,
)
server_approval = await sign_participant_set_approval(
    prepared.participant_set_approval_plan.members[2],
    server_signer,
)

The Ed25519 callback receives the decoded 32-byte challenge. The shared ParticipantSet nonce is already committed by every challenge and is not copied into individual approvals. The passkey adapter returns exact assertion bytes and converts DER to a raw low-S P-256 signature; compilation also defensively normalizes externally constructed high-S P-256 approvals. The personal-sign adapter validates compact k1 signatures, normalizes low-S, and adjusts the recovery byte. None of these helpers calls the hosted API.

from swig_developer_sdk.signers import (
    sign_prepared_swig_transaction,
    sign_prepared_transaction,
)

signed = await sign_prepared_transaction(
    prepared,
    sign_transaction=application_ed25519_signer,
)

signed = await sign_prepared_swig_transaction(
    prepared,
    secp256r1=application_passkey_signer,
)

sign_prepared_swig_transactions(...) signs an ordered sequence, which is what created.client_authority_transactions needs.

WebAuthn and EIP-1193 adapters are callback-based, so applications can connect a browser, hardware, wallet, or remote signer without changing the preparation API:

from swig_developer_sdk.signers import (
    create_secp256k1_evm_signing_fn,
    create_secp256r1_passkey_signing_fn,
)

passkey_signer = create_secp256r1_passkey_signing_fn(get_webauthn_assertion)
evm_signer = create_secp256k1_evm_signing_fn(
    provider=eip1193_provider,
    address=evm_address,
)

Submit and sponsor

submitted = await swig.transactions.sponsor(
    transaction=signed.transaction,
    network="mainnet",
    idempotency_key=idempotency_key,
)
# submitted.request_id, submitted.signature, submitted.spent_by_paymaster

Existing SponsorSignedTransactionArgs and SponsorSignedTransactionBundleArgs objects remain accepted as the sole argument. Do not mix an Args object with keyword parameters.

Pass idempotency_key whenever your application may retry; that is the only case in which the SDK retries a sponsorship POST.

For single-transaction sponsorship, network resolves from the call and then the client default. If neither is set, the paymaster defaults to mainnet.

A returned signature means the Solana RPC accepted the transaction. It may still be pending and is not confirmation or finality; track it through your RPC provider when your product needs either.

Bundle sponsorship

bundle = await swig.transactions.sponsor_bundle(
    transactions=(signed_create.transaction, signed_add_authority.transaction),
    network="mainnet",
    idempotency_key=idempotency_key,
)
# bundle.request_id, bundle.bundle_id, bundle.signatures,
# bundle.estimated_spent_by_paymaster

Constraints enforced before the request is sent:

  • mainnet only — any other network raises ValueError
  • one to five transactions per bundle
  • signatures come back in bundle order
  • estimated_spent_by_paymaster is an estimate, not a settled charge

A returned bundle_id means Jito accepted the bundle. It may still be pending; acceptance is not confirmation or finality.

Paymaster balance

balance = await swig.paymaster.get_balance(network="mainnet")
idp_balance = await swig.paymaster.get_idp_balance(network="mainnet")
# balance.configured, balance.address, balance.balance_lamports, balance.balance_sol

Proxy handler

Python ships a framework-neutral proxy handler instead of one framework adapter. Adapt your framework's request and response objects at the route boundary:

from swig_developer_sdk import SwigProxyConfig, create_swig_proxy_handler

swig_handler = create_swig_proxy_handler(
    SwigProxyConfig(api_key=swig_api_key, network="devnet")
)

response = await swig_handler.handle(
    method="POST",
    path="/transfer/sol",
    body=request_body,
)
# return response.body with response.status

Keep one handler for the application lifetime and await swig_handler.aclose() at shutdown, or use it as an async context manager. Its underlying client is created lazily and reused across requests. Network overrides remain per request.

The handler covers wallet creation, grouped preparation, SOL and SPL transfers, Jupiter swaps, wallet USD balance, token balances, token transactions, roles, x402 payment preparation, paymaster balance, and the ramp routes.

SwigProxyConfig accepts api_key, transaction_api_url, network, fee_payer (a value or a callable resolved per request), resolve_requester_authority, and an optional httpx transport. It falls back to SWIG_DEVELOPER_API_KEY / SWIG_API_KEY, SWIG_TRANSACTION_API_URL, and SWIG_FEE_PAYER when those fields are unset. A fee payer is required for preparation routes other than x402; x402 obtains its sponsor fee payer from the selected payment requirement.

Errors

from swig_developer_sdk import SwigDeveloperSdkError

try:
    await wallet.transfer.sol(
        fee_payer=fee_payer,
        destination=destination,
        amount=1_000_000,
    )
except SwigDeveloperSdkError as error:
    print(error.status_code, error.code, error)

Catch SwigDeveloperSdkError for hosted API and response failures, or use its more specific subclasses:

  • SwigConnectionError: HTTP transport failed; the original exception is in __cause__.
  • SwigTimeoutError: an HTTP timeout expired (also a connection error).
  • SwigResponseError: invalid JSON or an invalid successful response shape. It also inherits ValueError for compatibility with existing validation catches.

Invalid caller inputs remain ValueError or TypeError. Programming errors and cancellation are not converted to connection failures or retried. Transport errors use safe messages; error details include only the API's explicit details field, never a fallback copy of the response body. Treat API messages, details, and chained transport exceptions as potentially sensitive.

Keep API keys, signed transactions, and ramp launch URLs out of logs.

Typed authority inputs

The installed wheel includes py.typed. Authority dictionaries have explicit variants so editors and type checkers can check nested keys:

from swig_developer_sdk import WalletAuthority

authority: WalletAuthority = {"ed25519": {"public_key": user_public_key}}
wallet = swig.wallets.use("swig-config-address", requester_authority=authority)

secp256k1, secp256r1, program_exec_proof, and participant_set variants are also supported. Prefer snake_case keys. Existing camelCase inputs continue to work; ambiguous variants or conflicting aliases raise ValueError before sending an HTTP request. Annotate reused dictionary variables as WalletAuthority (or a specific exported authority type) to preserve their precise type.

SwigServerClient, callable transfer/swap shortcuts, and spl_token remain compatible aliases. Resource namespaces such as wallet.transfer.sol(...) are the canonical examples.

Local end-to-end script

scripts/local_transaction_e2e.py exercises wallet creation, real P-256 signing, direct and grouped SOL transfers, an SPL-token transfer, the Python proxy, the live paymaster balance endpoint, and a sponsored transfer where the user pays no network fee. It also creates a two-member ParticipantSet, adds it as a general role, compiles detached Ed25519 approvals, executes the transfer, and proves that another approval plan using the consumed shared nonce is rejected. Every transaction is submitted to Surfpool and verified on-chain; the paymaster flow also retries with the same idempotency key and verifies that no balance change repeats.

It requires a locally running backend stack, and defaults to http://localhost:8080 for the Developer API and http://localhost:8899 for Surfpool. Override with SWIG_TRANSACTION_API_URL, SOLANA_RPC_URL, or SWIG_DATABASE_URL. Set SWIG_E2E_SKIP_PAYMASTER=1 when validating only the transaction-service and Surfpool path; the receipt explicitly records that the paymaster phase was skipped.

Jupiter is not covered by this script, since it needs a mainnet-backed Surfpool.