Wallet Infrastructure
Wallet Infrastructure
Wallets

Sending Payments

Balances, quotes, network fees and the four payment statuses.

Payment flow
YouYour serverPOST /v1/quotes
amount
Wallet InfrastructurePrepare paymentuserOpHash
hash
CustomerPasskey approvespasskeyData
signature
Wallet InfrastructureSubmitsignature
send
NetworkRecipientrecipientAddress
webhooks
YouYour servertransaction.*

1. Check the balance

List wallet balances returns every wallet of a customer with its balances.

2. Get a quote

Get fee quote returns the network fee and the maximum amount the wallet can send. Do not send the sender's address: we find the wallet from moduleId and the x-customer-id header, and return the one we priced.

Amounts are in the asset's display unit, for example "12.5" USD₮ or "0.001" BTC, never in base units.

3. Send

Every payment is approved with the customer's passkey. The flow depends on the network.

EVM (evm_erc4337)

  1. Prepare userOp with the chain, token, recipient and amount. It returns a userOpHash.
  2. Have the customer sign the userOpHash with their passkey.
  3. Submit userOp signature. The payment is broadcast once the wallet's signing threshold is met; for most wallets that is one signature.

Example: approving an EVM payment

In the browser, turn the userOpHash into the passkey challenge and ask the customer to approve:

import { startAuthentication } from '@simplewebauthn/browser';

function hashToChallenge(userOpHash: string) {
  const bytes = userOpHash
    .slice(2)
    .match(/../g)!
    .map(h => parseInt(h, 16));
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

const challenge = hashToChallenge(userOpHash);
const assertion = await startAuthentication({
  optionsJSON: {
    challenge,
    rpId: 'your-domain.com',
    allowCredentials: [{ id: credentialId, type: 'public-key' }],
    userVerification: 'required',
  },
});
await fetch('/payments/approve', {
  method: 'POST',
  body: JSON.stringify({ userOpHash, challenge, assertion }),
});

On your server, submit the signature:

await fetch(`https://api.pilot.shiga.io/v1/userops/${userOpHash}/signatures`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SHIGA_API_KEY}`,
    'x-customer-id': customerId,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    passkeyPublicKey: { x: passkey.x, y: passkey.y },
    passkeyData: {
      credential: assertion,
      expectedChallenge: challenge,
      expectedOrigins: ['https://your-domain.com'],
      expectedRPID: 'your-domain.com',
      authenticator: {
        credentialId: passkey.credentialId,
        credentialPublicKey: passkey.credentialPublicKey,
        counter: 0,
      },
    },
  }),
});

passkeyPublicKey is the { x, y } returned when the wallets were provisioned. expectedChallenge must be the base64url encoding of the userOpHash bytes, not the hash as text.

Bitcoin, Spark, Solana, Tron and TON

On these networks the wallet key is held in our signing enclave. The enclave signs only after it verifies the customer's passkey approval of the exact transfer. See How wallets are secured.

  1. Choose a transactionId. Your own integer sequence number for this transfer. Keep it the same if you retry.
  2. Build the challenge from the wallet's entityKey, its moduleId and the transfer details (below).
  3. Have the customer approve with their passkey, using the challenge.
  4. Submit with Submit transaction. We sign and broadcast in one step.

Transfer details (txParams)

FieldValue
chainThe network, for example tron.
amountIn the asset's display unit, for example "12.5" USD₮ or "0.0001" BTC.
recipientAddressThe destination, in the network's own address format.
transactionIdYour integer sequence number from step 1.
networkConfig{}, or { "tokenMint": "..." } on Solana and { "tokenRoot": "..." } on TON to choose the token.
selectedPayment"native". On Solana the platform sets it. It is not part of the challenge.

Building the challenge

The challenge is the base64url SHA-256 of a canonical JSON object: keys sorted at every level, no spaces.

KeyValue
entityKeyThe wallet's entityKey from List wallets.
moduleIdFor example tron.
txParamsYour txParams without selectedPayment, and with networkConfig cut to tokenMint or tokenRoot.
import { createHash } from 'node:crypto';

function canonical(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(canonical);
  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.keys(value)
        .sort()
        .map(key => [key, canonical((value as Record<string, unknown>)[key])]),
    );
  }
  return value;
}

function transferChallenge(
  entityKey: string,
  moduleId: string,
  txParams: Record<string, unknown>,
) {
  const { selectedPayment, networkConfig, ...rest } = txParams;
  const asset = Object.fromEntries(
    Object.entries((networkConfig ?? {}) as Record<string, unknown>).filter(
      ([key]) => ['tokenMint', 'tokenRoot'].includes(key),
    ),
  );
  const intent =
    networkConfig === undefined ? rest : { ...rest, networkConfig: asset };
  return createHash('sha256')
    .update(
      JSON.stringify(canonical({ entityKey, moduleId, txParams: intent })),
    )
    .digest('base64url');
}

Submitting

Send moduleId, txParams and passkeyData: the customer's assertion (credential), the challenge as expectedChallenge, and the passkey's authenticator details. The response returns the transaction with status submitted.

On a treasury wallet that needs more than one approval, each signer submits the same transfer with their own passkey. Until enough have approved, the response carries awaitingApprovals; approvals lapse after 15 minutes.

A payment to the wallet's own address is refused with 400 validation_failed.

Payment statuses and events

StatusEventCredit or settle?
submittedtransaction.submittedNo
pendingtransaction.pendingNo
confirmedtransaction.successYes
failedtransaction.failedNo

transaction.reconciled is the signing side's report of the same confirmation as transaction.success; act on transaction.success. transaction.summary_corrected corrects a past transaction's details and does not change its status.

Only confirmed means paid; see Reliability. Read payments with List transactions, or receive each change as a notification. See Notifications.

Errors

CodeStatusCause
validation_failed400Malformed body, an address that fails its network's checksum, an asset the network does not list, or a payment to the wallet's own address.
insufficient_funds400The wallet cannot cover the amount and fees.
wallet_not_found404No single wallet matched moduleId and x-customer-id. Send x-customer-id.
chain_unavailable409The wallet is not yet deployed on this network; retry once it is.
transfer_reverted409The network would reject the transfer.
passkey_credential_invalid409The passkey assertion did not verify.
paymaster_rejected502The network fee could not be sponsored; retry.
upstream_unavailable502, 503, 504A network service did not answer in time; retry.

On this page