Skip to content
LogoLogo

Issuer service (arrekta-issuer)

Runs issued assets after issuance: investor registry, transfer restrictions, allocations and distributions, governed controls (freeze, clawback, rule changes), dividends, reserve attestations, and a public transparency log.

On Arrekta, compliance is enforced by co-signature and by consensus controls, never by contract code:

  • Restricted assets live only in managed addresses: 2-of-2 multisig locks over the holder's key and the asset's authorizer key.
    • The authorizer co-signs a transfer only when it passes the rules. This is the model Blockstream AMP uses, built here from standard Arrekta locks and PSTs.
    • Holders can't move the asset without the authorizer. The authorizer can't move it without the holder.
    • Compromising the authorizer key loses enforcement, not funds.
  • Freeze and clawback are consensus rules for assets issued with those flags (§7.3–7.4). The service decides when they may be used (officer quorum, time lock, reason code, evidence hash) and makes every use public.

Clients reach it through the gateway at /issuer/v1/* with an API key. GET /issuer/v1/public/* needs no key.

Setup

  1. Treasury. Create a wallet in the wallet service to hold the issuer's supply and the asset's control token. Issue the asset on chain with its supply and control token paid to a treasury address.
  2. Issuer. POST /issuers:
    {"name": "Example Capital", "officers": ["key_b"], "quorum": 2, "control_delay_secs": 86400, "treasury_wallet": "wal_…"}
    The caller becomes owner and first officer, and must own or co-sign the treasury wallet.
  3. Trusted claim issuers. POST /issuers/{id}/claim-issuers {"name", "public_key"}, owner only. Each claim issuer is an ML-DSA key, for example a KYC provider's.
  4. Asset. POST /issuers/{id}/assets:
    {"asset": "<hex or ticker>", "mode": "restricted", "rules": {…}, "metadata": {…ARRK-AMS-1…}}
    • Only the coin's issuer can register it, because wallets show registered coins as verified: the issuance transaction must have been paid from the issuer's treasury wallet, or the treasury must hold the coin's authority (mint) or control token (403 forbidden otherwise).
    • The metadata document must hash to the asset's on-chain metadata_hash (422 metadata-mismatch otherwise).
    • The response reports controls_ready: whether the control token is in the treasury.
    • Registration writes the first entry of the asset's public log, including its authorizer public key.

Investors and claims

  • Investors. POST /issuers/{id}/investors {"name", "external_ref"}.
  • Claims. POST /investors/{inv}/claims {"type", "value", "expires_at", "claim_issuer", "signature"}. The claim issuer signs, with context "ARRK-ISSUER-CLAIM-v1":
    TH("issuer-claim", varstr issuer_id ‖ varstr investor_id ‖ varstr type ‖ varstr value ‖ u64 expires_at)
    
    (expires_at is in unix seconds; 0 means never.) An unsigned or forged claim gets 403 bad-claim-signature.
  • Valid claims are unrevoked, unexpired, and issued by an active trusted claim issuer. DELETE /claims/{id} revokes a claim.
  • Holder addresses. POST /assets/{asset}/holders:
    • Restricted assets take {"investor_id", "public_key", "signature"}. The holder proves control by signing, with context "ARRK-ISSUER-HOLDER-v1":
      TH("issuer-holder", varstr asset_hex ‖ varstr investor_id ‖ varbytes public_key)
      
      The response is the managed address and its redeem policy ([holder, authorizer], m = 2).
    • Open assets take {"investor_id", "address"}.
  • Cap table. GET /assets/{asset}/holders returns live balances per investor and address.

Rules

{"receiver": [[{"claim": "kyc"}, {"jurisdiction_not_in": ["KP", "IR"]}],
              [{"claim": "kyc"}, {"claim": "accredited"}]],
 "sender":   [[{"claim": "kyc"}]],
 "max_holders": 2000, "max_balance": "1000000", "lockup_until_height": 120000}
  • Groups: a side passes when every condition in at least one group holds. No groups means any registered investor passes.
  • Conditions: {"claim": type}, {"jurisdiction_in": [...]}, {"jurisdiction_not_in": [...]}. Codes are ISO 3166-1 alpha-2. An investor without a jurisdiction claim never passes a jurisdiction condition.
  • Limits:
    • max_holders caps the number of investors holding a positive balance.
    • max_balance caps each holder's balance after the transfer.
    • lockup_until_height blocks holder-to-holder transfers until that height; issuer distributions are not affected.
  • Changing rules is a governed action (see Controls), and the change is public.
  • Dry run: POST /assets/{asset}/check {"to_investor", "amount", "from_investor"} returns {allowed, reasons}.

Holder transfers (restricted assets)

  1. The holder builds a PST spending its managed address, with redeem = its policy, and signs its slot.

  2. POST /assets/{asset}/authorize {"pst": "<base64>"}, with any API key (for example the holder's app).

  3. The authorizer checks the PST:

    • every non-clawback input holding the asset comes from a managed address;
    • every output of the asset goes to an active managed address, or back to an address of the issuer's own treasury wallet (a redemption: receiver rules don't apply to the issuer);
    • all asset inputs belong to one investor;
    • the rules hold for the sender and every recipient.

    PSTs that move only ARRK out of a managed address are approved; holders may need ARRK there for fees.

  4. If approved, the response carries the PST with the authorizer's signature. The holder finalizes it and broadcasts. Refusals return 403 transfer-not-compliant with the reasons. Every decision is recorded.

Allocations and distributions

  • Allocate. POST /assets/{asset}/assignments {"investor_id", "amount", "vesting_height"}. The receiver must already be eligible (422 not-eligible).
  • Distribute. POST /assets/{asset}/distributions pays every pending, vested assignment to the investor's latest active address. Payments go out in treasury-wallet transfers of up to 100 recipients. The treasury's co-signers sign them in the wallet service, whose policy also applies.
  • Settlement. A worker follows the wallet transfers: confirmed assignments become distributed, and failed ones return to pending. A finished distribution is written to the public log.

Controls: freeze, unfreeze, clawback, rules

  1. Propose. An officer proposes:
    {"kind": "clawback", "address": "…", "destination": "…", "reason_code": "court_order", "reference_hash": "<32-byte hex>", "note": "…"}
    • Reason codes: court_order, sanctions, fraud, lost_key_recovery, regulatory, error_correction, other (needs a note).
    • reference_hash is the hash of the evidence, such as a court order.
  2. Approve. Other officers approve (POST /actions/{id}/approve) until the issuer's quorum is met.
    • Rule changes apply immediately.
    • Freezes and unfreezes become executable.
    • Clawbacks become executable after control_delay_secs; until then execution returns 409 timelocked.
  3. Execute. POST /actions/{id}/execute builds a transaction that spends the control token and creates it again (so it never leaves the treasury). It carries the freeze record, or the clawed inputs plus an output to the destination. The transaction is imported into the treasury wallet as a PST, where the treasury's co-signers and policy apply.
  4. Log. Once confirmed, the action is executed and appears in the public log with its reason code, evidence hash, approval count and txid.

Lost-key recovery is a clawback from the old managed address to the investor's new one.

Dividends

  • Declare. POST /assets/{asset}/dividends {"record_height", "payout_asset": "ARRK", "total_amount"}.
    • The record height must be at least ARREKTA_ISSUER_RECORD_CONFIRMATIONS (default 6) blocks deep, and the indexer must have reached it.
    • Treasury addresses are excluded. Restricted assets count only registered addresses.
  • Allocation: largest-remainder method. Payouts sum to exactly the total, and ties go to the lowest address.
  • Signed table. The entitlement table is hashed and signed with the log key (context "ARRK-ISSUER-DIVIDEND-v1"), so any holder can check its line:
    TH("dividend", varstr asset ‖ u32 height ‖ varstr payout_asset ‖ u64 total ‖ varint n ‖ n × [varstr address ‖ u64 balance ‖ u64 amount])
    
  • Pay. POST /dividends/{id}/pay pushes the payments through the treasury wallet; the state goes paying → paid.

Reserve attestations

POST /assets/{asset}/attestations:

{"as_of", "total_reserves", "currency", "reserves": [...], "auditor", "report_url", "document_hash"}

The live circulating supply and chain height are recorded next to the report, and the entry goes into the public log.

Public transparency

Endpoint (no API key)Content
GET /public/assetsRegistered assets
GET /public/assets/{asset}Mode, rules, authorizer key, controls (officers, quorum, clawback delay, executed actions), supply, metadata hash, latest attestation, log head
GET /public/assets/{asset}/metadataThe verified ARRK-AMS-1 document
GET /public/assets/{asset}/logThe signed hash chain
GET /public/keysThe log key

Each log entry's hash is computed as follows:

hash = TH("issuer-log", prev_hash(32) ‖ u64 seq ‖ varstr kind ‖ varstr canonical_json(data) ‖ u64 at)

It is signed with context "ARRK-ISSUER-LOG-v1". A deleted or edited entry breaks the chain for anyone holding an earlier copy. all_svc/issuer_check.py verifies it independently in Python with OpenSSL.

Operations

  • Image and compose: image arrekta/arrekta-issuer:local (arrekta-compose.sh build issuer), compose service arrekta-issuer (profile platform, internal port 3006).
  • Database: ISSUER_DB_*.
  • Settings:
    • ARREKTA_ISSUER_SIGNING_SEED: signs the log, dividend tables and attestations.
    • ARREKTA_ISSUER_AUTHORIZER_SEED: root of the authorizer keys. Back it up. Without it, restricted assets can't move until they are clawed back to new addresses.
    • ARREKTA_ISSUER_RECORD_CONFIRMATIONS and ARREKTA_SERVICE_TOKEN.
  • Checks: core/.venv/bin/python all_svc/issuer_check.py (42 end-to-end checks).