> ## Documentation Index
> Fetch the complete documentation index at: https://docs.peaq.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Onboard a machine on Solana

> Activate an Economics 2.0 machine homed on Solana: register the owner on peaq once, pay the bond on Solana in PEAQ or USDC, mint the machine, and link it back to peaq through peaq's Trust Validator node. One CLI command, or one Python or JavaScript script.

export const KeyTerms = ({all = {}, ids = [], title = "Key terms in this guide"}) => <details className="not-prose my-4 rounded-xl border border-zinc-200 px-4 py-3 dark:border-zinc-800">
    <summary className="cursor-pointer font-medium text-zinc-900 dark:text-zinc-100">
      {title}
    </summary>
    <div className="mt-3 space-y-2 text-sm text-zinc-700 dark:text-zinc-300">
      {ids.map(id => {
  const t = all[id] || ({});
  return <div key={id}>
            <strong>{t.term}.</strong> {t.def}
          </div>;
})}
    </div>
  </details>;

export const G = {
  onchain: {
    id: "onchain",
    cat: "chain-infra",
    term: "On-chain vs off-chain",
    def: "On-chain means written to the shared public ledger every machine agrees on: permanent and readable by anyone. Off-chain means kept on a normal private server instead."
  },
  blockchain: {
    id: "blockchain",
    cat: "chain-infra",
    term: "Chain / blockchain",
    def: "A shared, tamper-resistant public database maintained by a whole network of computers with no single owner. Different chains are separate such networks."
  },
  peaqChain: {
    id: "peaqChain",
    cat: "chain-infra",
    term: "peaq chain",
    def: "The machine-focused blockchain peaqOS uses as home base for identity and credit records."
  },
  transaction: {
    id: "transaction",
    cat: "chain-infra",
    term: "Transaction (tx) / tx hash",
    def: "A single signed request that changes the ledger. Its hash is a unique, receipt-like ID you can use to look it up later."
  },
  rpcUrl: {
    id: "rpcUrl",
    cat: "chain-infra",
    term: "RPC URL / endpoint",
    def: "The network address your code calls to read from or write to a chain, like the base URL of the chain's API server."
  },
  mainnet: {
    id: "mainnet",
    cat: "chain-infra",
    term: "Mainnet / testnet (agung)",
    def: "Mainnet is the real, live network where tokens have real value. Testnet is a free practice copy with worthless tokens; peaq's is called agung."
  },
  evm: {
    id: "evm",
    cat: "chain-infra",
    term: "EVM / EVM-compatible",
    def: "The Ethereum Virtual Machine: the standard runtime many chains share, so the same 0x... addresses and tools work across all of them. peaq is EVM-compatible."
  },
  node: {
    id: "node",
    cat: "chain-infra",
    term: "Node (RPC node)",
    def: "A server running the blockchain software that holds a copy of the ledger and answers queries. NOT a ROS 2 node, despite the shared word."
  },
  chainId: {
    id: "chainId",
    cat: "chain-infra",
    term: "Chain ID",
    def: "A number that uniquely labels a chain so software doesn't confuse networks (peaq is 3338, Base is 8453)."
  },
  precompile: {
    id: "precompile",
    cat: "chain-infra",
    term: "Precompile",
    def: "A built-in function baked into the chain at a fixed address that acts like a contract but runs as faster native code. The batch one bundles several actions into one all-or-nothing transaction."
  },
  dataHash: {
    id: "dataHash",
    cat: "chain-infra",
    term: "Data hash (keccak256)",
    def: "A short, fixed-length fingerprint of a file, stored on-chain instead of the file itself, so data can be verified later while the raw data stays off-chain."
  },
  depin: {
    id: "depin",
    cat: "chain-infra",
    term: "DePIN",
    def: "Decentralized Physical Infrastructure Network: a network of real-world machines built and run by many independent owners who earn for the work their machines do."
  },
  wallet: {
    id: "wallet",
    cat: "wallet-keys",
    term: "Wallet",
    def: "An account on the chain, identified by a public address, that holds a machine's funds and approves its actions. Really just a pair of keys, not a place money is stored."
  },
  keypair: {
    id: "keypair",
    cat: "wallet-keys",
    term: "Keypair",
    def: "The two matched secrets behind a wallet: a public address you can share, and a private key you keep secret that signs actions."
  },
  privateKey: {
    id: "privateKey",
    cat: "wallet-keys",
    term: "Private key",
    def: "The secret string that proves you control a wallet. Anyone who has it has full control, like a master password that can never be reset."
  },
  sign: {
    id: "sign",
    cat: "wallet-keys",
    term: "Sign / signature",
    def: "Using your private key to produce a cryptographic stamp proving you approved a specific action, without ever revealing the key."
  },
  signer: {
    id: "signer",
    cat: "wallet-keys",
    term: "Signer / signing identity",
    def: "The wallet whose private key authorizes an action: the account the network treats as the one taking it. NOT a file or an app."
  },
  address: {
    id: "address",
    cat: "wallet-keys",
    term: "Address (0x...)",
    def: "The public 0x... identifier of a wallet or contract you can freely share so others can send to it or look it up, like an account number."
  },
  eoa: {
    id: "eoa",
    cat: "wallet-keys",
    term: "EOA (externally owned account)",
    def: "A plain wallet controlled directly by a private key, as opposed to one controlled by code. Here, the account that IS the machine."
  },
  ows: {
    id: "ows",
    cat: "wallet-keys",
    term: "OWS / wallet vault",
    def: "An open standard for storing wallet keys in an encrypted local file (a vault) with a backup phrase, instead of a bare key in a text file."
  },
  passphrase: {
    id: "passphrase",
    cat: "wallet-keys",
    term: "Passphrase (OWS_PASSPHRASE)",
    def: "The password that unlocks the encrypted wallet vault so its key can be used to sign."
  },
  mnemonic: {
    id: "mnemonic",
    cat: "wallet-keys",
    term: "Mnemonic / seed phrase",
    def: "A list of 12 or 24 ordinary words that encodes a wallet's secret key, used to back it up and recover it. Whoever has the words controls the wallet."
  },
  derivation: {
    id: "derivation",
    cat: "wallet-keys",
    term: "Derivation path",
    def: "The deterministic recipe that turns one backup phrase into many specific keys and addresses, one per network or index."
  },
  challenge: {
    id: "challenge",
    cat: "wallet-keys",
    term: "Challenge (sign-to-prove)",
    def: "A login-style handshake: the server sends a random message, you sign it with your key, and the signature proves you control the account without sending the key."
  },
  eip191: {
    id: "eip191",
    cat: "wallet-keys",
    term: "EIP-191 / personal_sign",
    def: "A standard way to sign a plain message to prove you control an account, without sending any on-chain transaction."
  },
  did: {
    id: "did",
    cat: "identity",
    term: "DID / peaqID",
    def: "A globally unique, self-owned ID for a machine that lives on the chain and isn't issued by any single company. peaqID is peaq's version: did:peaq:<machine ID as a decimal> for an Economics 2.0 machine, did:peaq:0x<address> for a Tokenomics 1.0 machine or an operator."
  },
  register: {
    id: "register",
    cat: "identity",
    term: "Register a machine",
    def: "Putting a machine on the network for the first time, which gives it an ID, a DID, an ownership token, and a locked deposit. Under Economics 2.0 this is one activateMachine call signed by the machine's owner on peaq, or the seven-stage onboarding for a machine homed on Solana; registerMachine and registerFor are the older Tokenomics 1.0 calls."
  },
  machineId: {
    id: "machineId",
    cat: "identity",
    term: "Machine ID",
    def: "Your machine's handle in every later call. Under Economics 2.0 it is derived from the machine type and credential subject (a large uint256, written as a decimal string); Tokenomics 1.0 machines got a small sequential number."
  },
  ownerOperator: {
    id: "ownerOperator",
    cat: "identity",
    term: "Owner / operator",
    def: "The owner owns a machine; the operator runs it. They can be the same account (self-managed) or different (proxy-managed)."
  },
  proxyOperator: {
    id: "proxyOperator",
    cat: "identity",
    term: "Proxy operator",
    def: "One account that registers and manages many machines on behalf of their owners, so a fleet operator can handle a whole fleet from one wallet."
  },
  didAttributes: {
    id: "didAttributes",
    cat: "identity",
    term: "DID attributes",
    def: "Public name-value facts (a docs link, a data endpoint) attached to a machine's DID and stored on-chain for anyone to read. An Economics 2.0 machine sets them as service endpoints during activation; a Tokenomics 1.0 machine writes them in a separate transaction after registration."
  },
  pairing: {
    id: "pairing",
    cat: "identity",
    term: "Pairing / pairing token",
    def: "The verified link between an AI agent and a machine, set up by signing a challenge. The pairing token is the signed credential the agent sends with each request, like a temporary access badge."
  },
  hardwareAttestation: {
    id: "hardwareAttestation",
    cat: "identity",
    term: "Hardware attestation",
    def: "A tamper-resistant chip on the machine cryptographically vouching that it's genuine hardware, so its identity can't be faked in software. This is the Verify layer."
  },
  gas: {
    id: "gas",
    cat: "tokens-economics",
    term: "Gas",
    def: "The small fee, paid in the chain's token, that every action writing to the ledger costs, like a per-write transaction cost."
  },
  peaqToken: {
    id: "peaqToken",
    cat: "tokens-economics",
    term: "PEAQ (token)",
    def: "The peaq network's own token, used to pay gas fees and to lock up as the deposit when registering a machine."
  },
  gasStation: {
    id: "gasStation",
    cat: "tokens-economics",
    term: "Gas Station / faucet",
    def: "A peaq service that hands a brand-new, empty wallet a tiny starting amount of tokens so it can afford its first network fees. Gated by 2FA."
  },
  bond: {
    id: "bond",
    cat: "tokens-economics",
    term: "Bond",
    def: "PEAQ locked when a machine activates, proving skin in the game. Under Economics 2.0 it is priced per tier in USD and converted to PEAQ at the oracle rate, and it is not withdrawable; Tokenomics 1.0 machines locked a fixed 1 PEAQ. Bonded means the deposit is in place."
  },
  nft: {
    id: "nft",
    cat: "tokens-economics",
    term: "NFT",
    def: "A unique, one-of-a-kind ownership token recorded on the chain. Unlike a coin, no two are interchangeable."
  },
  mint: {
    id: "mint",
    cat: "tokens-economics",
    term: "Mint / minting",
    def: "Creating a brand-new token on the chain and assigning it to an owner, like stamping a fresh serial-numbered certificate into existence."
  },
  machineNft: {
    id: "machineNft",
    cat: "tokens-economics",
    term: "Machine NFT",
    def: "The unique token representing one specific physical machine. Under Economics 2.0 a peaq-homed machine has one ERC-721 in MachineRegistry and a Solana-homed machine has a native Machine account; transferring it transfers ownership of the machine. Under Tokenomics 1.0 it is a separate financial NFT that can be sold or bridged apart from the machine's identity."
  },
  identityNft: {
    id: "identityNft",
    cat: "tokens-economics",
    term: "Identity NFT",
    def: "Tokenomics 1.0 only: a non-transferable (soulbound) token minted automatically when a machine registered, representing its identity. Its token ID equals the machine ID. An Economics 2.0 machine has no separate identity NFT; its Machine NFT in MachineRegistry carries the identity."
  },
  tokenId: {
    id: "tokenId",
    cat: "tokens-economics",
    term: "Token ID",
    def: "The unique number identifying one specific token within a collection, like a serial number."
  },
  mcr: {
    id: "mcr",
    cat: "tokens-economics",
    term: "Machine Credit Rating (MCR)",
    def: "A creditworthiness score for a machine (a Moody's-style grade AAA down to NR, plus a 0-100 number) computed from its recorded earnings and activity. Like a credit score for a robot."
  },
  mcrApi: {
    id: "mcrApi",
    cat: "tokens-economics",
    term: "MCR API",
    def: "The public web service you call to fetch a machine's credit score and profile as JSON, and the one place a machine's monetization is switched on or off with a signed message. No login needed: reads are open, and the write is authorized by the signature itself."
  },
  provisioned: {
    id: "provisioned",
    cat: "tokens-economics",
    term: "Provisioned / NR (Not Rated)",
    def: "Early MCR statuses. Provisioned means registered and bonded but with too little history to score yet. NR means no grade, because the score is too low or the machine isn't bonded."
  },
  event: {
    id: "event",
    cat: "tokens-economics",
    term: "Event (revenue / activity)",
    def: "A recorded data point about a machine's work, submitted to the chain to feed its credit score. Revenue events report money earned; activity events report work with no money. NOT a ROS topic message."
  },
  trustLevel: {
    id: "trustLevel",
    cat: "tokens-economics",
    term: "Trust level",
    def: "A label on each submitted event saying how strongly its truth is backed: the machine's word (0), a checkable on-chain record (1), or tamper-proof hardware proof (2)."
  },
  escrow: {
    id: "escrow",
    cat: "tokens-economics",
    term: "Escrow",
    def: "Holding a buyer's payment in a neutral locked place until the service is delivered, then releasing it, so neither side has to trust the other first."
  },
  paymentRail: {
    id: "paymentRail",
    cat: "tokens-economics",
    term: "Payment rail",
    def: "The specific method or channel a payment moves through, like choosing card vs bank transfer vs a particular token."
  },
  x402: {
    id: "x402",
    cat: "tokens-economics",
    term: "x402",
    def: "A web payment standard where a server answers 'payment required' with exact instructions, and the buyer's wallet signs an authorization instead of sending a separate transaction. Built for machines and agents paying per request."
  },
  usdt: {
    id: "usdt",
    cat: "tokens-economics",
    term: "USDT",
    def: "A stablecoin token meant to hold a value of one US dollar, used to pay service providers without price swings."
  },
  fractionalize: {
    id: "fractionalize",
    cat: "tokens-economics",
    term: "Fractionalize (ERC-3643)",
    def: "Splitting ownership of one machine into many small tradable shares so multiple people can each own a piece. ERC-3643 is the regulated-securities token standard used to do it."
  },
  smartContract: {
    id: "smartContract",
    cat: "smart-contracts",
    term: "Smart contract / contract address",
    def: "A program deployed on the chain that runs exactly as written and that anyone can call, identified by its own 0x... address."
  },
  registryContracts: {
    id: "registryContracts",
    cat: "smart-contracts",
    term: "Registry contracts",
    def: "On-chain programs that each keep an official, lookup-able list. Under Economics 2.0, MachineRegistry tracks which machines exist, EventRegistry stores their events and MachineSubscription holds their bonds; under Tokenomics 1.0 these were IdentityRegistry, EventRegistry and IdentityStaking."
  },
  smartAccount: {
    id: "smartAccount",
    cat: "smart-contracts",
    term: "Smart account (ERC-4337)",
    def: "A programmable wallet controlled by code instead of a single key, so it can enforce rules like spending limits. A machine gets one through a separate deploySmartAccount call after activation."
  },
  submitEvent: {
    id: "submitEvent",
    cat: "smart-contracts",
    term: "submitEvent / batchSubmitEvents",
    def: "The calls that record one or many of a machine's revenue or activity entries onto the chain."
  },
  revert: {
    id: "revert",
    cat: "smart-contracts",
    term: "Revert",
    def: "When an on-chain call is rejected and fully undone because a rule was broken, leaving no changes and usually a named error."
  },
  soulbound: {
    id: "soulbound",
    cat: "smart-contracts",
    term: "Soulbound",
    def: "A token that can never be transferred or sold and stays permanently attached to one owner. The Tokenomics 1.0 Identity NFT is soulbound."
  },
  bridge: {
    id: "bridge",
    cat: "cross-chain",
    term: "Bridge / bridging",
    def: "Moving a token from one chain to another. Tokenomics 1.0 Machine NFTs can be bridged from peaq to Base through the SDKs (mainnet only); Economics 2.0 machines cannot be bridged or relocated yet."
  },
  base: {
    id: "base",
    cat: "cross-chain",
    term: "Base",
    def: "Another blockchain network (built by Coinbase). Tokenomics 1.0 Machine NFTs can be bridged to and from it. Paying fees on Base needs Base ETH."
  },
  omniChain: {
    id: "omniChain",
    cat: "cross-chain",
    term: "Omni-chain / cross-chain",
    def: "Working across many separate chains at once, so a machine's identity and credit created on peaq can be read or used on other chains."
  },
  homeChain: {
    id: "homeChain",
    cat: "cross-chain",
    term: "Home chain",
    def: "The chain where a record's canonical, authoritative copy lives. For a peaqOS machine that is peaq, or Solana for an Economics 2.0 machine created there; other chains hold mirrors. The subscription is booked on peaq either way; a Solana-homed machine pays its bond on Solana."
  },
  satelliteChain: {
    id: "satelliteChain",
    cat: "cross-chain",
    term: "Satellite chain",
    def: "A chain carrying a read-only, automatically synced mirror of home-chain records, so apps there can use them without crossing back to the home chain."
  },
  sourceChainId: {
    id: "sourceChainId",
    cat: "cross-chain",
    term: "sourceChainId / sourceTxHash",
    def: "Two fields recording which chain an action happened on and its hash there, so a cross-chain event can be traced back and verified."
  },
  machineAgent: {
    id: "machineAgent",
    cat: "general-web3",
    term: "Machine Agent",
    def: "A third-party AI program (Claude, OpenAI, a custom bot) paired to a machine and given limited permission to find, buy, and pay for services on its behalf."
  },
  delegationPolicy: {
    id: "delegationPolicy",
    cat: "general-web3",
    term: "Delegation policy",
    def: "The rules an owner gives an AI agent that cap how much it can spend per transaction and per day and which services it may use, so it transacts within guardrails."
  },
  machineMarkets: {
    id: "machineMarkets",
    cat: "general-web3",
    term: "Machine Markets / Service Registry",
    def: "peaqOS's marketplace where machines list services they offer (Service Registry) and where agents discover, order, pay for, and run services from others."
  },
  sdk: {
    id: "sdk",
    cat: "general-web3",
    term: "SDK (peaq-os-sdk)",
    def: "peaq's code library (Python and JS) you install to call all this functionality without writing low-level blockchain calls yourself."
  },
  stream: {
    id: "stream",
    cat: "data-stream",
    term: "Stream (Data-as-a-Service)",
    def: "The peaqOS function where a machine sells the data it generates: it signs the data, encrypts what's sensitive, and grants buyers access. Selling data, as opposed to selling capacity (that's Monetize)."
  },
  edgeAgent: {
    id: "edgeAgent",
    cat: "data-stream",
    term: "peaqOS Edge Agent",
    def: "Software that runs on the machine itself (as a ROS 2 node) and signs, encrypts, and ships the data it produces. The on-machine half of Stream."
  },
  dataPackage: {
    id: "dataPackage",
    cat: "data-stream",
    term: "Signed data package",
    def: "A bundle of machine data stamped with the machine's identity (DID, timestamp, sequence number) and a signature, so anyone can prove which machine produced it and that it wasn't altered."
  },
  dataEventMap: {
    id: "dataEventMap",
    cat: "data-stream",
    term: "Data Event Map",
    def: "The policy file a machine owner writes to control what streams out: which topics to read, which fields to keep, drop, or encrypt, and where the signed data goes."
  },
  chunk: {
    id: "chunk",
    cat: "data-stream",
    term: "Chunk",
    def: "A bounded, individually encrypted slice of a data stream (by time window or size). The unit a buyer actually purchases and decrypts."
  },
  chunkChain: {
    id: "chunkChain",
    cat: "data-stream",
    term: "Chunk chain",
    def: "A run of chunks linked in order, each referencing the one before it, so missing, reordered, or edited chunks are detectable. Tamper-evidence for a continuous stream."
  },
  manifest: {
    id: "manifest",
    cat: "data-stream",
    term: "Manifest",
    def: "A record describing a chunk: its hashes, storage location, and encryption details, without the data itself. The machine signs the chunk's encrypted data hash; buyers check the signature and hashes before trusting or buying."
  },
  envelopeEncryption: {
    id: "envelopeEncryption",
    cat: "data-stream",
    term: "Envelope encryption / key wrapping",
    def: "Encrypt the data once with a random key, then lock that key separately for each authorized reader. Granting a buyer access re-locks the key to their public key; the data itself is never re-encrypted."
  },
  accessGrant: {
    id: "accessGrant",
    cat: "data-stream",
    term: "Access grant",
    def: "What a buyer receives after paying: the chunk keys they bought, each locked to their public key. They unlock with their private key and decrypt only those chunks."
  },
  contextProvider: {
    id: "contextProvider",
    cat: "data-stream",
    term: "Context Provider",
    def: "A third party that buys machine data, normalizes it into datasets, and serves or resells it (for example, for AI training). The buyer side of Stream, such as DataHive."
  },
  computeProvider: {
    id: "computeProvider",
    cat: "monetize",
    term: "Compute provider",
    def: "A machine that rents out its processing power to a compute network. Instead of sitting idle, the machine runs other people's workloads and earns for it."
  },
  provisioningManifest: {
    id: "provisioningManifest",
    cat: "monetize",
    term: "Provisioning manifest",
    def: "A published, step-by-step install plan that turns a machine into a compute provider: the checks to run first, the commands to execute, and the probes that prove it worked. The machine pulls it on demand and runs it locally."
  },
  heartbeat: {
    id: "heartbeat",
    cat: "monetize",
    term: "Heartbeat",
    def: "A short signed 'I'm here' message a machine sends out at a regular interval. As long as heartbeats keep arriving, the machine counts as online; if they stop, it's marked offline automatically."
  },
  aggregator: {
    id: "aggregator",
    cat: "monetize",
    term: "Aggregator",
    def: "A network that pools capacity from many machines and sells it on, such as a decentralized compute marketplace. Machines connect to an aggregator, which then dispatches work to them."
  },
  machineWallet: {
    id: "machineWallet",
    cat: "monetize",
    term: "Machine wallet",
    def: "The wallet that belongs to the machine itself, separate from its owner's or operator's wallet. Earnings from the machine's work land here by default."
  },
  walrus: {
    id: "walrus",
    cat: "chain-infra",
    term: "Walrus",
    def: "A decentralized storage network where encrypted data chunks can be parked, referenced by walrus:// links. The data stays off the blockchain; only its reference and fingerprint are tracked on-chain."
  },
  solana: {
    id: "solana",
    cat: "cross-chain",
    term: "Solana",
    def: "A high-throughput blockchain. peaqOS wallets hold a Solana account and sign Solana transactions, machine-economy payments can settle there, and an Economics 2.0 machine can be homed there (identity, DID and NFT as Solana accounts, the bond paid on Solana and booked on peaq)."
  }
};

A machine can live on <Tooltip tip={G.solana.def}>Solana</Tooltip> instead of peaq. Its identity record, DID document and Machine NFT are then Solana accounts that name a Solana key as owner. The subscription <Tooltip tip={G.bond.def}>bond</Tooltip> is paid on Solana, in PEAQ or USDC, and booked on peaq, so the machine still has one subscription and one credit rating on peaq.

Onboarding runs **terminal-first**, in seven stages and a fixed order:

| Stage | Who | What happens |
| :- | :- | :- |
| `[1/7]` Operator registration on peaq | peaq operator wallet | Binds the Solana owner wallet to the operator wallet on peaq. Once per owner wallet: later machines of the same owner wallet reconcile the binding and send nothing |
| `[2/7]` Activation request on Solana | Solana owner wallet | The escrow (PEAQ or USDC) moves into the request; the bond is sized and frozen |
| `[3/7]` Credit from peaq | peaq's Trust Validator node (the node) | The node commits the request's credit on Solana. This stage is a wait; nothing is signed |
| `[4/7]` Settlement on Solana | Solana owner wallet | Settles the request: PEAQ moves to the terminal's vault, or USDC is swapped for exactly the bond. **The point of no return** |
| `[5/7]` Bond on peaq | the node and peaq | peaq books the bond and the node commits the status back to Solana. This stage is a wait |
| `[6/7]` Native onboarding on Solana | Solana owner wallet | Mints the machine and its DID against the bonded request |
| `[7/7]` Link push Solana → peaq | Solana owner wallet, then the node | Tells peaq the machine lives on Solana. The Trust Validator node delivers it on peaq; there is no messaging fee |

The CLI runs all seven stages from one command and waits out the external legs. You can also run one stage per invocation. The Python and JavaScript SDKs expose the same phases, and the scripts in [the same flow in the SDKs](#the-same-flow-in-the-sdks) run them from one invocation.

<KeyTerms all={G} ids={["solana", "homeChain", "bond", "ows", "wallet", "did", "machineId", "machineNft", "rpcUrl", "mainnet"]} />

## What you need

| Item | Notes |
| :- | :- |
| The CLI or an SDK with Solana support | CLI 0.0.19 or newer (0.0.17 and 0.0.18 onboard too, but cannot send the machine's events (0.0.17) or manage it (0.0.18) afterwards): `pip install -U 'peaq-os-cli[solana,ows]'`. Python SDK 0.11.2 or newer: `pip install -U "peaq-os-sdk[solana,ows]"`, Python 3.10 or newer. JavaScript SDK 0.11.2 or newer: `npm install @peaqos/peaq-os-sdk viem @solana/web3.js @coral-xyz/anchor`, Node.js 22 or newer. Older releases implement the removed reservation flow and fail against the current programs. |
| A peaq **operator** wallet | Signs `[1/7]` only: the registration on peaq, about 0.01 PEAQ of gas, once per Solana owner wallet. It becomes the operator that owns the machine's points and revenue on peaq. It pays no bond. |
| A Solana **owner** wallet | Signs every Solana write and **pays the bond**, from its own token account: PEAQ on Solana (the OFT, mint `PEAQjk7SRS6rXHVFFmpRr7zrC4g5ZuEebpwTxvaLr3b`, 9 decimals) or USDC (mint `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, 6 decimals). Also pays rent and fees in SOL. |
| Two RPC endpoints | One for peaq, one for Solana. Use your own provider endpoint for peaq: an onboarding makes bursts of reads, and the free public peaq endpoints rate-limit them (`RPC_RATE_LIMITED`). For Solana, the public `api.mainnet-beta.solana.com` carried every proven run, the 2026-10-08 run included: the SDKs wait out its rate limits (HTTP 429) and its lagging servers (`-32016`) on reads. Since CLI 0.0.17 / SDK 0.11.2 they also re-send a write whose first send was lost, as the same signed bytes, while it can still land. A provider endpoint avoids the occasional rerun. |
| A DID document | Same three-field JSON as on peaq. Verification-method controllers are base58 Solana keys. |

Getting PEAQ or USDC onto Solana is outside the CLI: bridge PEAQ through the OFT, or buy either token on Solana. Onboarding runs on mainnet only. There is no testnet, so the preview is the only rehearsal. A settled bond is not refunded unless peaq refuses it (see [the point of no return](#the-point-of-no-return)).

## What it costs

Two parts, priced differently. The **SOL** you pay is rent and network fees. The chain fixes them by the size of the machine's record (the DID document, the machine type and the credential subject), so they are the same for every machine whose inputs have the same sizes. The **bond** is priced in USD and paid in PEAQ or USDC at the oracle's price, so it moves with the price. The preview (`--dry-run`) quotes your bond, with the escrow to set, before anything is signed.

| Who pays | What | Measured on production |
| :- | :- | :- |
| Operator wallet, PEAQ on peaq | Registration gas, first machine of an owner wallet only | about 0.01 PEAQ (98,688 and 105,432 gas measured) |
| Owner wallet, SOL | Rents and fees for the whole onboarding, at the default priority price | **8,117,350 lamports** paying in PEAQ, measured on 2026-10-08 with the mint's compute limit sized from its simulation (8,122,140 on 2026-10-07, when the mint asked for 600,000 CU); about **8,120,350** paying in USDC (computed from the 8,125,140 measured on 2026-10-07 at 600,000 CU), with the DID document below. The `Machine` record's rent grows with the document: the 2026-10-06 runs' smaller one cost 8,045,940 and 8,048,940 at 600,000 CU |
| Owner wallet, PEAQ on Solana | The Basic bond (USD 0.20) at the oracle's price | Quoted at run time. On 2026-10-06 it was 4,744,535,245 base units (4.744535245 PEAQ) at 0.04215376 USD per PEAQ; the escrow was the bond plus a 1% cushion, and the cushion came back at `[4/7]` |
| Owner wallet, USDC on Solana | The same bond, bought through the provision pool's swap | Quoted at run time. On 2026-10-06, 200,807 base units (0.200807 USDC), the pool's 1% fee included, exactly what the pre-signing simulation said; the rest of the escrow came back in USDC |
| peaq (nothing from you) | The node's credit, status and link deliveries, peaq's bond booking | paid by the node: about 0.17 PEAQ of gas on peaq and 10,000 lamports on Solana per machine, plus about 0.00105 SOL for the binding on an owner wallet's first machine |

The SOL figure breaks down as follows. The request pays three rents on a machine's first request: the machine's terminal record (1,071,880 lamports), the request (2,169,160) and its escrow account (1,488,440), plus a 6,500-lamport fee. The settlement returns the escrow account's rent less its fee (+1,482,440 on PEAQ; +1,479,440 on USDC, whose swap costs more compute). The mint pays the `Machine` record (2,966,720 for the template below; it grows with the document, and was 2,890,520 for the 2026-10-06 runs' smaller one), the `MachineState` record (878,840) and a 6,210-lamport fee (121,000 CU at the default price; 11,000 before 0.0.17, when the mint asked for 600,000). The link push pays the push account's one-time rent (1,005,840) and a 6,200-lamport fee. The request account's 2,169,160 stays locked: it only returns when the request is closed, which the CLI does not do.

Every Solana write pays a priority fee of 10,000 micro-lamports per compute unit by default: about 4,900 lamports across a PEAQ-rail onboarding, about 7,900 on USDC. These totals assume the mint's compute limit sized from its simulation (about 121,000 CU, CLI 0.0.17); the mint's fee fell from 11,000 to 6,210 lamports with it. A write sent without priority is more likely to be dropped by Solana's public endpoint under load. Since CLI 0.0.17 / SDK 0.11.2, a write whose first send is lost is re-sent as the same signed bytes while it can still land: same signature, so it cannot land twice. A write that still never lands is replaced: a request or settlement by a fresh one, and a mint is minted again on the next run (CLI 0.0.16 / SDK 0.11.1, see [Resume and troubleshoot](#resume-and-troubleshoot)). A dedicated Solana RPC endpoint makes drops rarer than the public one.

Fund the owner with at least **0.015 SOL per machine** and the bond in the pay-in token plus the cushion the preview shows, and the operator with 0.05 PEAQ on peaq. Bonds are not withdrawable.

## Configure

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
mkdir -p solana-onboarding && cd solana-onboarding   # one directory per machine, keep it
peaqos init                                          # mainnet, deployment peaq-mainnet, your own peaq RPC endpoint
printf '%s\n' 'PEAQOS_SVM_NETWORK=mainnet-beta' 'PEAQOS_SVM_RPC_URL=<your Solana RPC endpoint>' >> .env
```

`peaqos init` writes `.env` with `PEAQOS_NETWORK`, `TOKENOMICS_DEPLOYMENT_ID`, `PEAQOS_RPC_URL` and the six contract address variables that every client needs, keyless previews included. The two Solana lines are not part of the wizard. The SDK scripts read the same `.env`. Keep endpoint URLs in `.env`, not in exported variables or command arguments. An exported variable wins over `.env`, and a stale exported `PEAQOS_RPC_URL` is a common cause of a failed read. The CLI warns once per variable when an exported value differs from `.env`, naming both hosts. `--svm-rpc-url` and `--svm-network` before the command name override both.

Keep the journal (`peaqos.log` for the CLI) and the original inputs in this directory for every later run. The journal is what lets a rerun reconcile instead of resubmitting. Use one directory per machine. Each CLI run also writes `onboarding-<machine-id>.json` here: a record of every invocation for you to read (a rerun never reads it).

Create the two wallets in the <Tooltip tip={G.ows.def}>OWS</Tooltip> vault and note their public addresses. Fund them yourself; wallet creation does not.

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
peaqos wallet create svm-operator     # peaq address: registration gas only
peaqos wallet create svm-owner        # Solana address: the bond in PEAQ or USDC, rent and fees in SOL
peaqos wallet show svm-operator --json
peaqos wallet show svm-owner --json
unset PEAQOS_PRIVATE_KEY              # let the selected OWS wallet sign
```

Each wallet has its own passphrase. The one-command run asks for each one once, at the first stage that needs it, and names the wallet. To run unattended, give both wallets the same passphrase and export it as `OWS_PASSPHRASE` from your secret store.

## The DID document

Save as `did.json`, the same template as the [quickstart's](/peaqos/guides/onboarding-quickstart#part-1-set-up-once-per-fleet). `controller` is the owner wallet's base58 address and `publicKeyMultibase` its key in multibase form (the quickstart derives it). `authentication` entries are zero-based indices into `verificationMethods`. The optional machine-level controller is a separate flag (`--solana-controller`), not a field here.

```json theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
{
  "verificationMethods": [
    {
      "id": "#key-1",
      "methodType": "Ed25519VerificationKey2020",
      "controller": "<owner wallet address>",
      "publicKeyMultibase": "z6Mk<public key in multibase>"
    }
  ],
  "authentication": [0],
  "serviceEndpoints": [
    { "id": "#data", "serviceType": "peaq-data-api", "serviceEndpoint": "https://api.example.com/machines" },
    { "id": "#docs", "serviceType": "peaq-documentation", "serviceEndpoint": "https://docs.example.com" }
  ]
}
```

Replace the two example URLs with the machine's own before you onboard it: the service endpoints are written on chain and shown on the machine's public MCR profile (`GET /machine/did:peaq:<id>`), and changing them later is a separate write.

Limits come from the Solana program: 8 verification methods, 8 authentication indices, 8 service endpoints, 128 UTF-8 bytes per string field, 64 bytes for the machine type, 512 bytes of credential subject. Oversized input is rejected before anything is signed; nothing is truncated.

## The argument set

Every invocation of one onboarding takes the **same arguments**. Set the shell variables first. The `:?` guards make the shell stop on an unset variable instead of passing an empty argument. The [quickstart](/peaqos/guides/onboarding-quickstart#part-2-activate-each-machine) shows how to derive `CREDENTIAL_SUBJECT_HEX` from a serial number and `publicKeyMultibase` from the owner address. The blocks on this page carry `#` comments: run them in bash, or run `setopt interactive_comments` first in an interactive zsh.

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
ARGS=(
  --chain solana
  --solana-owner "${OWNER:?}"                       # base58, the Solana owner wallet
  --manufacturer "${MANUFACTURER:?}"                # base58, recorded without verification
  --machine-type "${MACHINE_TYPE:?}"                # with the credential subject it fixes the machine ID for good
  --credential-subject-hex "${CREDENTIAL_SUBJECT_HEX:?}"
  --tier basic --did-document ./did.json
  --pay-in PEAQ                                     # or USDC: the token the owner pays the bond in
  --max-native-fee-lamports 50000                   # network + priority fee of each Solana write, excludes rent
  --max-native-rent-lamports 6000000                # rent of each Solana write
  --timeout-seconds 300
)
```

The proven runs used these ceilings. The escrow, `--max-in`, is added after the preview, because the preview suggests it: `ARGS+=(--max-in "$MAX_IN")`, in base units of the pay-in token (9 decimals for PEAQ, 6 for USDC).

| Flag | Meaning |
| :- | :- |
| `--chain solana` | Home the machine on Solana. Without it `peaqos activate` is the one-transaction peaq flow. |
| `--operator-wallet`, `--owner-wallet` | The two OWS wallet names. The operator wallet signs `[1/7]` only, the owner wallet every Solana write. Both default to `PEAQOS_OWS_WALLET`, which can name only one of them, so the one-command run needs both flags. |
| `--pay-in` | `PEAQ` or `USDC`: the token the owner pays the bond in. On USDC the settlement swaps exactly the bond through the provision pool; the remainder returns in USDC. |
| `--max-in` | The escrow, in base units of the pay-in token. On PEAQ it must cover the bond (`ESCROW_BELOW_BOND` otherwise); on USDC the swap's simulated cost must fit. Take it from the preview's `Run it with --max-in …` line. What the bond does not use comes back at `[4/7]`. |
| `--tier` | `entry`, `basic` or `pro` (program tiers 0, 1 and 2), priced in USD as on peaq. The quote refuses a tier the deployment does not price. The proven runs used `basic`. |
| `--slippage-bps` | Default `100`. The cushion on the suggested escrow: on USDC it covers the swap, on PEAQ the price stamp moving between the quote and the request. |
| `--max-native-fee-lamports`, `--max-native-rent-lamports` | Required. Ceilings for each Solana write; `0` is a strict cap, never "unlimited". At the default priority price the largest fee is the request's, 6,500 lamports. The mint's was 11,000 before 0.0.17, when it asked for 600,000 CU; it is 6,210 now that the CLI sizes the mint's limit from its simulation. |
| `--compute-unit-price-micro-lamports` | Optional, default `10000`: the priority price of every Solana write. `0` sends without priority, which the public endpoint can drop under load. |
| `--max-link-push-fee-lamports` | Optional. Needed only when the deployment routes the link push over LayerZero (see [a link push over LayerZero](#a-link-push-over-layerzero)); the node's route has no fee and ignores it. Refused with any `--phase` other than `linkage`. |
| `--wait-minutes` | `0` to `60`, default `0`. One budget for the run's waits; it signs nothing while waiting. The credit wait also stops after 3 minutes and the bond wait after 6, whatever is left of the budget. A wait that ran out ends the run with `PENDING`, and a rerun keeps waiting. `60` lets a first run go from start to `complete` when each leg lands inside its limit (the proven runs took about 10 minutes). Not allowed with `--dry-run`. |
| `--timeout-seconds` | Time budget for each attempt of a write (its checks, the send and the receipt wait), default `300`. A budget, not a spend limit. |
| `--solana-owner` | Required. The owner wallet's base58 public key. |
| `--solana-controller` | Optional. Omit for the owner-only default. |
| `--evm-operator` | Optional assertion: when given, it must equal the operator wallet's peaq address (`EVM_OPERATOR_MISMATCH` before `[1/7]` otherwise). Omit it; the machine inherits the operator from the registration. |
| `--compute-unit-limit` | Optional. The mint's compute budget. Without it the CLI sizes the limit from the mint's simulation (0.0.17): the simulated units × 1.3, rounded up to the next 1,000 (about 121,000 on production), so the consent quotes the fee actually paid. An explicit value is used as given; before 0.0.17 the default was 600,000. The other writes use the SDK's own limits; the link push on the Trust Validator route asks for 120,000. |
| `--phase` | Run one stage only: `registration`, `request`, `finalise`, `native_onboarding`, `linkage` or `cancel_request`. The waits (`credit`, `bond`) are not phases: rerun without `--phase` and with `--wait-minutes` to wait them out. |

The machine ID is the same derivation as on peaq, `uint256(keccak256(abi.encode(machineType, credentialSubject)))`, a decimal string in JSON and the DID `did:peaq:<decimal id>`. `--for` and `--machine-key` are rejected with `--chain solana`. The removed reservation flow's options are refused by name with `OPTION_NOT_FOR_CHAIN` (exit `1`), naming the replacement: `--payment` (use `--pay-in`), `--max-net-peaq-amount` and `--max-usdt-amount` (use `--max-in`). `--from-block` is accepted and ignored.

### What stays fixed

The SDK fingerprints the machine as the mint and the link push need it: the owner, the DID document and controller, the manufacturer, the machine type and credential subject, the two native ceilings, and `--evm-operator` if you pass one. A changed machine type or credential subject is a different machine ID and a new onboarding; changing any of the others stops the remaining stages until you put the original values back. The request freezes the tier, the rail and the escrow when it opens, so keep `--tier`, `--pay-in` and `--max-in` for every later invocation too.

The fingerprint also covers the deployment record inside the installed SDK. Finish a machine before upgrading the CLI or the SDK; confirmed stages reconcile from the chain and are never paid twice.

### The point of no return

Until `[4/7]` you can step out: `--phase cancel_request` returns the escrow in the pay-in token and the request's rents to the owner, from a request that is still `Pending` or `Committed`. After the settlement there is no cancel. The bond goes to peaq; if peaq refuses it, the Trust Validator refunds it **in PEAQ**, on both rails, to the owner's PEAQ token account (`ACTIVATION_REFUNDED`, with the reason and the amount), and a rerun opens a new request. The settlement's terms open with "This cannot be cancelled afterwards."

## Run it in one command

<Steps>
  <Step title="Preview">
    Keyless; nothing is signed or sent. One screen: where the onboarding stands, the registration, the bond and what `--max-in` escrows against the owner's balance (on USDC, the swap's estimated cost), the rents, the settlement path and the upgrade lock, and what each of the seven stages would do. A preview is not transaction evidence. `peaqos -v` and `--json` show everything behind it.

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    peaqos activate "${ARGS[@]}" --dry-run
    ```

    What production printed on 2026-10-07 for a fresh machine of an owner wallet that was already registered:

    ```text theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    Solana onboarding preview · peaq-mainnet · machine 7916511350…938454
      Endpoints      peaq https://… · Solana https://api.mainnet-beta.solana.com
      Solana owner   ARDJEqBCNev88BhXkbTWZdsTz4btHrmLmUhXjQBUydhv
      peaq operator  0xf0Bb42C59928481cD9b7C2990dFFb034Eaf6E3DF
      Stands at      no request yet; a run starts at [1/7]
      Registration   bound to 0xf0Bb42C59928481cD9b7C2990dFFb034Eaf6E3DF (seq 1); [1/7] reconciles it, nothing is sent
      Bond           4,841,803,747 base units (4.841803747 PEAQ) for tier 1 at 0.04130692 USD per PEAQ (stamp 178 min old)
      Pay in PEAQ    --max-in 4890221785 escrows 4,890,221,785 base units (4.890221785 PEAQ)
                     the owner holds 15,351,131,634 base units (15.351131634 PEAQ)
      Rents          ≈ 0.00849884 SOL (request 0.00472948 SOL, machine 0.00376936 SOL); the link push is quoted at [7/7]
      Settlement     in PEAQ, no swap · upgrade lock released

    Stages
      [1/7] Operator registration on peaq  checks the chains first, sends only what is missing; peaq operator signs
      [2/7] Activation request on Solana   sends; Solana owner signs
      [3/7] Credit from peaq               waits for Trust Validator node (typically 3 min)
      [4/7] Settlement on Solana           sends; Solana owner signs
      [5/7] Bond on peaq                   waits for node and peaq (typically 6 min)
      [6/7] Native onboarding on Solana    sends; Solana owner signs
      [7/7] Link push Solana → peaq        checks the chains first, sends only what is missing; Solana owner signs (typically 6 min)

    Run it with --max-in 4890221785; --yes accepts each stage's terms.
    ```

    Add the suggested escrow to the argument set, and keep it for every later invocation:

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    MAX_IN=4890221785          # from "Run it with --max-in …"
    ARGS+=(--max-in "$MAX_IN")
    ```

    A wallet that is not registered yet is quoted for the operator `[1/7]` will register it under, so the first run sees its bond in advance. On USDC, check that the settlement row names the lookup table as verified.
  </Step>

  <Step title="Run">
    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    peaqos activate "${ARGS[@]}" \
      --operator-wallet svm-operator --owner-wallet svm-owner \
      --wait-minutes 60 --yes
    ```

    No link fee cap: production's link push goes to the Trust Validator node, with no messaging fee. The run prints one plan block (both wallets, the expected bond and escrow, the rents, what the owner must hold before `[2/7]`), then each stage's terms and outcome on stderr: every amount in base units with the exact token figure, each cap, and the signer. A confirmed write also says how many sends it took (`landed after 1 send`; the mint adds the compute it used of its limit; CLI 0.0.17). `--yes` accepts each stage's terms as printed; without it, each stage asks once. With `--json`, stderr carries only JSON lines (`plan`, `consent`, the stage and wait events) and stdout one JSON result at the end; each confirmation carries `sends` and `resend_errors`.

    What production printed for the USDC run of 2026-10-06, trimmed (the owner wallet was registered by an earlier machine, so `[1/7]` sent nothing), with the `landed after` lines that 0.0.17 adds under each confirmed write:

    ```text theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    [1/7] Operator registration on peaq: reconciled (already done; nothing was signed)
    [2/7] Activation request on Solana: confirmed 21MLLWZRq3…W2YKU
          landed after 1 send
    [3/7] Credit from peaq: …
          Waiting for the Trust Validator node to commit the request's credit on Solana (typically about 1.5 min; the node commits on its own).
          landed in 2 min 1 s
    [4/7] Settlement on Solana: …
      This cannot be cancelled afterwards.
      Swaps up to 202,814 base units (0.202814 USDC) of the escrow for exactly 4,744,535,245 base units (4.744535245 PEAQ) (simulated 200,807 base units (0.200807 USDC)) · returns at least 0 base units (0 USDC) in USDC
    [4/7] Settlement on Solana: confirmed 2Z6qyiETb8…JKPvr
          landed after 1 send
    [5/7] Bond on peaq: …
          landed in 3 min 2 s
    [6/7] Native onboarding on Solana: confirmed 1potTahdSP…SKLm4
          landed after 1 send
    [7/7] Link push Solana → peaq: …
      push account rent 0.00100584 SOL (cap 0.006 SOL, 6000000 lamports) · network fee 0.0000062 SOL · total 0.00101204 SOL · upgrade lock off
    [7/7] Link push Solana → peaq: confirmed 3ga11QrfBD…J7Fww
          landed after 1 send
          Link push queued for the Trust Validator node (seq 1, TV nonce 2). Waiting for the node to deliver it on peaq (typically about 6 min).
    …
    Onboarding complete: peaq and Solana agree: link seq 1 applied (watermark read at peaq block 11884206).
    Spent (from receipts)
      Solana SOL, owner ARDJEqBCNev88BhXkbTWZdsTz4btHrmLmUhXjQBUydhv: 8,048,940 lamports spent (request -4,735,980 · finalise 1,479,440 · native onboarding -3,780,360 · link push -1,012,040)
      Solana tokens (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v base units): escrow out 202,814 · back 2,007 · used 200,807
    ```

    The mint's line also carries its compute. On the 2026-10-08 run, whose first send of the mint was swallowed on purpose to prove the re-send, it read `landed after 2 sends · 93,009 of 121,000 CU`.

    A first machine of an owner wallet sends the registration at `[1/7]` instead. With the node healthy, the proven runs took about 10 minutes from one command:

    | Wait | Measured on production, 2026-10-06 to 2026-10-08 |
    | :- | :- |
    | The registration mirrored to Solana (first machine of an owner wallet) | 1 to 2 min |
    | `[3/7]` credit | 60 s to 2 min 1 s |
    | `[5/7]` bond | 1.5 to 4.6 min |
    | `[7/7]` the node's delivery of the link push on peaq | 44 s to 4 min |

    Done means exit `0` and `next_step.phase` equal to `complete`; with `--json`, `jq -r .next_step.phase` on the output.
  </Step>

  <Step title="If it stops early">
    **Rerun the same command.** It reads `peaqos.log`, works out where the onboarding stands and starts there: a live request resumes at the stage its state names (`Pending` → `[3/7]`, `Committed` → `[4/7]`, `Settled` → `[5/7]`, `Bonded` → `[6/7]`). Nothing recorded is ever signed again. A write is re-sent as the same signed bytes only inside the invocation that signed it, while it can still land; a rerun holds no bytes and re-sends nothing. That covers a wait that ran out (`PENDING`, exit `2`, naming `operator_binding`, `credit`, `bond` or `link_application`), Ctrl-C (`CANCELLED`, exit `1`), a timeout and a lost connection. Every stop prints the command as `resume_command`.
  </Step>
</Steps>

## Run it one stage at a time

The same onboarding, split up. Each command takes the full argument set and the wallet its stage needs: `svm-operator` for the registration, `svm-owner` for every Solana write (both from [Configure](#configure)).

<Steps>
  <Step title="Register the owner on peaq (operator wallet)">
    Skipped, and nothing signed, when the owner wallet is already bound to this operator.

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    peaqos activate "${ARGS[@]}" --phase registration --operator-wallet svm-operator --yes --wait-minutes 5
    ```

    The node mirrors the binding to Solana a minute or two later, and the request needs it. With `--wait-minutes` the stage waits for the mirror and ends `confirmed`. Without it, the stage exits `PENDING` (exit `2`) right after the registration lands, and a rerun waits.
  </Step>

  <Step title="Open the request, then settle it (owner wallet)">
    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    peaqos activate "${ARGS[@]}" --phase request --owner-wallet svm-owner --yes
    peaqos activate "${ARGS[@]}" --phase finalise --dry-run     # once the credit landed: the settlement path and, on USDC, the simulated swap
    peaqos activate "${ARGS[@]}" --phase finalise --owner-wallet svm-owner --yes
    ```

    Between the two writes the node commits the credit. Rerun without `--phase` and with `--wait-minutes` to wait for it, or preview `--phase finalise` until it no longer reports the credit unresolved. A per-phase preview prints every field as `Section / Field: value`: the request's state is `Terminal / Request state` (`pending`, then `committed`), and on USDC the swap's figure is `Observations / Settlement / Swap / Simulated in amount`. The settlement simulates again before signing and swaps up to that figure plus the slippage cushion, never more than the escrow; what the swap does not use comes back in USDC. Solana's public endpoint can answer one preview's reads at slightly different slots. If the request reads `committed` while the settlement still says `REQUEST_NOT_COMMITTED`, preview once more.
  </Step>

  <Step title="Mint the machine and push the link (owner wallet)">
    Once peaq has booked the bond:

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    peaqos activate "${ARGS[@]}" --phase native_onboarding --owner-wallet svm-owner --yes
    peaqos activate "${ARGS[@]}" --phase linkage --dry-run      # names the route and quotes the push account rent and the network fee
    peaqos activate "${ARGS[@]}" --phase linkage --owner-wallet svm-owner --yes --wait-minutes 10
    ```

    The link push (`send_machine_registry_push` on the Solana bridge adapter) tells peaq the machine exists on Solana and who operates it; the Trust Validator node then delivers it on peaq. A machine whose last push already carries its values reports `reason: "link_already_pushed"`, loads no wallet and sends nothing. `--wait-minutes` lets the link stage wait for the node's delivery and end at `complete`. Without it (the default is `0`) the stage ends once the push is sent; rerun it with `--wait-minutes` to wait for the delivery. Nothing is sent again.

    Each stage's summary covers that invocation only: the last stage's `Spent` lists the mint and the push, and its audit trail reads the earlier stages' transactions as "not read". For the whole onboarding's cost read [`peaqos machine history`](#see-what-ran), or `--dry-run --audit` without `--phase`.
  </Step>
</Steps>

To end an open request that will not settle (expired, or no longer wanted), cancel it with the owner wallet. The escrow returns in the pay-in token, the request's rents come back and the nonce is spent:

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
peaqos activate "${ARGS[@]}" --phase cancel_request --owner-wallet svm-owner --yes
```

The cancel prints `Cancel request on Solana: confirmed <signature>` and how many sends it took (CLI 0.0.17; before, it printed no result line). With no request open, the outcome is decided before consent and the passphrase: a cancel this journal recorded ends `already done; nothing was signed`; a cancel the journal did not record (the node's, once the request expired) ends `REQUEST_CLOSED`; a request closed after the mint ends `REQUEST_NOT_CANCELLABLE`.

## What "complete" means

Onboarding is complete when `next_step.phase` (and `onboarding_state.evidence.stage.phase`) reads `complete`, or the human output prints the summary with `link seq N applied`. Behind that is one on-chain read: peaq's `MachineBridgeAdapter.lastAppliedMirrorPushSeq` for the machine (the watermark) is at or above the sequence of your link push. The block in the summary is the finalized block the watermark was **read** at, not the block the push applied in. A later push still in flight, such as an operator change, is reported as `later_push_pending`; the onboarding stays complete.

A Solana receipt for the push proves the send, not the application, and the CLI does not use it as evidence. The push's receipt carries the node's queue entry (`TvRelayQueued`, with its outbox nonce); the node then calls `tvReceive` on peaq's bridge adapter, and the watermark moves. Every output block says where it came from: `provenance: "journal_claim"` is what the CLI recorded at submit time, `chain_verified` is what the SDK read back from peaq and Solana.

## See what ran

From any directory, with only the deployment's `.env` (no journal, original inputs or wallet):

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
peaqos machine history "$MACHINE_ID" --chain solana          # one line per transaction, then totals per payer
peaqos -v machine history "$MACHINE_ID" --chain solana       # plus full references and explorer links
peaqos machine history "$MACHINE_ID" --chain solana --json
```

Every transaction of the onboarding on both chains, in time order: the registration on peaq, the node's binding, its credit ticket on peaq and its credit and status commits on Solana, the request, the settlement, the bond on peaq, the mint, the link push and its delivery. Each row says who sent it (`owner`, `operator`, `node`, `layerzero`), where it landed and what it cost its payer. A delivery by the node's `tvReceive` reads `node`, and `-v` links the push to the node's outbox (`tv_outbox:`). A transaction the owner did not pay can still move the owner's balances: when the node cancels an expired request, its row shows the node's fee and then what came back to the owner (`owner +3,657,600 lamports`, and the escrow), and the owner's total is its net change. The registration and the binding belong to the owner wallet: on a later machine of the same owner they are marked `reused` and stay out of that machine's totals. A source that could not be read is named and the command exits `2`; the rows shown are still exact, and a rerun reads it.

In the machine's directory, `onboarding-<machine-id>.json` holds every invocation (start, end, exit code, stages), the references, `spent`, the audit trail and, once complete, the same history. `peaqos activate "${ARGS[@]}" --dry-run --audit` re-reads the onboarding from `peaqos.log`: every transaction confirmed against the chain with its slot or block, what it spent, and the link push's relay by the node with its outbox nonce.

## Read the state

With the machine ID alone, no wallet, journal or DID document (`MACHINE_ID` is the `machine_id` from any output, the preview included):

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
peaqos machine status "$MACHINE_ID" --json
peaqos machine status "$MACHINE_ID" --json --chain solana
```

Without `--chain solana`, the CLI falls back to the Solana read when the peaq record is missing or homed elsewhere and `PEAQOS_SVM_RPC_URL` is configured. That result has `status: "observed"` and a `native_current_state` with separate peaq (finalized) and Solana (confirmed) observations, not an atomic snapshot. With `--chain solana` the CLI reads the native management state instead, including the top-level `link` block with the watermark (`not_pushed`, `pending_application`, `linked` or `unavailable`); it needs the native machine to exist. `present` proves the accounts exist, not that this onboarding created them or that the link is complete.

## Resume and troubleshoot

Never delete the journal, never change the inputs, never submit a replacement transaction yourself. The SDK records every signed reference before broadcast. Every refusal before signing says so.

| You see | Do |
| :- | :- |
| `PENDING` after a wait, naming `operator_binding`, `credit`, `bond` or `link_application` | The wait itself sent nothing; the stages before it stand. Rerun the same command; it keeps waiting (the credit wait stops after 3 minutes and the bond wait after 6, whatever the budget). The measured waits are in the table above. If nothing has moved after 30 minutes, the node may be down: post your `machine_id` in the [peaq Discord](https://discord.gg/UKTFkPWsyH). peaq's Trust Validator node has stalled for hours before (on 2026-10-07 and again on 2026-10-08): every onboarding then stops at `[3/7]` until the node is back, and nothing is lost. The request expires after an hour, the node cancels and refunds it once it is back (or you cancel it with `--phase cancel_request`), and the rerun opens a new request. |
| `PENDING` right after a send | The endpoint has not shown the receipt yet. Wait a minute, rerun; it reconciles without resending. |
| A terminal write (`request`, `finalise`, `cancel_request`) that stays unconfirmed for several minutes after "signed and journaled" | The first send may have been lost on its way to a leader. Since CLI 0.0.17 / SDK 0.11.2 the SDK handles this itself: while the signature is unknown and the blockhash still valid (about 80 s), it re-sends the same signed bytes every 2 s. Same bytes, same signature: the write can land at most once. Only a write that still never lands is handled as before. Rerun once; the rerun reconciles the journaled signature and sends nothing while that signature may still land. A request, settlement or cancellation that failed, or expired without landing, is replaced by a fresh one; a link push (`[7/7]`) is not. If the write still reads unconfirmed, post your `machine_id` and the signature in the [peaq Discord](https://discord.gg/UKTFkPWsyH). |
| `[6/7]` stays `unconfirmed` after a rerun: a mint that never landed | The mint's first send was lost. Since CLI 0.0.17 / SDK 0.11.2 the run itself re-sends the same signed bytes for about 80 s, while the blockhash is still valid. On production on 2026-10-08 the first re-send landed the mint in the same run (`landed after 2 sends`). Only a mint that still never lands is handled as before: once its blockhash window has closed (about 90 s), the next run proves it dead (its blockhash expired, no signature in the cluster's history, no `Machine` or `MachineState` account), prints the row as `native_onboarding <signature> expired: never landed (…); minting again`, and `[6/7]` signs a fresh mint. The dead row stays in `peaqos.log` (CLI 0.0.16 / SDK 0.11.1). A mint that landed but ran out of compute and created nothing is minted again the same way: `native_onboarding <signature> failed: ran out of compute (ComputationalBudgetExceeded); nothing was created; minting again` (0.0.17 / 0.11.2). A row that still reads `unconfirmed` after that had a read fail or disagree (`--json`: `onboarding_recovery.attempts[].native`, with `validity`, `history`, `never_landed`, `failed_without_effect`, `no_effect` and `may_resubmit`); rerun, and if it persists post your `machine_id` and the signature in the [peaq Discord](https://discord.gg/UKTFkPWsyH). A dedicated Solana RPC endpoint makes drops rarer than the public one. |
| `REQUEST_EXPIRED` | The node did not commit the credit within the request's hour. Nothing was settled. The node cancels an expired request on its own once it is back, returning the escrow and the request's rents; or run `--phase cancel_request` yourself. Then rerun the same command: it opens a new request at the next nonce, even though the journal `peaqos.log` still holds the old one (CLI 0.0.17 / SDK 0.11.2; before, the rerun could take the old row for a live request and stop at `[3/7]` with `ActivationRequest absent`). |
| `COMMIT_EXPIRED` | The commit expired before the settlement. Nothing was settled. Cancel, then rerun. |
| `REQUEST_CLOSED` | You ran a single `--phase` (`finalise` or `cancel_request`; in the SDKs also the credit and bond waits and the settlement preview) on a request that was cancelled or refunded. There is nothing left to wait for, settle or cancel there, and nothing was signed. Rerun the same command without `--phase`: it opens a new request at the next nonce (CLI 0.0.17 / SDK 0.11.2; before, this read `REQUEST_NOT_COMMITTED`, `STATE_MISMATCH` or `REQUEST_NOT_CANCELLABLE`). |
| `ACTIVATION_REFUNDED` | peaq refused the bond; the Trust Validator returned it in PEAQ to the owner's PEAQ account (the error names the reason and the amount). Rerun the same command: it opens a new request. |
| `ESCROW_BELOW_BOND` | `--max-in` is below the bond on PEAQ. The error shows both figures; take the preview's suggested `--max-in`. |
| `INSUFFICIENT_BALANCE` | The owner's PEAQ or USDC token account holds less than `--max-in`, or does not exist. Fund it on Solana. |
| `INSUFFICIENT_SOL`, `INSUFFICIENT_NATIVE_BALANCE` | The owner cannot pay a write and still keep its own rent-exempt minimum (650,240 lamports for a plain wallet). Refused before signing; fund the owner wallet and rerun. |
| `RPC_FAILED` (`sdk_code` `READ_FAILED`; `READ_FAILED` in the SDKs) on a Solana read | A Solana server kept answering 429, or stayed behind the slot the read needed (`-32016`), after the SDK's retries. Nothing was signed. Rerun; a provider endpoint avoids it. |
| `FINALISE_TABLE_MISSING`, `FINALISE_TABLE_STALE` | USDC only, refused before signing: no usable lookup table for the swap. Pay in PEAQ, or rerun once the deployment has a table. A pool that moved past the cap fails the pre-signing simulation instead (`sdk_code` `SLIPPAGE_EXCEEDED`): rerun, the swap is simulated again. |
| `TECHNICALLY_PAUSED` | The suite is mid-upgrade. Nothing was signed. Rerun once the preview shows `upgrade lock released`. |
| `TV_OUTBOX_MISSING`, `TV_INBOUND_REFUSED` | The link push's route to the node is not set up (no outbox on Solana, or peaq refuses the node's deliveries). A deployment problem, not yours; nothing was signed. Post the code and your `machine_id` in the [peaq Discord](https://discord.gg/UKTFkPWsyH). |
| `EVM_OPERATOR_MISMATCH` | `--evm-operator` is not the operator wallet's address. Refused before `[1/7]`. Drop the flag. |
| `OPERATOR_BOUND_ELSEWHERE` | The owner wallet is already registered to another peaq operator. Use that operator wallet, or a new owner wallet. |
| `FEE_LIMIT_EXCEEDED`, `RENT_LIMIT_EXCEEDED` | Refused before signing; the message names the quoted amount and the cap. A network fee over `--max-native-fee-lamports`: lower `--compute-unit-price-micro-lamports`. The two native caps are part of the fixed inputs (see [what stays fixed](#what-stays-fixed)): raise one only before `[6/7]`, where the mint fixes them. A LayerZero fee over `--max-link-push-fee-lamports` (LayerZero route only): raise that cap. |
| `machine history` names an unread source | The rows shown are exact; a read timed out. Rerun it. |
| `OPTION_NOT_FOR_CHAIN` | An option of the removed reservation flow. The error names the replacement. |
| `ADDRESSES_UNSET` with `sdk_code` `DEPLOYMENT_UNAVAILABLE` | The installed SDK's packaged deployment evidence does not match the chain: an outdated SDK, or the programs were upgraded after its release. Upgrade the CLI and SDK. Nothing was signed. |
| `RPC_RATE_LIMITED` | The peaq endpoint kept answering 429 after the SDK's read retries. Point `PEAQOS_RPC_URL` at an endpoint with a higher quota and rerun. |
| `STATE_MISMATCH` with `Select the accepted phase's wallet` | The wrong wallet for the stage. Nothing was submitted. Registration takes the operator wallet, every Solana write the owner wallet. |
| A conflict about inputs, signer or account | Restore the saved argument set and the original DID document; see [what stays fixed](#what-stays-fixed). |

Exit codes: `1` input or consent (and `CANCELLED`), `2` chain, read or transaction failure (and `PENDING` after a wait), `3` configuration or missing dependency. With `--json`, read `error_code` and the retained evidence, not only the exit code. A stage failure sits in `phases[]` with its `error_code`, `detail` and `signed`, so a refusal before signing never reads as a failed transaction.

## The same flow in the SDKs

Both SDKs run the same seven stages. The scripts below are the ones that onboarded the proven machines (Python on both rails, JavaScript on both rails): each tab gives the whole file to copy, then walks through the parts that matter, quoted as they are. Each does whatever the SDK's status read says is next until the machine is `complete`. Each journals every write before it is broadcast and resumes from that journal, so a second invocation after any stop continues and never signs a recorded write again. Since SDK 0.11.2 a write whose first send is lost is re-sent as the same signed bytes, inside the invocation that signed it; a new invocation holds no bytes and re-sends nothing. Neither signs on peaq: register the owner wallet first, with the CLI's `[1/7]` (`--phase registration`) or the SDK's `register_solana_operator` / `registerSolanaOperator`. The machine's directory holds the same `.env` as the CLI and the DID document as `did.json`; the owner wallet's passphrase is read from `OWS_PASSPHRASE`.

<Tabs>
  <Tab title="Python">
    Run it with the machine's values; every Solana write is signed by the OWS wallet `--owner-wallet` names:

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    python onboard_solana.py --dir <machine dir> --serial <serial> --pay-in PEAQ \
        --owner <base58 owner> --operator <0x operator> --owner-wallet <OWS wallet> \
        --manufacturer <base58> --machine-type <type> [--dry-run] [--wait-minutes 60] \
        [--tier 1] [--compute-unit-price-micro-lamports 10000]
    ```

    Save the whole script as `onboard_solana.py`, in an environment with the SDK installed (`pip install -U "peaq-os-sdk[solana,ows]"`, 0.11.2 or newer). It reads the machine directory's `.env` itself, and the passphrase from `OWS_PASSPHRASE` in your shell:

    <Accordion title="onboard_solana.py (the whole script)">
      ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
      """Onboard one machine terminal-first on Solana from the public Python API, resuming from a journal.

      The Python twin of the JS SDK's ``onboard-solana.mjs``: the same stages, the same journal
      discipline, the same output lines. It does whatever the SDK's
      status read says is next until the machine is ``complete``, so a second invocation after any
      stop continues where the first ended and never re-sends::

          python onboard_solana.py --dir <machine dir> --serial <serial> --pay-in PEAQ \\
              --owner <base58 owner> --operator <0x operator> --owner-wallet <OWS wallet> \\
              --manufacturer <base58> --machine-type <type> [--dry-run] [--wait-minutes 60] \\
              [--tier 1] [--compute-unit-price-micro-lamports 10000]

      The machine dir holds ``.env`` (the CLI's keys), ``did.json`` (the DID document) and, after the
      first write, ``peaqos-py.log`` (one serialized journal row per line). Every Solana write is signed
      by the OWS vault wallet named by ``--owner-wallet``, unlocked with ``OWS_PASSPHRASE``. Nothing on
      peaq is signed: ``--owner`` must already be registered to ``--operator`` (``[1/7]``, once per owner
      wallet: ``peaqos activate --phase registration`` or ``register_solana_operator``).

      Reads are keyless. ``--dry-run`` stops after them: the registration, the plan, the quote and the
      link push quote (route, fee). No endpoint URL is ever printed.
      """

      from __future__ import annotations

      import argparse
      import json
      import os
      import sys
      import time
      from dataclasses import replace
      from datetime import datetime, timezone
      from pathlib import Path
      from typing import Any

      from peaq_os_sdk import (
          ActivateSolanaMachineParams,
          ActivateSolanaTerminalParams,
          Address,
          PeaqosClient,
          PreviewSolanaMachineActivationParams,
          ServiceEndpointInput,
          SolanaMachineActivationStatusParams,
          SolanaMachineIdentity,
          SolanaOnboardingInputs,
          SolanaOnboardingRecoveryParams,
          SolanaPubkey,
          SolanaVerificationMethodInput,
          Tokenomics20Config,
          TokenomicsPendingTransactionError,
          normalize_solana_controller,
      )
      from peaq_os_sdk.tokenomics.solana_onboarding_attempts import (
          SolanaOnboardingAttempt,
          SolanaOnboardingAttemptRecord,
          TerminalActivationAttempt,
          decode_solana_onboarding_attempt,
          serialize_solana_onboarding_attempt,
      )

      # ---- the caps and budgets (the owner, operator, manufacturer and machine type are arguments) ----
      MAX_NATIVE_FEE_LAMPORTS = 50_000
      MAX_NATIVE_RENT_LAMPORTS = 6_000_000
      COMPUTE_UNIT_LIMIT = 600_000  # the mint's budget; the link push sizes its own by route
      DEFAULT_COMPUTE_UNIT_PRICE = 10_000
      """Micro-lamports per compute unit. At 0, Solana's public endpoint can drop a write under load,
      and a journaled write is reconciled, never re-sent; 10,000 costs 6,000 lamports on the mint and
      1,200 on the push."""
      CALL_TIMEOUT_SECONDS = 120.0
      POLL_SECONDS = 15
      HISTORY_TIMEOUT_SECONDS = 600.0
      """The history reads both chains; right after a run the public Solana endpoint still
      rate-limits, and the SDK's 120 s default can leave sources unread. The CLI's `machine history`
      allows up to 900."""
      STAGE = {
          "registration": 1,
          "request": 2,
          "credit": 3,
          "finalise": 4,
          "bond": 5,
          "native_onboarding": 6,
          "linkage": 7,
      }
      TERMINAL_WAIT_SECONDS = {"credit": 180.0, "bond": 360.0}
      CLOSED_REQUEST = ("cancelled_or_refunded", "closed")
      """Request states the terminal reports once a request can no longer settle."""


      def log(line: str) -> None:
          print(f"{datetime.now(timezone.utc).strftime('%H:%M:%S')}  {line}", flush=True)


      def short(value: object) -> str:
          text = str(value)
          return f"{text[:10]}…{text[-5:]}" if len(text) > 20 else text


      def header(phase: str, text: str) -> None:
          log(f"[{STAGE[phase]}/7] {phase}: {text}")


      def read_env(path: Path) -> dict[str, str]:
          """The machine dir's ``.env``, which wins over the shell (an exported endpoint once did not)."""
          values: dict[str, str] = {}
          for raw in path.read_text(encoding="utf-8").splitlines():
              line = raw.strip()
              if not line or line.startswith("#") or "=" not in line:
                  continue
              key, value = line.split("=", 1)
              values[key.strip()] = value.strip().strip('"').strip("'")
          return values


      def build_inputs(args: argparse.Namespace, did: dict[str, Any]) -> SolanaOnboardingInputs:
          """The machine's native inputs: never change them between invocations of one machine."""
          return SolanaOnboardingInputs(
              solana_owner=SolanaPubkey(args.owner),
              controller=normalize_solana_controller(),  # owner-only, as the CLI
              manufacturer=SolanaPubkey(args.manufacturer),
              machine_type=args.machine_type,
              credential_subject=args.serial.encode("utf-8"),
              verification_methods=tuple(
                  SolanaVerificationMethodInput(
                      id=m["id"],
                      method_type=m["methodType"],
                      controller=SolanaPubkey(m["controller"]),
                      public_key_multibase=m["publicKeyMultibase"],
                  )
                  for m in did["verificationMethods"]
              ),
              authentication=tuple(int(i) for i in did["authentication"]),
              service_endpoints=tuple(
                  ServiceEndpointInput(
                      id=s["id"], service_type=s["serviceType"], service_endpoint=s["serviceEndpoint"]
                  )
                  for s in did["serviceEndpoints"]
              ),
              max_native_fee_lamports=MAX_NATIVE_FEE_LAMPORTS,
              max_native_rent_lamports=MAX_NATIVE_RENT_LAMPORTS,
              evm_operator=Address(args.operator),
          )


      class Journal:
          """``peaqos-py.log``: one serialized attempt per line, written before broadcast."""

          def __init__(self, path: Path, client: PeaqosClient) -> None:
              self.path = path
              self.client = client

          def records(self) -> tuple[SolanaOnboardingAttemptRecord, ...]:
              if not self.path.exists():
                  return ()
              lines = self.path.read_text(encoding="utf-8").splitlines()
              return tuple(decode_solana_onboarding_attempt(line) for line in lines if line.strip())

          def terminal(self) -> tuple[TerminalActivationAttempt, ...]:
              return tuple(
                  r.attempt for r in self.records() if isinstance(r.attempt, TerminalActivationAttempt)
              )

          def for_request(self, state: Any) -> tuple[TerminalActivationAttempt, ...]:
              """The terminal rows a new request may reconcile: none of a request already closed.

              A request the terminal has cancelled, refunded or closed is history; the next request
              opens the following nonce. Handed on, its row would be reconciled again instead of a new
              request being opened.
              """
              rows = self.terminal()
              terminal = state.observations.terminal if state.observations is not None else None
              last = terminal.terminal.last_nonce if terminal is not None else None
              if last is None or terminal is None or terminal.request_state not in CLOSED_REQUEST:
                  return rows
              current = tuple(r for r in rows if r.nonce > last)
              if len(current) < len(rows):
                  log(
                      f"      journal: request nonce {last} is {terminal.request_state} on the "
                      f"terminal; its {len(rows) - len(current)} row(s) stay in the journal and the "
                      f"request opens nonce {last + 1}"
                  )
              return current

          def persist(self, attempt: SolanaOnboardingAttempt) -> None:
              deployment = self.client.tokenomics20
              assert deployment is not None
              with self.path.open("a", encoding="utf-8") as handle:
                  handle.write(serialize_solana_onboarding_attempt(attempt, deployment) + "\n")
                  handle.flush()
                  os.fsync(handle.fileno())
              reference = getattr(attempt, "signature", None) or getattr(
                  attempt, "transaction_hash", None
              )
              log(f"      {attempt.phase} signed and journaled: {short(reference)}")


      class Runner:
          def __init__(self, args: argparse.Namespace) -> None:
              self.dir = Path(args.dir)
              self.serial: str = args.serial
              self.pay_in: str = args.pay_in
              self.tier: int = args.tier
              self.wallet: str = args.owner_wallet
              self.owner = SolanaPubkey(args.owner)
              self.operator = Address(args.operator)
              self.price: int = args.compute_unit_price_micro_lamports
              self.deadline = time.monotonic() + args.wait_minutes * 60
              env = read_env(self.dir / ".env")

              def need(key: str) -> str:
                  value = env.get(key)
                  if not value:
                      raise SystemExit(f"{key} is not set in {self.dir / '.env'}")
                  return value

              self.deployment_id = need("TOKENOMICS_DEPLOYMENT_ID")
              # Keyless: no private key. Nothing on peaq is signed by this script.
              self.client = PeaqosClient(
                  rpc_url=need("PEAQOS_RPC_URL"),
                  identity_registry=Address(need("IDENTITY_REGISTRY_ADDRESS")),
                  identity_staking=Address(need("IDENTITY_STAKING_ADDRESS")),
                  event_registry=Address(need("EVENT_REGISTRY_ADDRESS")),
                  machine_nft=Address(need("MACHINE_NFT_ADDRESS")),
                  did_registry=Address(need("DID_REGISTRY_ADDRESS")),
                  batch_precompile=Address(need("BATCH_PRECOMPILE_ADDRESS")),
                  tokenomics20=Tokenomics20Config(
                      deployment_id=self.deployment_id,
                      creation_home="solana",
                      solana_rpc_url=need("PEAQOS_SVM_RPC_URL"),
                  ),
              )
              did = json.loads((self.dir / "did.json").read_text(encoding="utf-8"))
              self.identity = SolanaMachineIdentity(args.machine_type, self.serial.encode("utf-8"))
              self.inputs = build_inputs(args, did)
              self.preview = PreviewSolanaMachineActivationParams(
                  inputs=self.inputs,
                  compute_unit_limit=COMPUTE_UNIT_LIMIT,
                  compute_unit_price_micro_lamports=self.price,
              )
              self.journal = Journal(self.dir / "peaqos-py.log", self.client)
              self.signer: Any = None
              self.machine_id = 0

          # ---- reads ---------------------------------------------------------------------------
          def budget_left(self) -> int:
              return max(0, int(self.deadline - time.monotonic()))

          def registration(self) -> None:
              state = self.client.get_solana_operator_registration(
                  self.owner, peaq_operator=self.operator
              )
              bound = state.bound_operator
              if state.state != "bound" or bound is None or bound.lower() != self.operator.lower():
                  raise SystemExit(
                      f"registration is {state.state} ({bound}); this script signs nothing on peaq"
                  )
              header("registration", f"reconciled (bound to {self.operator}; nothing was signed)")

          def quote_request(self) -> int:
              quote = self.client.quote_solana_activation(
                  self.identity,
                  tier=self.tier,
                  pay_in=self.pay_in,  # type: ignore[arg-type]
                  wallet=self.owner,
              )
              log(
                  f"quote: bond {quote.bond.base} PEAQ base units · suggested escrow {quote.max_in} "
                  f"({self.pay_in} base units) · wallet balance {quote.wallet_balance} · "
                  f"stamp age {quote.price_age_secs} s"
              )
              return quote.max_in

          def status(self) -> Any:
              records = self.journal.records()
              return self.client.get_machine_activation_state(
                  self.machine_id,
                  solana=SolanaMachineActivationStatusParams(inputs=self.inputs, records=records or None),
              )

          def link_quote(self) -> Any:
              quote = self.client.quote_solana_link_push(self.machine_id, payer=self.owner)
              log(
                  f"      link push quote: {quote.status} · transport {quote.transport} · messaging fee "
                  f"{quote.native_fee_lamports} · rent {quote.push_account_rent_lamports} · "
                  f"compute {quote.compute_unit_limit} · total {quote.total_lamports} lamports"
              )
              return quote

          def get_signer(self) -> Any:
              if self.signer is None:
                  plan = self.client.preview_machine_activation(self.preview)
                  required = self.client.get_solana_onboarding_signer_requirements(
                      self.inputs, plan, "native_onboarding", candidate_address=self.owner
                  )
                  self.signer = self.client.solana_signer_from_wallet(
                      self.wallet, os.environ.get("OWS_PASSPHRASE"), chain_id=required.chain_id
                  )
                  self.client.validate_solana_onboarding_signer(
                      self.inputs, plan, "native_onboarding", solana_signer=self.signer
                  )
                  log(
                      f"      signer {self.wallet} ({short(self.owner)}) unlocked for chain "
                      f"{required.chain_id}"
                  )
              return self.signer

          # ---- writes --------------------------------------------------------------------------
          def terminal(
              self,
              phase: str,
              max_in: int | None = None,
              records: tuple[TerminalActivationAttempt, ...] | None = None,
          ) -> None:
              started = time.monotonic()
              header(phase, "…")
              observe = phase in TERMINAL_WAIT_SECONDS
              params = ActivateSolanaTerminalParams(
                  self.identity,
                  phase,  # type: ignore[arg-type]
                  tier=self.tier,
                  pay_in=self.pay_in,  # type: ignore[arg-type]
                  solana_signer=None if observe else self.get_signer(),
                  max_in_amount=max_in,
                  max_native_fee_lamports=MAX_NATIVE_FEE_LAMPORTS,
                  max_native_rent_lamports=MAX_NATIVE_RENT_LAMPORTS,
                  compute_unit_price_micro_lamports=self.price,
                  records=self.journal.terminal() if records is None else records,
                  wait_seconds=(
                      min(float(self.budget_left()), TERMINAL_WAIT_SECONDS[phase]) if observe else None
                  ),
                  timeout_seconds=CALL_TIMEOUT_SECONDS,
                  on_transaction_submitted=None if observe else self.journal.persist,
              )
              result = self.client.activate_machine(params)
              submitted = result.confirmation.submitted if result.confirmation else result.submitted
              reference = getattr(submitted, "signature", None)
              request = result.request.state if result.request is not None else "-"
              header(
                  phase,
                  f"{result.status}{f' ({result.exit})' if result.exit else ''}"
                  f"{f' {short(reference)}' if reference else ''} · request {request} "
                  f"nonce {result.nonce} · {int(time.monotonic() - started)} s",
              )
              swap = result.settlement
              if swap is not None:
                  log(
                      f"      settlement: swap simulated {swap.simulated_in_amount} · cap sent "
                      f"{swap.max_in_amount} · escrow {swap.escrow_amount} ({self.pay_in} base units) · "
                      f"slippage {swap.slippage_bps} bps · lock in table {swap.lock_in_table}"
                  )
              if result.status == "exited":
                  raise SystemExit(f"{phase} exited: {result.exit} {result.advice or ''}")

          def native(self, phase: str) -> None:
              header(phase, "…")
              recovery = self.client.get_machine_activation_state(
                  self.machine_id,
                  solana=SolanaOnboardingRecoveryParams(self.inputs, self.journal.records()),
              )
              if recovery.error is not None:
                  raise recovery.error
              plan = self.client.preview_machine_activation(self.preview)
              signer = self.get_signer()
              if phase == "linkage":
                  self.link_quote()
              params = ActivateSolanaMachineParams(
                  inputs=self.inputs,
                  accepted_plan=plan,
                  phase=phase,  # type: ignore[arg-type]
                  expected_machine_id=self.machine_id,
                  records=recovery.history.records,
                  compute_unit_limit=COMPUTE_UNIT_LIMIT,
                  compute_unit_price_micro_lamports=self.price,
                  timeout_seconds=CALL_TIMEOUT_SECONDS,
                  on_transaction_submitted=self.journal.persist,
              )
              # No max_link_push_fee_lamports: the Trust Validator node's route has no fee. On a
              # LayerZero route the SDK refuses before signing and names the cap to pass.
              params = replace(params, solana_signer=signer)
              try:
                  result = self.client.activate_machine(params)
              except TokenomicsPendingTransactionError as error:
                  log(f"      pending after signing: {error}; the row is journaled, rerun to reconcile")
                  return
              confirmation = result.confirmation
              signature = getattr(getattr(confirmation, "submitted", None), "signature", None)
              header(
                  phase,
                  f"{result.status} ({result.reason}, {result.origin})"
                  f"{f' {short(signature)}' if signature else ''}",
              )
              if phase == "linkage" and confirmation is not None:
                  pushed = getattr(confirmation, "submitted", None)
                  transport = getattr(pushed, "transport", None) or "layerzero"
                  tv_nonce = getattr(pushed, "tv_nonce", None) or "-"
                  log(
                      f"      transport {transport} · TV nonce {tv_nonce} · "
                      f"seq {getattr(pushed, 'sequence', '-')}"
                  )
              if result.error is not None:
                  log(f"      error: {result.error.code}: {result.error}")
              if result.status != "confirmed":
                  raise SystemExit(f"{phase} ended {result.status}: {result.reason}")

          # ---- the run -------------------------------------------------------------------------
          def dry_run(self, plan: Any) -> None:
              try:
                  self.quote_request()
              except Exception as error:  # a refusal is the answer a dry run reports
                  log(f"quote: {getattr(error, 'code', type(error).__name__)}: {error}")
              for stage in plan.stages:
                  log(
                      f"  stage {stage.phase}: {stage.action} (signer {stage.signer or '-'}, "
                      f"unresolved {list(stage.unresolved)})"
                  )
              try:
                  self.link_quote()
              except Exception as error:
                  log(
                      f"      link push quote: {getattr(error, 'code', type(error).__name__)}: {error} "
                      "(expected before the mint: MACHINE_NOT_ONBOARDED)"
                  )
              step = self.status().next_step
              log(f"next step: {step.phase} {step.action}")

          def run(self, dry_run: bool) -> None:
              mode = "dry run" if dry_run else "write"
              log(
                  f"Solana onboarding (Python) · {self.deployment_id} · serial {self.serial} · "
                  f"{self.pay_in} · {mode}"
              )
              self.registration()
              # The machine ID comes from the plan (keyless), never from the quote: once the request
              # is bonded the terminal rightly refuses a fresh quote, and a resume still needs the ID.
              plan = self.client.preview_machine_activation(self.preview)
              self.machine_id = plan.binding.machine_id
              log(f"machine {self.machine_id}")
              log(f"journal: {len(self.journal.records())} row(s) in {self.journal.path.name}")
              if dry_run:
                  self.dry_run(plan)
                  return
              for _ in range(60):
                  state = self.status()
                  if state.error is not None:
                      raise state.error
                  step = state.next_step
                  if step.phase == "complete":
                      break
                  if step.phase == "cancel_request":
                      raise SystemExit(
                          "the request expired before the node credited it; nothing was settled. "
                          "Wait for the node to cancel it (the escrow and rents come back), or cancel "
                          "it yourself with `peaqos activate ... --phase cancel_request`; then rerun"
                      )
                  if step.phase == "registration":
                      raise SystemExit("the status read wants a registration; this script signs none")
                  if step.phase == "request":
                      self.terminal(
                          "request", max_in=self.quote_request(), records=self.journal.for_request(state)
                      )
                  elif step.phase in ("credit", "bond", "finalise"):
                      self.terminal(step.phase)
                  elif step.phase == "native_onboarding":
                      self.native("native_onboarding")
                  elif step.phase == "linkage" and step.action == "observe":
                      if self.budget_left() == 0:
                          raise SystemExit(
                              "PENDING: the link is not applied within --wait-minutes; rerun to keep "
                              "waiting (nothing is resent)"
                          )
                      log(
                          "      waiting for the node to deliver the link push on peaq "
                          f"({self.budget_left()} s left)"
                      )
                      time.sleep(POLL_SECONDS)
                  elif step.phase == "linkage":
                      self.native("linkage")
                  else:
                      raise SystemExit(f"unexpected next step {step.phase} {step.action}")
              else:
                  raise SystemExit("PENDING: too many steps without completing; rerun to continue")
              self.finish()

          def finish(self) -> None:
              recovery = self.client.get_machine_activation_state(
                  self.machine_id,
                  solana=SolanaOnboardingRecoveryParams(
                      self.inputs, self.journal.records(), include_linkage=True
                  ),
              )
              relays = recovery.tv_relays
              log(
                  f"complete: link applied; messages {len(recovery.messages)} (LayerZero), "
                  f"tv relays {len(relays)}"
              )
              for relay in relays:
                  log(
                      f"      tv relay: {short(relay.signature)} dst {relay.dst_eid} nonce "
                      f"{relay.nonce} applied {relay.applied}"
                  )
              history = self.client.get_solana_onboarding_history(
                  self.machine_id, timeout_seconds=HISTORY_TIMEOUT_SECONDS
              )
              log(f"history: {len(history.rows)} rows, unread {list(history.unread)}")
              for row in history.rows:
                  log(
                      f"  {row.step:<18} {row.actor:<9} {row.chain:<6} {short(row.reference)}"
                      f"{' · reused' if row.reused else ''}"
                  )
              for total in history.totals:
                  log(
                      f"  total {total.chain} {short(total.payer)} lamports {total.lamports_delta} "
                      f"gas {total.gas_wei} tokens {dict(total.token_deltas)}"
                  )
              log(f"done · machine {self.machine_id}")


      def main(argv: list[str] | None = None) -> int:
          parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
          parser.add_argument("--dir", required=True, help="the machine dir (.env, did.json, journal)")
          parser.add_argument("--serial", required=True, help="the credential subject, as text")
          parser.add_argument("--pay-in", required=True, choices=("PEAQ", "USDC"))
          parser.add_argument("--owner", required=True, help="the Solana owner's base58 public key")
          parser.add_argument("--operator", required=True, help="the peaq operator it is registered to")
          parser.add_argument("--owner-wallet", required=True, help="the owner's OWS vault wallet")
          parser.add_argument("--manufacturer", required=True, help="base58, recorded unverified")
          parser.add_argument("--machine-type", required=True, help="with the serial, fixes the ID")
          parser.add_argument("--dry-run", action="store_true", help="keyless reads only")
          parser.add_argument("--wait-minutes", type=float, default=60.0)
          parser.add_argument("--tier", type=int, default=1)
          parser.add_argument(
              "--compute-unit-price-micro-lamports", type=int, default=DEFAULT_COMPUTE_UNIT_PRICE
          )
          args = parser.parse_args(argv)
          Runner(args).run(args.dry_run)
          return 0


      if __name__ == "__main__":
          sys.exit(main())
      ```
    </Accordion>

    The client is keyless (nothing on peaq is signed), and the machine's inputs come from the arguments and `did.json`:

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    self.client = PeaqosClient(
        rpc_url=need("PEAQOS_RPC_URL"),
        identity_registry=Address(need("IDENTITY_REGISTRY_ADDRESS")),
        identity_staking=Address(need("IDENTITY_STAKING_ADDRESS")),
        event_registry=Address(need("EVENT_REGISTRY_ADDRESS")),
        machine_nft=Address(need("MACHINE_NFT_ADDRESS")),
        did_registry=Address(need("DID_REGISTRY_ADDRESS")),
        batch_precompile=Address(need("BATCH_PRECOMPILE_ADDRESS")),
        tokenomics20=Tokenomics20Config(
            deployment_id=self.deployment_id,
            creation_home="solana",
            solana_rpc_url=need("PEAQOS_SVM_RPC_URL"),
        ),
    )
    ```

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    def build_inputs(args: argparse.Namespace, did: dict[str, Any]) -> SolanaOnboardingInputs:
        """The machine's native inputs: never change them between invocations of one machine."""
        return SolanaOnboardingInputs(
            solana_owner=SolanaPubkey(args.owner),
            controller=normalize_solana_controller(),  # owner-only, as the CLI
            manufacturer=SolanaPubkey(args.manufacturer),
            machine_type=args.machine_type,
            credential_subject=args.serial.encode("utf-8"),
            verification_methods=tuple(
                SolanaVerificationMethodInput(
                    id=m["id"],
                    method_type=m["methodType"],
                    controller=SolanaPubkey(m["controller"]),
                    public_key_multibase=m["publicKeyMultibase"],
                )
                for m in did["verificationMethods"]
            ),
            authentication=tuple(int(i) for i in did["authentication"]),
            service_endpoints=tuple(
                ServiceEndpointInput(
                    id=s["id"], service_type=s["serviceType"], service_endpoint=s["serviceEndpoint"]
                )
                for s in did["serviceEndpoints"]
            ),
            max_native_fee_lamports=MAX_NATIVE_FEE_LAMPORTS,
            max_native_rent_lamports=MAX_NATIVE_RENT_LAMPORTS,
            evm_operator=Address(args.operator),
        )
    ```

    The run is one loop over the status read. The terminal stages (`request`, `credit`, `finalise`, `bond`) go through `ActivateSolanaTerminalParams`, the two native ones through `ActivateSolanaMachineParams`:

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    for _ in range(60):
        state = self.status()
        if state.error is not None:
            raise state.error
        step = state.next_step
        if step.phase == "complete":
            break
        if step.phase == "cancel_request":
            raise SystemExit(
                "the request expired before the node credited it; nothing was settled. "
                "Wait for the node to cancel it (the escrow and rents come back), or cancel "
                "it yourself with `peaqos activate ... --phase cancel_request`; then rerun"
            )
        if step.phase == "registration":
            raise SystemExit("the status read wants a registration; this script signs none")
        if step.phase == "request":
            self.terminal(
                "request", max_in=self.quote_request(), records=self.journal.for_request(state)
            )
        elif step.phase in ("credit", "bond", "finalise"):
            self.terminal(step.phase)
        elif step.phase == "native_onboarding":
            self.native("native_onboarding")
        elif step.phase == "linkage" and step.action == "observe":
            if self.budget_left() == 0:
                raise SystemExit(
                    "PENDING: the link is not applied within --wait-minutes; rerun to keep "
                    "waiting (nothing is resent)"
                )
            log(
                "      waiting for the node to deliver the link push on peaq "
                f"({self.budget_left()} s left)"
            )
            time.sleep(POLL_SECONDS)
        elif step.phase == "linkage":
            self.native("linkage")
        else:
            raise SystemExit(f"unexpected next step {step.phase} {step.action}")
    else:
        raise SystemExit("PENDING: too many steps without completing; rerun to continue")
    self.finish()
    ```

    A terminal stage: the waits (`credit`, `bond`) sign nothing; the writes sign with the owner and journal before broadcast:

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    params = ActivateSolanaTerminalParams(
        self.identity,
        phase,  # type: ignore[arg-type]
        tier=self.tier,
        pay_in=self.pay_in,  # type: ignore[arg-type]
        solana_signer=None if observe else self.get_signer(),
        max_in_amount=max_in,
        max_native_fee_lamports=MAX_NATIVE_FEE_LAMPORTS,
        max_native_rent_lamports=MAX_NATIVE_RENT_LAMPORTS,
        compute_unit_price_micro_lamports=self.price,
        records=self.journal.terminal() if records is None else records,
        wait_seconds=(
            min(float(self.budget_left()), TERMINAL_WAIT_SECONDS[phase]) if observe else None
        ),
        timeout_seconds=CALL_TIMEOUT_SECONDS,
        on_transaction_submitted=None if observe else self.journal.persist,
    )
    result = self.client.activate_machine(params)
    ```

    A native stage, the mint or the link push, against a fresh plan and the journal's reconciled history:

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    recovery = self.client.get_machine_activation_state(
        self.machine_id,
        solana=SolanaOnboardingRecoveryParams(self.inputs, self.journal.records()),
    )
    if recovery.error is not None:
        raise recovery.error
    plan = self.client.preview_machine_activation(self.preview)
    signer = self.get_signer()
    if phase == "linkage":
        self.link_quote()
    params = ActivateSolanaMachineParams(
        inputs=self.inputs,
        accepted_plan=plan,
        phase=phase,  # type: ignore[arg-type]
        expected_machine_id=self.machine_id,
        records=recovery.history.records,
        compute_unit_limit=COMPUTE_UNIT_LIMIT,
        compute_unit_price_micro_lamports=self.price,
        timeout_seconds=CALL_TIMEOUT_SECONDS,
        on_transaction_submitted=self.journal.persist,
    )
    # No max_link_push_fee_lamports: the Trust Validator node's route has no fee. On a
    # LayerZero route the SDK refuses before signing and names the cap to pass.
    params = replace(params, solana_signer=signer)
    ```

    `COMPUTE_UNIT_LIMIT` (600,000) is the mint's compute limit, and the SDK uses an explicit `compute_unit_limit` as given. Since SDK 0.11.2 the plan carries `plan.native.suggested_compute_unit_limit`: the limit sized from the mint's simulation (the simulated units × 1.3, rounded up to the next 1,000, at least 50,000; about 121,000 on production, where the mint used 93,009). Pass that value instead of the constant to pay the fee actually needed, 6,210 lamports instead of 11,000 at the default price.

    The constant's docstring says a journaled write is reconciled, never re-sent. Since 0.11.2 the SDK does re-send a write whose first send was lost: the same signed bytes, every 2 s while the signature is unknown and the blockhash valid (at most 40 times), inside the process that signed them, so the write can land at most once. `result.confirmation.send_count` (`SendCount(sends, resend_errors)`) says how many sends it took; it is `None` for a row this process did not send. A mint that ran out of compute and created nothing (receipt reason `compute_budget_exceeded`) is minted again on the next run, like a mint that never landed: the recovery's `failed_without_effect`, and `no_effect` (which also covers `never_landed`), admit it. The script's `for_request` already keeps a closed request's rows out of a new request; since 0.11.2 the SDK's request phase reads the terminal record itself and opens a new request at the next nonce after a cancelled or refunded one. A phase run alone on such a request (`credit`, `bond`, `finalise`, `cancel_request`, or the settlement preview) is refused with `REQUEST_CLOSED` before anything is signed.

    The journal appends one `serialize_solana_onboarding_attempt` row per write and flushes it to disk before the SDK broadcasts; a rerun decodes the rows with `decode_solana_onboarding_attempt` and hands them back:

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    def persist(self, attempt: SolanaOnboardingAttempt) -> None:
        deployment = self.client.tokenomics20
        assert deployment is not None
        with self.path.open("a", encoding="utf-8") as handle:
            handle.write(serialize_solana_onboarding_attempt(attempt, deployment) + "\n")
            handle.flush()
            os.fsync(handle.fileno())
    ```

    Once `complete`, the script reads the onboarding back from the chains:

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    history = self.client.get_solana_onboarding_history(
        self.machine_id, timeout_seconds=HISTORY_TIMEOUT_SECONDS
    )
    ```

    Its output on production, 2026-10-06 (PEAQ): `[2/7] request: complete … nonce 1 · 24 s`, `[3/7] credit: complete … 121 s`, `[4/7] finalise: complete … 10 s`, `[5/7] bond: complete … 212 s`, `[6/7] native_onboarding: confirmed (native_creation_confirmed, new_attempt)`, `[7/7] linkage` with `link push quote: ready · transport trust_validator · messaging fee 0 · rent 1005840 · compute 120000 · total 1010840 lamports`, then `tv relay: … nonce 3 applied True` and `done`. Reference: [SDK Python: Solana](/peaqos/sdk-reference/sdk-python#solana).
  </Tab>

  <Tab title="JavaScript">
    Run it with the machine's values; every Solana write is signed by the OWS wallet `--owner-wallet` names:

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    node --env-file=<machine dir>/.env onboard-solana.mjs --dir <machine dir> --serial <serial> \
         --pay-in PEAQ --owner <base58 owner> --operator <0x operator> --owner-wallet <OWS wallet> \
         --manufacturer <base58> --machine-type <type> [--dry-run] [--wait-minutes 60] \
         [--tier 1] [--compute-unit-price-micro-lamports 10000]
    ```

    Save the whole script as `onboard-solana.mjs`, in the folder where you ran the `npm install` from [What you need](#what-you-need) (SDK 0.11.2 or newer). `--env-file` loads the machine directory's `.env`; export `OWS_PASSPHRASE` in your shell:

    <Accordion title="onboard-solana.mjs (the whole script)">
      ```javascript theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
      #!/usr/bin/env node
      /**
       * Onboard one machine terminal-first on Solana from the public JS API, one phase at a time,
       * resuming from a journal. It does whatever the SDK's status read says is next until the machine
       * is `complete`, so a second invocation after any stop continues and never re-sends.
       *
       *   node --env-file=<machine dir>/.env onboard-solana.mjs --dir <machine dir> --serial <serial> \
       *        --pay-in PEAQ --owner <base58 owner> --operator <0x operator> --owner-wallet <OWS wallet> \
       *        --manufacturer <base58> --machine-type <type> [--dry-run] [--wait-minutes 60] \
       *        [--tier 1] [--compute-unit-price-micro-lamports 10000]
       *
       * The machine dir holds `.env` (the CLI's keys), `did.json` (the DID document) and, after the
       * first write, `peaqos-js.log` (one serialized journal row per line). Everything the script signs
       * goes through the OWS vault wallet named by `--owner-wallet`, unlocked with OWS_PASSPHRASE.
       * Nothing on peaq is signed: `--owner` must already be registered to `--operator` (`[1/7]`, once
       * per owner wallet: `peaqos activate --phase registration` or `registerSolanaOperator`).
       *
       * Reads are keyless. `--dry-run` stops after them: the registration, the quote, the plan and the
       * link push quote (route, fee). Never prints an endpoint URL.
       */
      import { appendFileSync, existsSync, readFileSync } from "node:fs";
      import { join } from "node:path";
      import { randomBytes } from "node:crypto";
      import {
        PeaqosClient,
        decodeSolanaOnboardingAttempt,
        serializeSolanaOnboardingAttempt,
        solanaSignerFromWallet,
        TokenomicsPendingTransactionError,
      } from "@peaqos/peaq-os-sdk";

      // ---- arguments ---------------------------------------------------------------------------
      const args = Object.fromEntries(
        process.argv.slice(2).reduce((acc, item, i, all) => {
          if (item.startsWith("--"))
            acc.push([
              item.slice(2),
              all[i + 1]?.startsWith("--") || all[i + 1] === undefined ? true : all[i + 1],
            ]);
          return acc;
        }, []),
      );
      const need = (key) => {
        if (args[key] === undefined || args[key] === true) throw new Error(`--${key} is required`);
        return args[key];
      };
      const DIR = need("dir");
      const SERIAL = need("serial");
      const PAY_IN = need("pay-in");
      if (PAY_IN !== "PEAQ" && PAY_IN !== "USDC") throw new Error("--pay-in must be PEAQ or USDC");
      const DRY_RUN = args["dry-run"] === true;
      const WAIT_MINUTES = Number(args["wait-minutes"] ?? 60);
      const OWNER_WALLET = need("owner-wallet");
      const TIER = Number(args.tier ?? 1);
      // Micro-lamports per compute unit. At 0, Solana's public endpoint can drop a write under load, and
      // a journaled write is reconciled, never re-sent; 10,000 costs 6,000 lamports on the mint and 1,200
      // on the push.
      const PRICE = BigInt(args["compute-unit-price-micro-lamports"] ?? 10_000);
      // The history reads both chains; right after a run the public Solana endpoint still rate-limits,
      // and the SDK's 120 s default can leave sources unread. The CLI's `machine history` allows 900.
      const HISTORY_TIMEOUT_SECONDS = 600;

      // ---- the machine's inputs (never change these between invocations of one machine) ----------
      const OWNER = need("owner"); // the Solana owner's base58 public key
      const OPERATOR = need("operator"); // the peaq operator it is registered to
      const identity = {
        machineType: need("machine-type"),
        credentialSubject: "0x" + Buffer.from(SERIAL, "utf8").toString("hex"),
      };
      const did = JSON.parse(readFileSync(join(DIR, "did.json"), "utf8"));
      const inputs = {
        owner: OWNER,
        controller: OWNER,
        manufacturer: need("manufacturer"),
        machineType: identity.machineType,
        credentialSubject: identity.credentialSubject,
        verificationMethods: did.verificationMethods,
        authentication: did.authentication.map((i) => BigInt(i)),
        serviceEndpoints: did.serviceEndpoints,
        maxNativeFeeLamports: 50_000n,
        maxNativeRentLamports: 6_000_000n,
        evmOperator: OPERATOR,
      };
      const budget = { computeUnitLimit: 600_000, computeUnitPriceMicroLamports: PRICE };
      const terminalCaps = { maxNativeFeeLamports: 50_000n, maxNativeRentLamports: 6_000_000n };

      // ---- the client (the Python CLI's .env keys) ----------------------------------------------
      const env = (key) => {
        const value = process.env[key];
        if (!value) throw new Error(`${key} is not set in .env`);
        return value;
      };
      const deploymentId = env("TOKENOMICS_DEPLOYMENT_ID"); // peaq-mainnet
      const svmDeploymentId = deploymentId.replace(/^peaq-/, "solana-"); // solana-mainnet
      const client = new PeaqosClient({
        rpcUrl: env("PEAQOS_RPC_URL"),
        // Nothing is signed on peaq: the wallet is registered already. A throwaway key satisfies the
        // constructor; the registration below is read keyless and must name OPERATOR.
        privateKey: "0x" + randomBytes(32).toString("hex"),
        contracts: {
          identityRegistry: env("IDENTITY_REGISTRY_ADDRESS"),
          identityStaking: env("IDENTITY_STAKING_ADDRESS"),
          eventRegistry: env("EVENT_REGISTRY_ADDRESS"),
          machineNft: env("MACHINE_NFT_ADDRESS"),
          didRegistry: env("DID_REGISTRY_ADDRESS"),
          batchPrecompile: env("BATCH_PRECOMPILE_ADDRESS"),
        },
        tokenomics20: { deploymentId },
        svm: {
          deploymentId: svmDeploymentId,
          rpcUrl: env("PEAQOS_SVM_RPC_URL"),
          commitment: "finalized",
        },
      });
      const deployment = client.tokenomics20;

      // ---- the journal ---------------------------------------------------------------------------
      const JOURNAL = join(DIR, "peaqos-js.log");
      const loadRows = () =>
        existsSync(JOURNAL)
          ? readFileSync(JOURNAL, "utf8")
              .split("\n")
              .filter(Boolean)
              .map((line) => decodeSolanaOnboardingAttempt(line))
          : [];
      const persist = (row) => {
        appendFileSync(JOURNAL, serializeSolanaOnboardingAttempt(row, deployment) + "\n");
        log(`      ${row.phase} signed and journaled: ${short(row.signature ?? row.transactionHash)}`);
      };
      const terminalRows = (rows) =>
        rows.filter(
          (r) => !("schema" in r) && ["request", "finalise", "cancel_request"].includes(r.phase),
        );
      // A request the terminal has already closed (cancelled, refunded or closed) is history: a new
      // request opens the next nonce, so its rows are not handed on (handed on, the old row would be
      // reconciled again instead of a new request being opened).
      const CLOSED_REQUEST = ["cancelled_or_refunded", "closed"];
      const requestRows = (state) => {
        const terminal = state.observations?.terminal;
        const last = terminal?.terminal?.lastNonce;
        const all = terminalRows(rows);
        if (last === undefined || !CLOSED_REQUEST.includes(terminal.requestState)) return all;
        const current = all.filter((r) => BigInt(r.nonce) > BigInt(last));
        if (current.length < all.length)
          log(
            `      journal: request nonce ${last} is ${terminal.requestState} on the terminal; its ` +
              `${all.length - current.length} row(s) stay in the journal and the request opens nonce ${BigInt(last) + 1n}`,
          );
        return current;
      };

      // ---- helpers --------------------------------------------------------------------------------
      const stamp = () => new Date().toISOString().slice(11, 19);
      const log = (line) => console.log(`${stamp()}  ${line}`);
      const short = (s) =>
        typeof s === "string" && s.length > 20 ? `${s.slice(0, 10)}…${s.slice(-5)}` : String(s);
      const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
      const json = (v) => JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toString() : x));
      const STAGE = {
        registration: 1,
        request: 2,
        credit: 3,
        finalise: 4,
        bond: 5,
        native_onboarding: 6,
        linkage: 7,
      };
      const header = (phase, text) => log(`[${STAGE[phase]}/7] ${phase}: ${text}`);
      const deadline = Date.now() + WAIT_MINUTES * 60_000;
      const budgetLeft = () => Math.max(0, Math.floor((deadline - Date.now()) / 1000));

      let signer;
      const getSigner = async () => {
        if (signer === undefined) {
          const plan = await client.previewMachineActivation({ chain: "solana", inputs, ...budget });
          const required = client.getSolanaOnboardingSignerRequirements(
            inputs,
            plan,
            "native_onboarding",
            { candidateAddress: OWNER },
          );
          signer = await solanaSignerFromWallet(
            OWNER_WALLET,
            undefined,
            true,
            undefined,
            required.chainId,
          );
          client.validateSolanaOnboardingSigner(inputs, plan, "native_onboarding", signer);
          log(
            `      signer ${OWNER_WALLET} (${short(signer.address)}) unlocked for chain ${required.chainId}`,
          );
        }
        return signer;
      };

      // ---- keyless reads --------------------------------------------------------------------------
      log(
        `Solana onboarding (JS) · ${deploymentId} · serial ${SERIAL} · ${PAY_IN} · ${DRY_RUN ? "dry run" : "write"}`,
      );
      const registration = await client.getSolanaOperatorRegistration(OWNER);
      if (
        registration.state !== "bound" ||
        registration.boundOperator?.toLowerCase() !== OPERATOR.toLowerCase()
      ) {
        throw new Error(
          `registration is ${registration.state} (${registration.boundOperator}); this script signs nothing on peaq`,
        );
      }
      header("registration", `reconciled (bound to ${OPERATOR}; nothing was signed)`);

      // The machine id comes from the plan (keyless), never from the quote: once the request is bonded,
      // the terminal rightly refuses a fresh quote (MACHINE_ALREADY_ACTIVE), and a resume still needs the id.
      const plan0 = await client.previewMachineActivation({ chain: "solana", inputs, ...budget });
      const machineId = plan0.binding.machineId;
      log(`machine ${machineId}`);
      const quoteRequest = async () => {
        const quote = await client.quoteSolanaActivation(identity, {
          tier: TIER,
          payIn: PAY_IN,
          wallet: OWNER,
        });
        log(
          `quote: bond ${quote.bond.base} PEAQ base units · suggested escrow ${quote.maxIn} (${PAY_IN} base units) · ` +
            `wallet balance ${quote.walletBalance} · stamp age ${quote.priceAgeSecs} s`,
        );
        return quote.maxIn;
      };

      let rows = loadRows();
      log(`journal: ${rows.length} row(s) in ${JOURNAL.replace(process.cwd() + "/", "")}`);
      const status = async () =>
        client.getMachineActivationState(machineId, { chain: "solana", inputs, records: rows });

      if (DRY_RUN) {
        try {
          await quoteRequest();
        } catch (error) {
          log(`quote: ${error.code ?? error.name}: ${error.message}`);
        }
        for (const s of plan0.stages)
          log(
            `  stage ${s.phase}: ${s.action} (signer ${s.signer ?? "-"}, unresolved ${json(s.unresolved)})`,
          );
        try {
          const link = await client.quoteSolanaLinkPush(machineId, { payer: OWNER });
          log(
            `link push quote: ${link.status} · transport ${link.transport} · messaging fee ${link.nativeFeeLamports} · total ${link.totalLamports} lamports`,
          );
        } catch (error) {
          log(
            `link push quote: ${error.code ?? error.name}: ${error.message} (expected before the mint: MACHINE_NOT_ONBOARDED)`,
          );
        }
        const state = await status();
        log(`next step: ${state.nextStep.phase} ${state.nextStep.action} (actor ${state.nextActor})`);
        process.exit(0);
      }

      // ---- the run: do whatever the status read says is next, until complete --------------------
      const terminal = async (phase, extra = {}) => {
        const t0 = Date.now();
        header(phase, "…");
        const result = await client.activateMachine({
          chain: "solana",
          phase,
          identity,
          getSigner,
          records: terminalRows(rows),
          ...terminalCaps,
          computeUnitPriceMicroLamports: PRICE,
          waitSeconds:
            Math.min(budgetLeft(), phase === "credit" ? 180 : phase === "bond" ? 360 : 0) || undefined,
          onTransactionSubmitted: persist,
          onWaitEvent: (e) => log(`      ${e.message ?? e.kind}`),
          ...extra,
        });
        const took = Math.round((Date.now() - t0) / 1000);
        const ref = result.confirmation?.submitted?.signature ?? result.submitted?.signature;
        header(
          phase,
          `${result.status}${result.exit ? ` (${result.exit})` : ""}${ref ? ` ${short(ref)}` : ""} · request ${result.request?.state ?? "-"} nonce ${result.nonce} · ${took} s`,
        );
        const swap = result.settlement;
        if (swap !== undefined)
          log(
            `      settlement: swap simulated ${swap.simulatedInAmount} · cap sent ${swap.maxInAmount} · ` +
              `escrow ${swap.escrowAmount} (${PAY_IN} base units) · slippage ${swap.slippageBps} bps · ` +
              `lock in table ${swap.lockInTable}`,
          );
        if (result.status === "exited")
          throw new Error(
            `${phase} exited: ${result.exit} ${result.advice ?? ""} ${result.refund ? json(result.refund) : ""}`,
          );
        rows = loadRows();
        return result;
      };

      const native = async (phase) => {
        header(phase, "…");
        const plan = await client.previewMachineActivation({ chain: "solana", inputs, ...budget });
        await getSigner();
        if (phase === "linkage") {
          const link = await client.quoteSolanaLinkPush(machineId, { payer: OWNER });
          log(
            `      link push quote: ${link.status} · transport ${link.transport} · messaging fee ${link.nativeFeeLamports} · rent ${link.pushAccountRentLamports} · total ${link.totalLamports} lamports`,
          );
        }
        let result;
        try {
          result = await client.activateMachine({
            chain: "solana",
            phase,
            inputs,
            plan,
            records: rows,
            ...budget,
            getSigner,
            onTransactionSubmitted: persist,
            // No maxLinkPushFeeLamports: the route is the Trust Validator node's (no fee). On a LayerZero
            // route the SDK refuses before signing and names the cap to pass.
          });
        } catch (error) {
          if (error instanceof TokenomicsPendingTransactionError) {
            log(
              `      pending after signing: ${error.message}; the row is journaled, rerun to reconcile`,
            );
            rows = loadRows();
            return;
          }
          throw error;
        }
        const c = result.confirmation;
        header(
          phase,
          `${result.status} (${result.reason}, ${result.origin})${c?.submitted ? ` ${short(c.submitted.signature)}` : ""}`,
        );
        if (phase === "linkage" && c)
          log(
            `      transport ${c.transport ?? c.submitted?.transport} · TV nonce ${c.tvNonce ?? c.submitted?.tvNonce ?? "-"} · seq ${c.sequence ?? c.submitted?.sequence}`,
          );
        if (result.error) log(`      error: ${result.error.code}: ${result.error.message}`);
        rows = loadRows();
        if (result.status !== "confirmed")
          throw new Error(`${phase} ended ${result.status}: ${result.reason}`);
      };

      for (let guard = 0; guard < 40; guard++) {
        const state = await status();
        const next = state.nextStep.phase;
        if (next === "complete") {
          const relays = state.tvRelays ?? [];
          log(
            `complete: link applied; messages ${state.messages?.length ?? 0} (LayerZero), tv relays ${relays.length}`,
          );
          for (const r of relays)
            log(
              `      tv relay: ${short(r.signature)} dst ${r.dstEid} nonce ${r.nonce} applied ${r.applied}`,
            );
          break;
        }
        if (next === "cancel_request")
          throw new Error(
            "the request expired before the node credited it; nothing was settled. Wait for the node " +
              "to cancel it (the escrow and rents come back), or cancel it yourself with " +
              "`peaqos activate ... --phase cancel_request`; then rerun",
          );
        if (next === "registration")
          throw new Error("the status read wants a registration; this script signs nothing on peaq");
        if (next === "request") {
          const maxInAmount = await quoteRequest();
          await terminal("request", {
            tier: TIER,
            payIn: PAY_IN,
            maxInAmount,
            records: requestRows(state),
          });
        } else if (next === "credit" || next === "bond") await terminal(next);
        else if (next === "finalise") await terminal("finalise");
        else if (next === "native_onboarding") await native("native_onboarding");
        else if (next === "linkage") {
          if (state.nextStep.action === "observe") {
            if (budgetLeft() === 0)
              throw new Error(
                "PENDING: the link is not applied within --wait-minutes; rerun to keep waiting (nothing is resent)",
              );
            log(`      waiting for the node to deliver the link push on peaq (${budgetLeft()} s left)`);
            await sleep(15_000);
          } else await native("linkage");
        } else throw new Error(`unexpected next step ${json(state.nextStep)}`);
      }

      const history = await client.getSolanaOnboardingHistory(machineId, {
        timeoutSeconds: HISTORY_TIMEOUT_SECONDS,
      });
      log(`history: ${history.rows.length} rows, unread ${json(history.unread)}`);
      for (const r of history.rows)
        log(
          `  ${r.step.padEnd(18)} ${r.actor.padEnd(9)} ${r.chain.padEnd(6)} ${short(r.reference)}${r.reused ? " · reused" : ""}`,
        );
      for (const t of history.totals)
        log(
          `  total ${t.chain} ${short(t.payer)} lamports ${t.lamportsDelta ?? "-"} gas ${t.gasWei ?? "-"} tokens ${json(t.tokenDeltas ?? {})}`,
        );
      log(`done · machine ${machineId}`);
      ```
    </Accordion>

    The same shapes as Python, in camelCase with `bigint` amounts. The machine's inputs and the client, which signs nothing on peaq:

    ```javascript theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    const OWNER = need("owner"); // the Solana owner's base58 public key
    const OPERATOR = need("operator"); // the peaq operator it is registered to
    const identity = {
      machineType: need("machine-type"),
      credentialSubject: "0x" + Buffer.from(SERIAL, "utf8").toString("hex"),
    };
    const did = JSON.parse(readFileSync(join(DIR, "did.json"), "utf8"));
    const inputs = {
      owner: OWNER,
      controller: OWNER,
      manufacturer: need("manufacturer"),
      machineType: identity.machineType,
      credentialSubject: identity.credentialSubject,
      verificationMethods: did.verificationMethods,
      authentication: did.authentication.map((i) => BigInt(i)),
      serviceEndpoints: did.serviceEndpoints,
      maxNativeFeeLamports: 50_000n,
      maxNativeRentLamports: 6_000_000n,
      evmOperator: OPERATOR,
    };
    const budget = { computeUnitLimit: 600_000, computeUnitPriceMicroLamports: PRICE };
    const terminalCaps = { maxNativeFeeLamports: 50_000n, maxNativeRentLamports: 6_000_000n };
    ```

    ```javascript theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    const client = new PeaqosClient({
      rpcUrl: env("PEAQOS_RPC_URL"),
      // Nothing is signed on peaq: the wallet is registered already. A throwaway key satisfies the
      // constructor; the registration below is read keyless and must name OPERATOR.
      privateKey: "0x" + randomBytes(32).toString("hex"),
      contracts: {
        identityRegistry: env("IDENTITY_REGISTRY_ADDRESS"),
        identityStaking: env("IDENTITY_STAKING_ADDRESS"),
        eventRegistry: env("EVENT_REGISTRY_ADDRESS"),
        machineNft: env("MACHINE_NFT_ADDRESS"),
        didRegistry: env("DID_REGISTRY_ADDRESS"),
        batchPrecompile: env("BATCH_PRECOMPILE_ADDRESS"),
      },
      tokenomics20: { deploymentId },
      svm: {
        deploymentId: svmDeploymentId,
        rpcUrl: env("PEAQOS_SVM_RPC_URL"),
        commitment: "finalized",
      },
    });
    ```

    The run is one loop over the status read; the terminal stages and the native ones are both `activateMachine` with `chain: "solana"`:

    ```javascript theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    for (let guard = 0; guard < 40; guard++) {
      const state = await status();
      const next = state.nextStep.phase;
      if (next === "complete") {
        const relays = state.tvRelays ?? [];
        log(
          `complete: link applied; messages ${state.messages?.length ?? 0} (LayerZero), tv relays ${relays.length}`,
        );
        for (const r of relays)
          log(
            `      tv relay: ${short(r.signature)} dst ${r.dstEid} nonce ${r.nonce} applied ${r.applied}`,
          );
        break;
      }
      if (next === "cancel_request")
        throw new Error(
          "the request expired before the node credited it; nothing was settled. Wait for the node " +
            "to cancel it (the escrow and rents come back), or cancel it yourself with " +
            "`peaqos activate ... --phase cancel_request`; then rerun",
        );
      if (next === "registration")
        throw new Error("the status read wants a registration; this script signs nothing on peaq");
      if (next === "request") {
        const maxInAmount = await quoteRequest();
        await terminal("request", {
          tier: TIER,
          payIn: PAY_IN,
          maxInAmount,
          records: requestRows(state),
        });
      } else if (next === "credit" || next === "bond") await terminal(next);
      else if (next === "finalise") await terminal("finalise");
      else if (next === "native_onboarding") await native("native_onboarding");
      else if (next === "linkage") {
        if (state.nextStep.action === "observe") {
          if (budgetLeft() === 0)
            throw new Error(
              "PENDING: the link is not applied within --wait-minutes; rerun to keep waiting (nothing is resent)",
            );
          log(`      waiting for the node to deliver the link push on peaq (${budgetLeft()} s left)`);
          await sleep(15_000);
        } else await native("linkage");
      } else throw new Error(`unexpected next step ${json(state.nextStep)}`);
    }
    ```

    A terminal stage and a native stage:

    ```javascript theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    const result = await client.activateMachine({
      chain: "solana",
      phase,
      identity,
      getSigner,
      records: terminalRows(rows),
      ...terminalCaps,
      computeUnitPriceMicroLamports: PRICE,
      waitSeconds:
        Math.min(budgetLeft(), phase === "credit" ? 180 : phase === "bond" ? 360 : 0) || undefined,
      onTransactionSubmitted: persist,
      onWaitEvent: (e) => log(`      ${e.message ?? e.kind}`),
      ...extra,
    });
    ```

    ```javascript theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    result = await client.activateMachine({
      chain: "solana",
      phase,
      inputs,
      plan,
      records: rows,
      ...budget,
      getSigner,
      onTransactionSubmitted: persist,
      // No maxLinkPushFeeLamports: the route is the Trust Validator node's (no fee). On a LayerZero
      // route the SDK refuses before signing and names the cap to pass.
    });
    ```

    `budget.computeUnitLimit` (600,000) is the mint's compute limit, and the SDK uses an explicit `computeUnitLimit` as given. Since SDK 0.11.2 the plan carries `plan.native.suggestedComputeUnitLimit`: the limit sized from the mint's simulation (the simulated units × 1.3, rounded up to the next 1,000, at least 50,000; about 121,000 on production). Pass that value as `computeUnitLimit` instead of the constant to pay the fee actually needed, 6,210 lamports instead of 11,000 at the default price.

    The comment above `PRICE` says a journaled write is reconciled, never re-sent. Since 0.11.2 the SDK does re-send a write whose first send was lost: the same signed bytes, every 2 s while the signature is unknown and the blockhash valid (at most 40 times), inside the process that signed them, so the write can land at most once. `result.confirmation.sendCount` (`{ sends, resendErrors }`) says how many sends it took; it is `undefined` for a row this process did not send. A mint that ran out of compute and created nothing (receipt reason `compute_budget_exceeded`) is minted again on the next run, like a mint that never landed: the recovery's `failedWithoutEffect`, and `noEffect` (which also covers `never_landed`), admit it. The script's `requestRows` already keeps a closed request's rows out of a new request; since 0.11.2 the SDK's request phase reads the terminal record itself and opens a new request at the next nonce after a cancelled or refunded one. A phase run alone on such a request (`credit`, `bond`, `finalise`, `cancel_request`, or the settlement preview) is refused with `REQUEST_CLOSED` before anything is signed.

    The journal is one `serializeSolanaOnboardingAttempt` line per write, read back with `decodeSolanaOnboardingAttempt`:

    ```javascript theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    const persist = (row) => {
      appendFileSync(JOURNAL, serializeSolanaOnboardingAttempt(row, deployment) + "\n");
      log(`      ${row.phase} signed and journaled: ${short(row.signature ?? row.transactionHash)}`);
    };
    ```

    What it printed on production for the PEAQ run of 2026-10-06, trimmed:

    ```text theme={"theme":{"light":"github-light-default","dark":"github-dark"}}
    20:11:02  [1/7] registration: reconciled (bound to 0xf0Bb42C59928481cD9b7C2990dFFb034Eaf6E3DF; nothing was signed)
    20:11:05  quote: bond 4744535245 PEAQ base units · suggested escrow 4791980598 (PEAQ base units) · wallet balance 20095666879 · stamp age 7860 s
    20:11:29  [2/7] request: complete 2JBB6gyNaH…W3zCk · request - nonce 1 · 23 s
    20:13:01  [3/7] credit: complete · request committed nonce 1 · 91 s
    20:13:13  [4/7] finalise: complete 3DThGSJiY4…ZJaUV · request - nonce 1 · 11 s
    20:15:46  [5/7] bond: complete · request bonded nonce 1 · 152 s
    20:16:15  [6/7] native_onboarding: confirmed (native_creation_confirmed, new_attempt) 2NpBv2Ktbt…CBq9N
    20:16:20        link push quote: ready · transport trust_validator · messaging fee 0 · rent 1005840 · total 1010840 lamports
    20:16:42  [7/7] linkage: confirmed (link_push_sent, new_attempt) BaaQWcVCUj…p5Q7d
    20:16:42        transport trust_validator · TV nonce 5 · seq 1
    20:18:23  complete: link applied; messages 0 (LayerZero), tv relays 1
    20:18:23        tv relay: BaaQWcVCUj…p5Q7d dst 30302 nonce 5 applied true
    20:20:16  history: 11 rows, unread []
    20:20:16    total solana ARDJEqBCNe…Uydhv lamports -8045940 gas 0 tokens {"PEAQjk7SRS6rXHVFFmpRr7zrC4g5ZuEebpwTxvaLr3b":"-4744535245"}
    ```

    Reference: [SDK JS: Solana](/peaqos/sdk-reference/sdk-js#solana-svm).
  </Tab>
</Tabs>

## A link push over LayerZero

Production delivers the link push through the Trust Validator node. A deployment may instead route it over LayerZero, per message type; the SDK reads the route from the chain before every push, and `--phase linkage --dry-run` names it. On a LayerZero route the push pays a messaging fee (about 962,000 lamports when it was production's route) and needs a ceiling: `--max-link-push-fee-lamports` on the one-command run and on `--phase linkage` (`max_link_push_fee_lamports` / `maxLinkPushFeeLamports` in the SDKs). Without it the push is refused before signing (`INVALID_INPUT`, with the dry run as guidance); above it, `FEE_LIMIT_EXCEEDED`. LayerZero's Executor then delivers it on peaq, and `machine history` shows the delivery's actor as `layerzero` with a LayerZero Scan link.

## After onboarding

* **Manage the machine** with the same `peaqos machine` commands plus `--chain solana` (`status`, `transfer --unsafe`, `suspend`, `resume`, `did set-controller`, the DID setters, and the Solana-only `set-evm-operator`), or the same SDK methods; the SDK resolves the home chain from the machine ID and refuses the wrong chain. Transfer, controller and the peaq operator link need the Solana **owner** signature; DID lists, suspend and resume take owner or controller. There is no safe transfer on Solana (`transfer` requires `--unsafe`, `--data-hex` is refused), and `approve` / `approve-all` do not exist; the SDKs refuse `approveMachine` and `safeTransferMachine` for a Solana machine. Details: [SDK JS](/peaqos/sdk-reference/sdk-js#solana-svm), [SDK Python](/peaqos/sdk-reference/sdk-python#solana).
* **Submit events** with `submitEvent` / `submit_event` or `peaqos qualify event`: the SDK routes a Solana-homed machine to the Solana `EventRegistry` program and signs with the owner's or controller's OWS Solana signer (the peaq operator does not qualify). In Python set `client.solana_signer` before the call, or `submit_event` raises `SIGNER_UNAVAILABLE`; the CLI attaches it itself. The first event for a machine also pays rent for its event-log account.
* **Credit rating**: the 2.0 MCR scores a Solana-homed machine once its Solana indexer has the machine's events; until then the response carries `home_chain: 5` and a `rating_unavailable` reason. A freshly created machine reads `mcr: "Provisioned"`, `mcr_score: 0` and `rating_unavailable: "no_events_yet"`. See the [API reference](/peaqos/api-reference/overview#machines-and-ids).
* **Monetization opt-in** for a Solana-homed machine is refused by the CLI and both SDKs with `SOLANA_MONETIZATION_UNVERIFIED` before anything is signed. The owner can sign the opt-in directly against the [monetization endpoint](/peaqos/api-reference/put-machine-monetization). Rating reads and peaq-homed opt-in are unaffected.
* **Relocation** between peaq and Solana stays switched off on chain. Onboarding on Solana is not a relocation: the machine is created there.

## Related

* [Onboarding quickstart](/peaqos/guides/onboarding-quickstart): the same run from a blank terminal, wallets and funding included
* [Activate](/peaqos/functions/activate): the one-transaction flow on peaq
* [Omni-chain: Solana](/peaqos/concepts/omni-chain#solana): what is live on Solana
* [Smart contracts: Solana mainnet](/peaqos/concepts/contracts#solana-mainnet-addresses): the program IDs
* [CLI: activate](/peaqos/cli#peaqos-activate)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.