Arxium

SDK walkthrough, part 3 of 3

Compliant transfers in your app

Check before you send, send, and turn a refusal into a message your user can act on.

This is the code a wallet, marketplace or fund portal runs when a user moves a regulated asset. The chain enforces the rules either way. Checking first means your user learns why before signing, not after.

Before you start

  • arxium.mjs and the asset from Issue an asset from code.
  • A second Console account to send to. Don't attest it yet. Export its key from its Settings too, as holder-key.json: the last step runs the script as that account.

1. The transfer script

transfer.mjs
import { ActionDroppedError, encodeTransferAsset } from "@arxiumlabs/sdk";import { me, rpc, send } from "./arxium.mjs"; const [asset, to, amount] = process.argv.slice(2); // What to tell your user for each sending-side reason. The keys are a stable RPC contract.const WHY = {  asset_frozen: "The issuer has paused this asset.",  holder_frozen: "The issuer has frozen your holding.",  missing_attestation: "Verify your identity before you can move this asset.",  missing_required_claim: "This asset needs a verification you don't have yet.",  attestation_expired: "Your verification has expired. Renew it to continue.",  jurisdiction_not_allowed: "This asset isn't available in your country.",  no_transferable_balance: "You have nothing you can send right now.",}; // 1. Check your side before asking the user to sign.const holding = (await rpc.assets(me.address)).find((a) => a.ref === asset);if (!holding?.transfer_eligible) {  console.log(WHY[holding?.eligibility_reason] ?? "You don't hold this asset.");  process.exit(1);} // 2. Send. The chain also checks the recipient, which only shows up as a refusal.try {  const { status } = await send(encodeTransferAsset(asset, to, BigInt(amount)));  console.log(`Sent ${amount} ${holding.symbol} at height ${status.height}`);} catch (error) {  if (!(error instanceof ActionDroppedError)) throw error;  console.log(`Refused: ${error.reason}`);  process.exitCode = 1;}

2. Try it

Send to the unattested account first. Your side passes the check, so the chain decides, and refuses:

Terminal
ASSET=arxasset1...    # from issue.mjsHOLDER=arx1...        # the second Console account node transfer.mjs $ASSET $HOLDER 100Refused: compliance check failed: arx1urfs… is not KYC'd/allowlisted

Click Get attested in the second account, then send again. The second send breaks the 150-unit cap set in part 2:

Terminal
node transfer.mjs $ASSET $HOLDER 100Sent 100 FUNDB at height 37 node transfer.mjs $ASSET $HOLDER 100Refused: arx1urfs… would hold 200 of arxasset1tgm4…, over the per-holder limit of 150

Now see the check that runs before signing. Freeze the holder (with the CLI, or encodeSetHolderFrozen from your issuer key), then run the script as the holder, sending back to you. It stops before anything is signed:

Terminal
arx send freeze-holder $ASSET $HOLDERARX_KEY=holder-key.json node transfer.mjs $ASSET arx1k29t... 10The issuer has frozen your holding.

3. How the two checks fit together

  • rpc.assets(address) reports transfer_eligible and eligibility_reason for the sending side only, because the recipient and nonce aren't known in advance. Use it to disable the send button and say why.
  • ActionDroppedError.reason covers everything else, recipient included: the node's exact text, such as a missing claim, a holder cap or a country restriction. Show it as is, or match on it.
  • New eligibility_reason values may be added, but existing ones are never renamed, so keep a fallback message like the ?? above. The full list of reasons and refusal messages is in llms.txt.