Version: 0.1.0
Chain: Avian Network (AVN), Avian Core 5.0.0
Format: BIP 174 (non-witness UTXO variant)
This document defines the PSBT-based workflow standard for Avian asset marketplace flows. It is intentionally kept separate from any specific app implementation so the same workflows can be used in:
- Web marketplace (this repo)
- Desktop wallet apps
- CLI tools
- Mobile apps
| Phase | Role | RPC Call |
|---|---|---|
| Creator | Proposes transaction structure | createpsbt, walletcreatefundedpsbt |
| Updater | Adds UTXO data for signing | utxoupdatepsbt |
| Signer | Adds partial signatures | walletprocesspsbt |
| Combiner | Merges partial PSBTs | combinepsbt |
| Finalizer | Converts partial sigs to final scriptSig | finalizepsbt |
| Extractor | Produces broadcast-ready raw tx | finalizepsbt (with extract=true) |
| Analyzer | Checks state and completeness | analyzepsbt, decodepsbt |
| Type | Meaning | Use case |
|---|---|---|
ALL |
Signs all inputs and outputs | Standard payment |
ALL|ANYONECANPAY |
Signs this input + all outputs; others can add inputs | General multi-party use |
SINGLE |
Signs this input + its paired output | Advanced cases |
SINGLE|FORKID|ANYONECANPAY |
Signs this input + its paired output (BIP143); others can add inputs/outputs | Marketplace listings — seller pre-signs asset input + payment output only |
FORKID is mandatory on Avian. Always use the
|FORKIDvariants explicitly (e.g.SINGLE|FORKID|ANYONECANPAY,ALL|FORKID). Omitting FORKID may cause the node to compute a legacy sighash preimage while still stamping the FORKID byte on the signature, producing a signature that fails verification at broadcast.
Seller lists an asset for a fixed AVN price. Buyer can atomically purchase without the seller needing to be online at purchase time.
- Seller — owns the asset UTXO, sets the price
- Buyer — has AVN UTXOs, wants the asset
- App — coordinates, never holds keys
1. Seller → App: POST /psbt/build/listing
Body: { assetName, assetAmount, sellerAddress, priceAvn,
assetUtxoTxid? (optional), assetUtxoVout? (optional) }
If assetUtxoTxid/Vout are omitted, the service auto-detects the UTXO
via listunspent on the node.
App: builds PSBT → utxoupdatepsbt (enrich with UTXO data) → decodepsbt
Returns: { psbtBase64, decoded }
PSBT structure:
Inputs: [seller's asset UTXO]
Outputs: [seller receives priceAvn AVN]
2. Seller: imports PSBT into their Avian wallet, reviews, signs with:
sighashType = "SINGLE|FORKID|ANYONECANPAY"
→ Seller's input[0] + output[0] (payment) are locked;
buyer can add their own inputs and outputs around them.
FORKID is mandatory on Avian — always specify it explicitly.
avian-cli walletprocesspsbt "<psbt>" true "SINGLE|FORKID|ANYONECANPAY" false
Use the psbt field from the result (not the original).
3. Seller → App: POST /listings
Body: { assetName, assetAmount, priceAvn, psbtBase64 (seller-signed) }
App validates PSBT is signed, stores listing as ACTIVE
4. Buyer: sees listing, decides to buy
5. Buyer → App: POST /psbt/decode
Body: { psbtBase64 }
App returns: { decoded, analyzed } — for UI display before the buyer signs
6. Buyer → App (direct purchase, no offer flow):
POST /psbt/submit
Body: { listingId, psbtBase64 (buyer-signed combined PSBT) }
App: validates → finalizepsbt → testmempoolaccept → sendrawtransaction
Returns: { txid }
7. Indexer: monitors for confirmations, updates listing status → SOLD
Inputs:
[0] seller's asset UTXO — signed by seller (SINGLE|FORKID|ANYONECANPAY)
[1] buyer's AVN UTXO(s) — signed by buyer (ALL|FORKID)
Outputs:
[0] seller receives priceAvn AVN (from seller's listing — MUST stay at index 0)
[1] buyer receives asset (added by buyer)
[2] buyer receives AVN change (added by buyer)
Important: The seller's
SINGLE|FORKID|ANYONECANPAYsignature commits to input[0] and output[0] specifically. Both MUST remain at index 0 in the combined transaction or the seller's signature will be invalid.
Buyer makes an offer on a listed asset. Seller reviews and accepts or rejects. On acceptance the buyer constructs the full transaction using the seller's pre-signed PSBT and their own funding UTXOs, then broadcasts.
Prerequisite: Workflow 2 requires a Workflow 1 listing to exist. The seller's SINGLE|FORKID|ANYONECANPAY-signed PSBT (stored in the listing record) is injected into the buyer's funding PSBT at
combine-psbttime. For the case where no listing exists yet, see Workflow 3 (Blind Offer) — the PSBT settlement path is identical once the seller accepts.
- Seller — owns the listed asset, reviews and accepts/rejects offers
- Buyer — has AVN UTXOs, submits a bid price
- App — coordinates PSBT construction, never holds keys
1. Buyer → App: POST /offers
Body: { listingId, offeredPriceAvn, ttlSeconds? }
App creates offer record (status: PENDING)
2. Seller: reviews offers via GET /offers/listing/:listingId
Seller → App: PATCH /offers/:id/accept
App: marks offer ACCEPTED, marks all other PENDING offers REJECTED,
marks listing SOLD
(Seller can also PATCH /offers/:id/reject to reject individual offers)
3. Buyer: is notified (WebSocket / polling)
Buyer → App: GET /offers/:id/funding-info
App returns: { sellerInputTxid, sellerInputVout, sellerInputSequence,
sellerAddress, priceAvn, assetName, assetAmount }
4. Buyer: constructs a funding PSBT on their own Avian node, seeding the
seller's UTXO at input[0] so the seller's SINGLE|FORKID|ANYONECANPAY sig stays valid:
avian-cli walletcreatefundedpsbt \
'[{"txid":"<sellerInputTxid>","vout":<sellerInputVout>,"sequence":<sellerInputSequence>}]' \
'[{"<sellerAddress>":<priceAvn>}]' \
0 '{"add_inputs":true,"fee_rate":2}'
5. Buyer → App: POST /offers/:id/combine-psbt
Body: { buyerFundingPsbt } (from step 4)
App: binary-injects the seller's partial signature + non_witness_utxo from
the stored listing PSBT into input[0] of the buyer's funded PSBT.
NOTE: avian-cli combinepsbt is NOT used — the two PSBTs have different
transaction structures (1-in/1-out vs N-in/M-out) and cannot be combined
with that RPC. A binary BIP174 injection is used instead.
Returns: { combinedPsbt }
6. Buyer: signs the combined PSBT (signs only their own inputs with ALL)
avian-cli walletprocesspsbt "<combinedPsbt>" true "ALL|FORKID" false
7. Buyer → App: POST /offers/:id/complete
Body: { signedPsbt } (buyer-signed combined PSBT from step 6)
App: finalizepsbt → sendrawtransaction
Returns: { txid }
App: marks offer COMPLETED, records txid
8. Indexer: monitors for confirmations
| Endpoint | Auth | Description |
|---|---|---|
GET /offers/my |
buyer | All offers made by authenticated buyer |
GET /offers/listing/:listingId |
public | Pending + accepted offers for a listing |
PATCH /offers/:id/reject |
seller | Reject a specific pending offer |
PATCH /offers/:id/withdraw |
buyer | Withdraw a pending offer |
A buyer can express intent to purchase a specific asset before any listing exists. The asset holder sees the bid, and if they accept they provide a signed PSBT. The server then atomically creates a Listing + an already-ACCEPTED Offer, so the buyer can complete the purchase immediately using the same steps as Workflow 2.
- Buyer — knows the asset name and amount they want; submits a price bid
- Seller (asset holder) — sees blind offers for assets they hold; provides signed PSBT to accept
- App — stores the blind offer, coordinates PSBT acceptance, never holds keys
1. Buyer → App: POST /blind-offers
Body: { assetName, assetAmount, offeredPriceAvn, ttlSeconds? }
App creates a BlindOffer record (status: PENDING)
No PSBT is involved at this stage.
2. Seller: discovers offers via GET /blind-offers/received
(Returns all PENDING blind offers for assets held by the authenticated user,
detected from their on-chain balance via listassetbalancesbyaddress.)
3. Seller decides to accept:
a. Seller builds a listing PSBT for the offered asset.
In Avian Core console:
# Find asset UTXO
listunspent 1 9999999 [] true {"assetName":"<ASSET>"}
# Create PSBT (seller's asset input → seller receives offered price)
createpsbt \
'[{"txid":"<txid>","vout":<vout>}]' \
'[{"<sellerAddress>":<offeredPriceAvn>},{"<sellerAddress>":{"transfer":{"<ASSET>":<amount>}}}]'
# Sign with SINGLE|FORKID|ANYONECANPAY
walletprocesspsbt "<psbt>" true "SINGLE|FORKID|ANYONECANPAY"
# Use the "psbt" field from the result.
b. Seller → App: POST /blind-offers/:id/accept
Body: { psbtBase64 } (signed PSBT from step 3a)
App:
- Validates PSBT is structurally sound and signed
- Creates a Listing (status: SOLD) with the seller's PSBT
- Creates an Offer (status: ACCEPTED) for the buyer
- Updates the BlindOffer (status: ACCEPTED, listingId, offerId)
Returns: { listingId, offerId }
4. Buyer: polls GET /blind-offers/my and detects status transition to ACCEPTED.
The response includes listingId and offerId.
5. Buyer → App: GET /offers/:id/funding-info
App returns: { sellerInputTxid, sellerInputVout, sellerInputSequence,
sellerAddress, priceAvn, assetName, assetAmount }
6–8. Buyer completes the purchase using Workflow 2 steps 4–8 (combine-psbt → sign → complete).
The PSBT flow is identical: the seller's SINGLE|FORKID|ANYONECANPAY sig is injected into
the buyer's funding PSBT via POST /offers/:id/combine-psbt, the buyer signs, and the
app finalizes and broadcasts.
| Endpoint | Auth | Description |
|---|---|---|
GET /blind-offers/my |
buyer | All blind offers submitted by the buyer |
GET /blind-offers/received |
seller | Pending blind offers for assets the caller holds |
GET /blind-offers/asset/:assetName |
public | Public pending blind offers for an asset |
PATCH /blind-offers/:id/withdraw |
buyer | Withdraw a pending blind offer |
PATCH /blind-offers/:id/reject |
seller | Reject a blind offer (validates seller holds the asset) |
| Workflow 2 | Workflow 3 | |
|---|---|---|
| Who creates the listing? | Seller (before any offer) | App (atomically on acceptance) |
| When does the seller sign the PSBT? | Before listing is created | When accepting the blind offer |
| Offer status on creation | PENDING | ACCEPTED (immediately) |
| Discovery path for sellers | Seller monitors their listing | Seller monitors /blind-offers/received |
Send an asset to another address without any AVN payment in return. The sender
pays only the network fee. After successful broadcast, the app records the gift
in the gifts table for activity reporting and history.
- Sender — owns the asset
- App — builds the PSBT and broadcasts, never holds keys
1. Sender → App: POST /psbt/build/gift
Body: { senderAddress, recipientAddress, assetName, assetAmount }
App:
- Finds asset UTXO via getaddressutxos (address index)
- Finds smallest AVN UTXO >= 0.001 AVN at the sender address for fee
- Builds bare PSBT via createpsbt
- Enriches with utxoupdatepsbt
Returns: { psbtBase64, decoded, fee }
PSBT structure:
Inputs:
[0] sender's asset UTXO (nValue = 0; carries only the asset)
[1] sender's AVN UTXO (for network fee)
Outputs:
[0] asset transfer to recipient (OP_AVN_ASSET)
[1] AVN change to sender (AVN input − 0.001 fee)
Implicit fee: 0.001 AVN (unaccounted AVN becomes miner fee)
2. Sender: signs the PSBT in Avian Core:
avian-cli walletprocesspsbt "<psbt>" true "ALL|FORKID"
Both inputs belong to the sender, so ALL|FORKID covers everything.
3. Sender → App: POST /psbt/submit/gift
Body: { psbtBase64 (signed), senderAddress, recipientAddress, assetName, assetAmount }
App: finalizepsbt → testmempoolaccept → sendrawtransaction → records Gift in DB
Returns: { txid }
| Marketplace (Workflows 1–3) | Gift (Workflow 4) | |
|---|---|---|
| Sighash | SINGLE|FORKID|ANYONECANPAY (seller) + ALL|FORKID (buyer) |
ALL|FORKID (sender only) |
| Validation | analyzepsbt + validateComplete() |
Skip analyzepsbt (miscalculates asset output fees); rely on finalizepsbt + testmempoolaccept |
| Inputs | Multi-party (seller asset + buyer AVN) | Single-party (sender asset + sender AVN) |
| Database records | Listing, Offer, PsbtRecord | Gift (sender, recipient, asset, amount, txid, fee) |
| Fee handling | Buyer pays via walletcreatefundedpsbt |
Explicit: AVN input − change = fee |
Not planned. All current workflows are fully atomic — asset and AVN swap in a single transaction, leaving no delivery window that escrow would protect against. Introducing escrow for only one side (e.g. AVN payment after asset transfer) would be strictly worse than the atomic approach. If scope expands to cross-chain trades or time-separated delivery this would need to be revisited.
An auction is a listing variant, not a separate PSBT workflow. The PSBT completion
path is identical to Workflow 2 — once the auction closes, the winner follows the
same funding-info → combine-psbt → complete steps.
What an auction adds over a standard listing:
auctionEndAtdeadline andminBidAvnfloor on the listing record- The
expiry-watcher(already in the indexer) auto-accepts the top PENDING offer whenauctionEndAtpasses, instead of the seller manually callingPATCH /offers/:id/accept - All other bid and settlement logic is reused as-is
No new PSBT workflow is required. Implementation is a scheduling + auto-accept concern scoped to the listings/offers services and the expiry-watcher.
- The app server never holds private keys
- PSBTs are stored server-side only for coordination — they are always incomplete until the user signs
- Always call
decodepsbtbefore signing to verify inputs/outputs - Always call
testmempoolacceptbeforesendrawtransaction - Listings should have a TTL/expiry to prevent stale PSBTs accumulating
- Offers support an optional
ttlSecondsfield; expired offers should be treated as WITHDRAWN SIGHASH_SINGLE|FORKID|ANYONECANPAYcommits to input[index] and output[index] (BIP143) — the seller's input and payment output must remain at index 0 in the buyer's funded PSBT- The binary PSBT injection used in
combine-psbtis intentional:combinepsbtrequires identical unsigned transactions, which is impossible when the buyer adds their own inputs/outputs - Direct wallet broadcast: If a buyer obtains the combined PSBT and broadcasts it directly from their own wallet (bypassing
POST /offers/:id/complete), the indexer will still detect the settlement on-chain — the seller's asset UTXO being spent triggersdetectOnChainSettlements()in the block poller, which marks the listingSOLDand any accepted offerCOMPLETED. However, if the broadcast happens with no offer record in the app (e.g. the buyer constructed and broadcast entirely outside the marketplace), the listing will be markedSOLDbut no offer or buyer notification will exist. This is a known limitation of the coordinator model: the app can observe settlement but cannot reconstruct intent it was never told about.
# Build listing PSBT (coordinator, unsigned)
avian-cli createpsbt '[{"txid":"<utxo>","vout":0}]' '[{"<sellerAddr>":50}]'
# Add UTXO data for external signing
avian-cli utxoupdatepsbt "<psbt_base64>"
# Seller signs (SINGLE|FORKID|ANYONECANPAY) — commits to input[0] + output[0] only
avian-cli walletprocesspsbt "<psbt_base64>" true "SINGLE|FORKID|ANYONECANPAY" false
# Buyer: build funding PSBT, seeding seller's UTXO at input[0]
avian-cli walletcreatefundedpsbt \
'[{"txid":"<sellerInputTxid>","vout":<sellerInputVout>,"sequence":<seq>}]' \
'[{"<sellerAddr>":<priceAvn>}]' \
0 '{"add_inputs":true,"fee_rate":2}'
# NOTE: Do NOT use combinepsbt — PSBTs have different structures.
# Instead, POST /offers/:id/combine-psbt to inject the seller's sig via the API.
# Buyer signs combined PSBT (ALL|FORKID — covers buyer's inputs only)
avian-cli walletprocesspsbt "<combined_psbt>" true "ALL|FORKID" false
# Finalize + extract
avian-cli finalizepsbt "<signed_psbt>" true
# Test before broadcast
avian-cli testmempoolaccept '["<hex_tx>"]'
# Broadcast
avian-cli sendrawtransaction "<hex_tx>"
# ─── Gift (Workflow 4) ────────────────────────────────────────────────────────
# Build gift PSBT — asset transfer + AVN fee input
# Asset UTXO has nValue=0; only the AVN UTXO contributes to fee budget
avian-cli createpsbt \
'[{"txid":"<assetUtxoTxid>","vout":0},{"txid":"<avnUtxoTxid>","vout":1}]' \
'[{"<recipientAddr>":{"transfer":{"<ASSET>":<amount>}}},{"<senderAddr>":0.999}]'
# Sender signs (ALL|FORKID — single party, both inputs belong to sender)
avian-cli walletprocesspsbt "<psbt>" true "ALL|FORKID"