Skip to content

Latest commit

 

History

77 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OpenAMP

Open-source issuer-governed assets for the Sequentia network: a self-hostable equivalent of Blockstream's AMP2. Issuers manage regulated assets (securities, funds, bonds) whose every transfer is co-signed by an issuer-controlled policy server, with registration/KYC whitelisting, categories, velocity limits, holder caps, lock-in and vesting, freezing, clawback under disclosed terms, ownership reports, and a hash-chained transparency log that can be anchored on-chain. It requires zero consensus changes: the daemon (openampd, written in Go) talks to an ordinary Sequentia node over JSON-RPC, and all enforcement lives in taproot script plus the policy server's signature.

This is testnet software. Everything here runs against the Sequentia public testnet (parent chain: Bitcoin testnet4); there is no mainnet.

Design document: doc/sequentia/openamp-design.md in the node repository. Frozen format specifications live under spec/.

The live instance, and what it serves

  • openampd runs on the Sequentia public testnet behind https://sequentiatestnet.com/openamp/ (REST API only, no web UI). It governs the demo restricted asset BONDX:

    curl -s https://sequentiatestnet.com/openamp/v1/assets
    

    The same instance also governs two network-enforced (OpenDAMP) assets, NEPLT and NEPL2 ("enforcement": "damp" in their contracts).

  • The daemon implements: registration, enclave addresses and balances, hosted transfers with fee conversion or sponsorship, raw co-signing of self-built transactions, hosted issuance (demo mode) with optional blinded minting, per-transfer opt-in confidentiality end to end (blinded or explicit transfers of any asset, mixed explicit+blinded enclave sets, watch-wallet unblinding), freezes, categories, per-asset rules (velocity, holder cap, lock-in, vesting), clawback, ownership reports, transparency log with on-chain anchoring, and a reorg-aware chain follower.

  • Also running on the live testnet: the FROST threshold backend for the policy key (-signer frost), with distributed key generation and a transport seam for running the quorum across hosts. Its members are separate roles in one process today, which is a deployment posture rather than a protocol limitation — see "Trust model" below.

Trust model

  • Enclave outputs. A restricted asset exists only in taproot outputs with a NUMS internal key (no key-path spend) and script leaves <K_user> CHECKSIGVERIFY <K_policy> CHECKSIG (transfer) and, if enabled at issuance, <K_issuer> CHECKSIGVERIFY <K_policy> CHECKSIG (clawback). Every spend therefore needs the policy server's signature in addition to the holder's: enforcement is server-side, not consensus-side.
  • The asset ID commits to the policy key. The issuance contract JSON (which names K_policy) is hashed into the asset's issuance entropy, so anyone holding the contract can verify offline that a given asset ID is governed by a given policy key. No registry state, no consensus lookup. Format: spec/contract-v1.md.
  • Policy key backend. On-chain, K_policy is always a single x-only key, so the signing backend is swappable without any chain migration. Two backends ship, selected with -signer: local (default; one software key per asset in a 0600 file) and frost (a 2-of-3 threshold Schnorr quorum whose group public key IS K_policy, so no address, asset id, or wallet changes when you switch). Under frost, no single share can sign, every partial is verified before aggregation, and assets issued under local keep working through a per-asset fallback. Keys are generated by a DISTRIBUTED key generation (Feldman VSS with a per-member proof of knowledge): every member samples its own polynomial and verifies the shares it is dealt against the sender's published commitments, so no component — dealer, coordinator or member — ever holds the group secret, and a member that misbehaves is named while the run aborts leaving no key material behind. The trusted-dealer split stays available via OPENAMPD_FROST_KEYGEN=dealer, which is how the pinned test vectors are reproduced. The quorum runs in process today — the members are separate roles sharing one host and one 0600 keys file, so the threshold protects against a signing-path bug rather than host compromise; moving members onto their own hosts means implementing the Member/Transport seam in openampd/internal/server/frostsigner/transport.go and touches no protocol code.
  • Confidentiality is opt-in per transfer. Sequentia is transparent by default with opt-in confidentiality, and OpenAMP follows: an asset is never "a confidential asset" — any holder may receive or move ANY restricted asset confidentially in a given transaction. POST /v1/transfers takes "confidential": true to blind that transfer's enclave outputs (amounts and asset tags hidden on-chain); GET /address?confidential=1 returns the blinded (blech32) enclave address; issuance and reissue accept "confidential": true meaning only "blind this mint transaction". An enclave can hold a mix of explicit and blinded coins, and every read (balances, holders, supply) unifies both views. The policy server derives and holds the per-(holder, asset) blinding keys in a node watch wallet, so the issuer sees and reports every holding and outside observers see nothing: confidentiality is privacy from outsiders only, never from the issuer. No confidential-transaction crypto is reimplemented: the node's rawblindrawtransaction/unblind machinery does the work, and the holder still only signs the returned sighashes.
  • Fees (Rule 1). A restricted asset never appears in a fee output, so it can never be swept into a block producer's coinbase. The policy server refuses to co-sign such a transaction, and Sequentia's default-deny fee-asset whitelist makes it non-paying at every producer regardless. Holders still pay costs in the restricted asset via fee conversion (the issuer takes a fee-equivalent slice into its own enclave and attaches the real fee in an ordinary asset, atomically in the same transaction), or the server sponsors the fee, or the sender self-pays in an ordinary asset with no issuer involvement.
  • Transparency log. Every registration, rule change, freeze, transfer approval, refusal, and clawback is appended to a hash-chained public log (GET /v1/log); clawbacks are logged before they are signed. The issuer can anchor the log head on-chain in an OP_RETURN (POST /v1/issuer/anchor).
  • Reorg awareness. Sequentia reorganizes whenever Bitcoin reorganizes (anchoring is supreme). The chain follower detects forks and re-marks transfer records above the fork point as unconfirmed, so velocity accounting and reports reflect only the surviving chain.

REST API

Base URL of the public testnet instance: https://sequentiatestnet.com/openamp (local default: http://127.0.0.1:8722). All bodies are JSON; unknown fields are rejected. Errors are {"error": "<message>"} with a meaningful HTTP status; policy refusals are HTTP 403 and are recorded in the transparency log.

Wallet surface (no authentication)

Method and path Purpose
POST /v1/users Register a pubkey set, returns the account ID (AID)
GET /v1/users/{aid} Registration status
GET /v1/users/{aid}/address?asset=<id> Enclave address and spend data for an asset (&confidential=1 for the blinded form)
GET /v1/users/{aid}/balance?asset=<id> Confirmed enclave balance
POST /v1/transfers Build a hosted transfer (fee conversion or sponsorship)
POST /v1/transfers/{id}/complete Submit the holder's signatures, co-sign and broadcast
POST /v1/cosign Co-sign a self-built transaction (fee self-paid)
GET /v1/assets All assets with their contracts
GET /v1/assets/{id} One asset
GET /v1/supply?asset=<id> Chain-derived circulating supply: {"asset", "circulating_atoms", "height"}
GET /v1/snapshots?asset=<id>[&seq=<n>] Published policy snapshots (network-enforced assets; latest, or one by seq)
GET /v1/log The transparency log (JSON lines)

POST /v1/users, body {"pubkeys": ["<x-only hex>", ...]}, response {"aid": "<40-char hex>"}. The AID is sha256("openamp-aid-v1" || sorted pubkey hex) truncated to 20 bytes. Registering the same key set twice returns the same AID. pubkeys[0] is the active enclave key.

GET /v1/users/{aid}/address?asset=<id> returns everything a wallet needs to receive and later spend from its enclave. With &confidential=1 the primary address is the blinded (blech32) form instead; either way both forms are returned, and either way the server imports the enclave script and its blinding key into the watch wallet so later blinded receipts to this enclave stay readable:

{
  "aid": "...", "asset": "...",
  "address": "<taproot address; blech32 when confidential=1>",
  "address_confidential": "<blech32 confidential address>",
  "confidential": false,
  "script_pubkey": "<hex>",
  "user_pubkey": "<x-only hex>",
  "transfer_leaf": "<leaf script hex>", "transfer_control": "<control block hex>",
  "claw_leaf": "<hex, only if the asset has clawback>", "claw_control": "<hex>"
}

GET /v1/users/{aid}/balance?asset=<id> returns {"aid", "asset", "atoms", "utxos"} from a confirmed UTXO-set scan.

POST /v1/transfers, body:

{"asset": "<id>", "sender_aid": "...", "recipient_aid": "...",
 "atoms": 1000, "fee_mode": "convert", "confidential": false}

confidential (default false) is the per-transfer privacy choice: true blinds this transaction's enclave outputs to the server-derived blinding keys, whatever the asset; false builds the asset legs explicit even when some of the sender's coins are blinded (spending a blinded coin then requires one blinded balancing output, which the server puts on its own fee change). fee_mode is "convert" (a fee-equivalent slice of the asset, rules.fee_convert_atoms, goes to the issuer's enclave and the server attaches the real fee) or "sponsor" (the server pays the fee and takes nothing). Self-paid transactions use POST /v1/cosign instead. Response:

{"id": "<transfer id>", "tx": "<unsigned tx hex>",
 "to_sign": [{"input": 0, "sighash": "<32-byte hex>", "pubkey": "<x-only hex>"}],
 "convert_atoms": 100, "fee_sats": 1000}

The wallet signs each sighash with BIP340 Schnorr under its enclave key. Pending transfers, burns and clawbacks expire after 72 hours.

POST /v1/transfers/{id}/complete, body {"sigs": {"<input index>": "<64-byte schnorr sig hex>"}}. The server verifies the holder's signatures, runs the policy engine, attaches the policy signatures and its own fee-input signature, broadcasts, and returns {"txid": "..."}. A policy refusal returns 403 with the reason.

POST /v1/cosign for self-built transactions (the sender attaches its own ordinary-asset fee). Self-built flows are transparent-only: a transaction containing blinded outputs, or claiming a blinded enclave coin as an input, is refused whatever the asset — per-transfer confidentiality lives in the hosted flow. Body:

{"tx": "<tx hex>", "asset": "<id>", "sender_aid": "...", "inputs": [0, 1]}

inputs are the indices spending the sender's enclave outputs. The server checks that each claimed input really is the sender's enclave output, that no unclaimed input spends this asset's enclaves, and runs the same policy engine. Response (the caller assembles the witnesses and broadcasts):

{"sigs": [{"input": 0, "sighash": "...", "policy_sig": "...",
           "leaf": "...", "control": "..."}]}

Witness stack for an enclave input, bottom to top: <policy sig> <user sig> <leaf script> <control block>.

Policy engine. Both approval paths re-derive everything from the transaction and the chain (request metadata is never trusted) and refuse when: the sender or a non-issuer recipient is frozen; any output's asset or value is blinded; the restricted asset appears in a fee output; an output pays the asset to a non-enclave script (OP_RETURN burns allowed only if the contract permits them); a recipient lacks a required category; the asset is inside its lock-in window (issuer-bound conversion exempt if configured); the sender's velocity window would be exceeded; the holder cap would be exceeded; or the sender's vested balance is insufficient.

Issuer surface (Authorization: Bearer <issuer token>)

Method and path Purpose
POST /v1/issuer/assets Issue a co-signed restricted asset (requires -demoissuer)
POST /v1/issuer/damp-assets Prepare a network-enforced (OpenDAMP) asset (requires -dampregistry)
POST /v1/issuer/damp-assets/{id}/complete Supply the covenant CMRs and mint it
POST /v1/issuer/damp-policy Prepare a policy update (freeze/unfreeze/admit) for a network-enforced asset
POST /v1/issuer/damp-policy/{id}/complete Supply the recompiled CMR, the issuer signature and the respend
POST /v1/issuer/freeze Freeze or unfreeze a user
POST /v1/issuer/categories Set a user's categories
POST /v1/issuer/rules Replace an asset's policy rules
POST /v1/issuer/clawback Claw back a holder's enclave UTXOs (phase 1 when the issuer key is external)
POST /v1/issuer/clawback/{id}/complete Phase 2: submit the issuer's signatures, co-sign and broadcast
POST /v1/issuer/burn Build a redeem burn of a holder's units to an OP_RETURN output (completed through POST /v1/transfers/{id}/complete)
POST /v1/issuer/reissue Mint more of an existing asset into a target enclave (idempotent by request_id)
POST /v1/issuer/snapshots Publish a signed policy snapshot (network-enforced assets)
POST /v1/issuer/pledges Lock a holder's units in place as loan collateral
GET /v1/issuer/pledges List pledges
POST /v1/issuer/pledges/{id}/release Debt settled: free the collateral (lender-signed)
POST /v1/issuer/pledges/{id}/seize Default: move the collateral to the lender (lender-signed)
GET /v1/issuer/holders?asset=<id> Ownership report
POST /v1/issuer/anchor Anchor the transparency-log head on-chain
POST /v1/issuer/rotate-blinding Rotate an asset's blinding-key epoch and re-import every holder's key
POST /v1/issuer/consolidate Sweep blinded fee-asset change into an explicit coin

If no issuer token is configured, every issuer request is rejected (401).

POST /v1/issuer/assets, body:

{"name": "OpenAMP Demo Bond", "ticker": "BONDX", "precision": 8,
 "atoms": 100000000000000, "holder_aid": "...", "issuer_aid": "...",
 "clawback": true, "burn_allowed": true,
 "rules": {"fee_convert_atoms": 100},
 "terms_hash": "<sha256 hex, optional>", "endpoint": "<base URL, optional>"}

Mints directly into the initial holder's enclave; clawback defaults to true and cannot be retrofitted either way. An optional "confidential": true blinds the mint transaction's outputs — it marks nothing about the asset (the contract never records it), and later transfers choose blinded or explicit independently. Response: {"asset", "token", "entropy", "txid", "contract", "contract_hash"}. Hosted issuance holds the issuer key server-side and therefore requires the -demoissuer flag (testnet demo only; a production issuer keeps that key offline).

Network-enforced issuance (OpenDAMP)

A second enforcement tier, elected per asset at issuance and committed into the asset id. Units of a network-enforced asset live in Simplicity user covenants C_U(X) and every transfer is policed on chain by a verifier covenant C_V(pi) the holder spends alongside their own coins. There is no co-signature and no enclave: transfers keep working with this policy server switched off, which is the tier's headline property. Protocol: doc/sequentia/opendamp-design.md; what the shipped covenants do and do NOT enforce: opendamp/STATUS.md — read it before describing a policy to an issuer. Every predicate in the design document — the whitelist (sender and recipient), the blacklist by outpoint, the transfer limit and the height windows — is consensus-enforced; velocity and holder caps remain registrar-side.

Configure it with -dampregistry <path> (or OPENAMPD_DAMP_REGISTRY), pointing at the CMR pinning file opendamp registry produces (opendamp/vectors/addresses.json is a valid input). Unset, every network-enforcement endpoint answers 501 {"error": "network enforcement is not configured on this policy server"}. Unlike hosted issuance this needs no -demoissuer: no issuer private key is held server-side, because issuer_update_key is the issuer's own key and this server never signs a transfer of the asset.

Why it takes two calls. Both covenant programs are parameterized at compile time — the user program by (asset, verifier asset, q) and the verifier program by pi — so their CMRs come from the SimplicityHL compiler in the Rust opendamp crate, which a Go daemon cannot run. And the dependency is circular: pi commits to the asset id, the asset id commits to the contract and to the funding outpoint this server chooses, so neither the CMRs nor pi can be computed by anyone before this server has picked that outpoint. Hence:

  1. POST /v1/issuer/damp-assets issues the verifier asset V, pins the funding outpoint, and therefore fixes the asset id, the contract bytes and pi_0. It broadcasts only the V issuance and returns a derive-ready snapshot.

    {"name": "Damp Bond", "ticker": "DBND", "precision": 2, "atoms": 500000,
     "holder_pubkey": "<64-hex x-only>",
     "whitelist": ["<64-hex x-only>", "..."],
     "verifier_amount": 1,
     "issuer_update_key": "<64-hex x-only>",
     "burn_allowed": false, "network": "testnet",
     "terms_hash": "<optional>", "entity_domain": "<optional>"}

    whitelist must contain holder_pubkey: the covenant checks the recipient of every regulated output, so a holder outside the list could never be paid, not even by their own change. verifier_amount (q) defaults to 1. Response: {"prepare_id", "asset", "contract", "contract_hash", "verifier_asset", "verifier_amount", "verifier_issue_txid", "pi", "whitelist_root", "tree", "snapshot", "snapshot_hash", "snapshot_sig_message", "derive_snapshot", "next"}.

  2. The operator runs the Rust toolchain on the returned document, which compiles the three programs and prints their CMRs, C_V(pi_0)'s scriptPubKey and its own pi:

    opendamp derive --snapshot derive_snapshot.json
  3. POST /v1/issuer/damp-assets/{prepare_id}/complete mints the asset straight into C_U(holder) and locks q of V into C_V(pi_0) in one transaction, so the asset never exists without its verifier output and therefore never moves unpoliced.

    {"user_cmr": "<u_cmr>", "verifier_cmr": "<p_cmr>", "issuer_cmr": "<g_cmr, optional>",
     "pi": "<the pi derive printed>", "verifier_spk": "<optional cross-check>",
     "snapshot_sig": "<optional BIP340 over snapshot_sig_message>"}

    pi is required and checked: this server recomputes it from the whitelist and asset id it stored and refuses (409, nothing broadcast) unless they agree. That is the authority split — this server stays authoritative about policy while the Rust toolchain stays authoritative about program compilation. A supplied verifier_spk is compared against the address derived from verifier_cmr, which catches a CMR pasted from the wrong policy before any coin moves. Response: {"asset", "verifier_asset", "txids": {"verifier_issue", "asset_issue", "verifier_lock"}, "pi", "whitelist_root", "user_covenant_address", "user_covenant_spk", "verifier_covenant_address", "verifier_covenant_spk", "contract", "contract_hash", "snapshot_seq", "snapshot_signed"}asset_issue and verifier_lock are the same transaction by construction.

The contract's openamp block gains "enforcement": "damp", verifier_asset, verifier_amount and issuer_update_key, all committed into the asset id, and clawback is always false (there is no clawback leaf; the issuer's powers are a policy update and a halt through the covenant's issuer path). It carries no genesis_policy and no genesis_snapshot_hash: pi_0 commits to the asset id, which commits to the contract, so a contract carrying pi_0 would have to contain a hash of itself. policy_pubkey is still issued even though nothing reads it, so the asset stays registerable and the asset-id shape stays uniform across both tiers.

The genesis snapshot is published at seq 0 through the ordinary snapshot store, so GET /v1/snapshots?asset=<id> serves it like any other. Its tree is dmt-v1, the format the deployed covenants actually verify against, and a dmt-v1 snapshot's pi is the covenant's own commitment (internal-order asset bytes, the covenant rules_root) It carries BOTH list predicates, because the covenant reads both: a whitelist root over recipient keys and a blacklist root over frozen outpoints. The genesis blacklist is empty but not absent — the empty interval tree still has a root (its guard interval), so "freeze nothing" is a commitment like any other and a missing root is a malformed policy rather than a permissive one. Blacklist entries are SHA256(txid || BE32(vout)) with the txid in internal (consensus) byte order, not the reversed display form. Later policy versions go through POST /v1/issuer/snapshots and require the issuer signature as always.

Transaction shape limits are part of the covenant. The verifier program is compiled once per transaction shape and every shape is a leaf of the same taptree: the canonical leaf is p3x5 (3 inputs, 5 outputs, one regulated input) and the menu runs through p3x4 and p4x6 to p5x7 (5 inputs, 7 outputs, three regulated inputs), so a single transfer may spend at most three UTXOs of the asset. That is a real operational constraint on a holder with fragmented coins, and it is committed into the program identity, so it cannot be widened for an asset already issued against it. The shapes and their bounds are pinned in opendamp/vectors/addresses.json under programs.verifier_shapes.

Policy updates: the freeze path for a network-enforced asset

A co-signed asset is frozen by withholding a signature. A network-enforced one has no signature to withhold, so a freeze is a publication plus a spend: publish snapshot seq n+1 whose whitelist drops the holder (the covenant checks the owner key of every regulated input) and/or whose blacklist lists an outpoint (that UTXO alone stops moving), then respend the verifier output through the issuer path G(I) so the on-chain C_V commits to the new pi. Until that respend confirms, holders transfer under the OLD policy, because the old C_V is what their transfers spend. Both directions are reversible by a further update.

This hits the same seam issuance does and resolves it the same way, so a policy update is also two phases.

  1. POST /v1/issuer/damp-policy takes a DELTA against the currently published policy, never a replacement set:

    {"asset": "<id>",
     "add_whitelist": ["<key>", {"key": "<key>", "send_after": 95200, "recv_after": 0}],
     "remove_whitelist": ["<key>"],
     "set_windows": [{"key": "<key>", "send_after": 95200, "recv_after": 0}],
     "add_blacklist": [{"txid": "<display txid>", "vout": 3}],
     "remove_blacklist": [{"txid": "<display txid>", "vout": 3}],
     "reason": "court order 2026-1188"}

    reason is required and is written to the public transparency log BEFORE anything is signed, exactly as a clawback's is. The endpoint recomputes both roots and pi_{n+1} from the snapshot chain, builds and validates snapshot n+1, records a pending update, and returns {"policy_id", "seq", "prev_pi", "pi_next", "whitelist", "blacklist", "change", "snapshot", "to_sign", "verifier_outpoint", "verifier_cmr_current", "derive_snapshot", "next"}. Nothing is signed, published or broadcast. Refusals are the requests that would publish a policy the issuer did not mean: an empty holder list, a duplicate addition, a removal of something not listed, a change that changes nothing.

  2. The issuer's toolchain runs opendamp derive --snapshot <derive_snapshot> for the new p_cmr and C_V(pi_{n+1}) address, and opendamp issuer-update --snapshot <current> --next-snapshot <derive_snapshot> --request <fee coin> --issuer-privkey <key> for the finished, signed respend. The issuer brings its own fee coin: this is the issuer's transaction, not the operator's.

  3. POST /v1/issuer/damp-policy/{id}/complete with {"sig", "verifier_cmr", "verifier_spk": "<optional>", "signed_tx"}. sig is the issuer update key's BIP340 signature over to_sign (the tagged snapshot hash), which is the same signature every published seq after 0 has always required, and it is checked FIRST. verifier_cmr must differ from the current one — an unchanged CMR means the derivation ran against the old policy — and a supplied verifier_spk must equal the address derived from it. signed_tx is checked structurally: it must consume the recorded verifier outpoint and recreate C_V at the new address carrying exactly q of the verifier asset. Only then does it run the mempool gate, broadcast, and publish seq n+1. Replaying a completed update returns the same txid with "idempotent": true and never broadcasts or publishes twice.

    The authority split is issuance's, unchanged: this server cannot compile Simplicity and cannot produce a G(I) witness, so it is authoritative about POLICY (both roots, pi, the sequence, the reason, the record) and accepts a program identity it cannot compile only alongside the checks it can make.

Height bounds live in the whitelist leaf. A whitelist entry is SHA256(0x00 || key || BE32(send_after) || BE32(recv_after)): send_after is the lockup binding the OWNER of a regulated input, recv_after the receive window binding the RECIPIENT of a regulated output, and zero means unbounded. In the snapshot document an unbounded entry is a bare hex string and a bounded one an object, so every document written before bounds existed still hashes and verifies exactly as it did. Computing a whitelist root from keys alone would publish a commitment that omits every bound, which is a pi no deployed program answers to.

On a network-enforced asset, every co-signing path — POST /v1/transfers, POST /v1/cosign, /v1/issuer/burn, /v1/issuer/reissue, /v1/issuer/clawback — answers 409 {"error": "this asset is network-enforced: transfers are built by the holder from the published snapshot, not co-signed here"}. The read paths stay useful instead of reporting an empty enclave: GET /v1/users/{aid}/address returns the holder's covenant address (no leaves, and ?confidential=1 is refused because the covenant must read explicit asset ids), and balance, holders and supply are scanned at the covenant scripts.

Rules object (co-signed tier only; used at issuance and by POST /v1/issuer/rules with body {"asset": "<id>", "rules": {...}}):

{"allowed_categories": ["accredited"],
 "velocity_window_blocks": 1000, "velocity_max_atoms": 500000,
 "holder_cap": 50,
 "lockin_until_height": 12000, "convert_during_lockin": true,
 "vesting": [{"aid": "...", "atoms": 1000000, "until_height": 20000}],
 "fee_convert_atoms": 100}

All fields optional; zero/empty means no restriction.

POST /v1/issuer/freeze: {"aid": "...", "frozen": true}. POST /v1/issuer/categories: {"aid": "...", "categories": ["accredited"]}.

POST /v1/issuer/clawback: {"asset": "<id>", "holder_aid": "...", "reason": "<required>"}. The reason is written to the public transparency log before the transaction is signed; the seized funds move to the issuer's enclave through the disclosed clawback leaf. Fails for assets issued without a clawback leaf. Response: {"txid", "atoms"} when the server holds the issuer key (-demoissuer). For an asset whose issuer key is external (every asset issued through SeqPal) the call is phase 1 and returns {"id", "tx", "to_sign": [{"input", "sighash", "pubkey"}], "atoms"}; the issuer signs each sighash with its entity key and posts {"sigs": {"<input index>": "<sig hex>"}} to POST /v1/issuer/clawback/{id}/complete, which adds the policy signature, broadcasts and returns {"txid"} (a replay returns the same txid with "idempotent": true).

Pledges (collateral). A restricted asset cannot be moved into a lending covenant without leaving its enclave, so collateral for a loan is locked in place: POST /v1/issuer/pledges {"asset", "holder_aid", "lender_aid", "atoms", "debt_asset", "debt_atoms", "maturity_height", "note"} records the pledge and the policy engine keeps those atoms unspendable by the holder. The issuer token alone cannot move value: /release {"repaid_txid", "lender_sig"} needs the lender's signature (or "force": true with a logged reason, which only ever returns the collateral to its owner), and /seize {"reason", "lender_sig", "holder_sig"} needs the lender's signature plus either a matured loan or the holder's countersignature. Signatures are BIP340 over sha256("openamp-pledge|<action>|<id>|<extra>"), never a taproot sighash. A seizure is a clawback-leaf spend paying the pledged atoms to the lender's enclave and the rest back to the holder; with an external issuer key it is two-phase like a clawback and completes through POST /v1/issuer/clawback/{id}/complete.

GET /v1/issuer/holders?asset=<id> returns {"asset", "height", "holders": {"<aid>": atoms}, "total_atoms"} from a confirmed UTXO-set scan.

POST /v1/issuer/anchor (empty body) commits OPENAMP:<seq>:<log head hash> in an OP_RETURN and returns {"txid", "seq", "head"}.

Transparency log

GET /v1/log serves the raw log: one JSON object per line, {"seq", "prev", "time", "action", "data", "hash"}, where hash = sha256("<seq>|<prev>|<time>|<action>|<data-json>") and prev is the previous entry's hash. Any client can re-verify the chain and compare its head against the latest on-chain anchor.

Note on contract history: BONDX was issued before the contract-v1 freeze, so its on-chain contract JSON carries a legacy "tier": "A" field. Assets issued while confidentiality was per-asset carry a "confidential" key in their openamp block; new issuances never write one (confidentiality is per transfer). Verify every contract as-is over its exact committed bytes; new issuances follow spec/contract-v1.md.

End-to-end walkthrough

Against your own openampd (see "Build, run, test" and deploy/DEPLOY.md); the live instance works the same for the unauthenticated endpoints. keygen and signer are demo helpers from this repository.

# 0. demo keys (name, private key, x-only pubkey) for issuer, alice, bob
go run ./openampd/cmd/keygen
# issuer 7f3a... 8fcd...
# alice  92b1... 55aa...
# bob    0cc4... 77ee...

# 1. register the three accounts (returns AIDs)
curl -s localhost:8722/v1/users -d '{"pubkeys":["<issuer-xonly>"]}'
curl -s localhost:8722/v1/users -d '{"pubkeys":["<alice-xonly>"]}'
curl -s localhost:8722/v1/users -d '{"pubkeys":["<bob-xonly>"]}'

# 2. issuer mints 1,000,000.00000000 BONDX into alice's enclave
curl -s localhost:8722/v1/issuer/assets \
  -H "Authorization: Bearer $ISSUER_TOKEN" \
  -d '{"name":"OpenAMP Demo Bond","ticker":"BONDX","precision":8,
       "atoms":100000000000000,"holder_aid":"<alice-aid>",
       "issuer_aid":"<issuer-aid>","burn_allowed":true,
       "rules":{"fee_convert_atoms":100}}'
# -> {"asset":"<asset-id>", "txid":..., "contract":..., "contract_hash":...}

# 3. alice's enclave address and balance for the new asset
curl -s "localhost:8722/v1/users/<alice-aid>/address?asset=<asset-id>"
curl -s "localhost:8722/v1/users/<alice-aid>/balance?asset=<asset-id>"

# 4. alice sends 5000 atoms to bob, fee converted from the asset
curl -s localhost:8722/v1/transfers \
  -d '{"asset":"<asset-id>","sender_aid":"<alice-aid>",
       "recipient_aid":"<bob-aid>","atoms":5000,"fee_mode":"convert"}'
# -> {"id":"<tid>", "to_sign":[{"input":0,"sighash":"<h>", ...}], ...}

# 5. alice signs each sighash with her enclave key and completes
go run ./openampd/cmd/signer <alice-priv> <h>       # -> <64-byte sig hex>
curl -s localhost:8722/v1/transfers/<tid>/complete \
  -d '{"sigs":{"0":"<sig-hex>"}}'
# -> {"txid":"..."}   (or 403 with the policy refusal reason)

# 6. the decision trail
curl -s localhost:8722/v1/log

The same flow, driven programmatically against a regtest node, is the committed integration proof test/functional/feature_openamp_daemon.py in the node repository.

How openampd uses the Sequentia node

openampd needs one Sequentia node (sequentiad) with a funded wallet and nothing else; there are no consensus changes and no patched node. An explorer (electrs) instance is optional: it is only the prevout fallback for POST /v1/cosign when the node runs without -txindex. RPC usage:

  • chain queries: getblockcount, getblockhash, getblock (follower), gettxout (prevout resolution), scantxoutset (enclave balances and holder reports), decodescript (address rendering), dumpassetlabels (fee-asset default);
  • wallet operations: listunspent, getnewaddress, getaddressinfo, signrawtransactionwithwallet, sendrawtransaction, and createrawtransaction + fundrawtransaction for log anchors.

Enclave transactions are built by openampd's own minimal Elements codec (openampd/internal/elements): explicit assets and values end to end, Elements taproot with the /elements tagged hashes and leaf version 0xc4, and the Elements taproot sighash. Byte-exactness is enforced by golden vectors generated from the node repository's functional-test framework. Issuance entropy and asset/token IDs are derived in openampd/internal/fastmerkle (the Elements two-leaf fast merkle root).

The node pays and accepts fees per Sequentia's open fee market: openampd attaches its fees in one configurable ordinary asset (-feeasset, defaulting to the chain's policy asset), which must be on the producers' accepted-fee-asset whitelist.

Build, run, test

Requires Go 1.26+ (dependencies are vendored, so builds work offline).

git clone https://github.com/ConcatenaLabs/openamp.git
cd openamp
go build ./...
go test ./...
go build -o openampd/openampd ./openampd/cmd/openampd

Run against a node:

./openampd/openampd \
  -rpc http://127.0.0.1:18776 \
  -rpcauth user:pass \            # or cookie:/path/to/.cookie
  -rpcwallet mywallet \
  -issuertoken <long-random-token> \
  -feeasset <display hex of the fee asset> \
  -demoissuer                     # testnet demos only: issuer keys server-side
  -signer frost                   # 2-of-3 threshold policy key (default: local)
Flag Default Meaning
-listen 127.0.0.1:8722 HTTP listen address
-datadir ~/.openampd state directory (state.json, keys.json 0600, transparency.log)
-rpc http://127.0.0.1:18776 sequentiad RPC URL (the node's RPC port on chain test)
-rpcauth (required) user:pass or cookie:<path>
-rpcwallet (none) wallet name, appended as /wallet/<name>
-issuertoken (none) bearer token gating /v1/issuer/*; empty locks the issuer API
-feeasset chain policy asset display hex of the ordinary asset openampd pays fees in
-feesats 1000 flat fee attached to server-funded transactions, in fee-asset atoms
-demoissuer off hold issuer keys server-side (testnet demo only)
-signer local (OPENAMPD_SIGNER) policy-key backend: local (one key per asset) or frost (2-of-3 threshold quorum, DKG-generated keys; OPENAMPD_FROST_KEYGEN=dealer reverts to the trusted-dealer split)
-dampregistry (none) path to the OpenDAMP CMR pinning file (OPENAMPD_DAMP_REGISTRY); unset disables network enforcement
-electrs http://127.0.0.1:3003 (OPENAMPD_ELECTRS_URL) explorer (electrs) base URL; prevout fallback for /v1/cosign when the node lacks -txindex
-follow 2s chain follower poll interval

Production deployment (systemd unit, Caddy reverse proxy, secrets handling): deploy/DEPLOY.md.

Repository layout

openampd/cmd/openampd/      the daemon (flags, wiring, chain follower start)
openampd/cmd/keygen/        demo BIP340 keypair generator
openampd/cmd/signer/        demo client-side sighash signer
openampd/cmd/seqpald/       the superseded M0 SeqPal gateway (see below)
openampd/internal/server/   HTTP API, policy engine, issuance, transfers,
                            clawback, pledges, snapshots, PolicySigner seam,
                            chain follower
openampd/internal/server/frostsigner/  the FROST 2-of-3 policy-key backend
                            (DKG, signing, Member/Transport seam)
openampd/internal/damp/     OpenDAMP policy commitment (pi), dmt-v1 tree,
                            snapshot document format
openampd/internal/elements/ minimal Elements tx codec, taproot, sighash
                            (golden-vectored; see tools/gen_vectors.py)
openampd/internal/fastmerkle/  issuance entropy and asset/token id derivation
openampd/internal/rpc/      minimal JSON-RPC client for sequentiad
openampd/internal/store/    JSON state store, 0600 key file, transparency log
openampd/docs/              design notes (blinding-key rotation, the M2
                            snapshot service)
opendamp/                   the Rust crate: SimplicityHL covenant programs
                            (programs/*.simf), the opendamp CLI, the regtest
                            proof (tests/regtest.rs), the CMR pinning file
                            (vectors/addresses.json) and a Go mirror of the
                            dmt-v1 tree (gomirror/); start with
                            opendamp/README.md
spec/                       frozen formats (contract v1) and the venue/wallet
                            integration specification
deploy/                     systemd units + deploy runbook
tools/gen_vectors.py        golden-vector generator (runs against the node
                            repo's functional-test framework)
vendor/                     vendored Go dependencies (offline builds)

openampd/cmd/seqpald/ and deploy/seqpald.service are the original M0 SeqPal gateway (four routes, port 8724) and are superseded: the seqpald that runs today lives in the SeqPal repository (its own database, escrow and ~80 routes, port 8730; see seqpald/DEPLOY.md there). They are kept for the record and are not what the box runs.

Regenerating the golden vectors (needs a checkout of the node repository):

PYTHONPATH=$SEQ_REPO/test/functional python3 tools/gen_vectors.py \
  > openampd/internal/elements/testdata/vectors.json
go test ./openampd/internal/elements

Integration proofs

  • test/functional/feature_openamp_m0.py (node repo): the M0 proof; demonstrates on regtest that enclave issuance, policy-co-signed transfer, clawback, and the contract-to-asset-ID binding all work against unmodified consensus.
  • test/functional/feature_openamp_daemon.py (node repo): end-to-end integration of a real openampd process with a regtest node, covering the hosted-transfer, rules, freeze, and clawback flows.
  • openampd/internal/elements/testdata/vectors.json: golden vectors proving the Go Elements primitives byte-exact against the node's test framework.

Ecosystem

Repo One-liner
Sequentia The Sequentia node (Sequentia Core, sequentiad; a fork of Elements 23.3.3): consensus, anchoring, proof of stake, open fee market, plus the canonical protocol documentation in doc/sequentia/.
sequentia-registry Sequentia Asset Registry service (asset metadata).
SWK Sequentia Wallet Kit: a fork of Blockstream LWK, providing a Rust wallet library, CLI, and WASM bindings for building Sequentia (and Bitcoin testnet4) wallets.
ambra Ambra: non-custodial dual-chain (Bitcoin testnet4 + Sequentia) mobile wallet, a Flutter UI over a Rust core built on SWK.

Contributing

Pull requests against main. Run go build ./... && go test ./... before submitting; if you touch openampd/internal/elements, regenerate or extend the golden vectors rather than weakening the tests. Secrets (RPC credentials, issuer tokens, keys) never belong in this repository.

About

OpenAMP: open-source restricted-asset issuance and transfer-approval service for Sequentia (an AMP2 equivalent) with opt-in confidentiality. Live API at sequentiatestnet.com/openamp/v1/

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages