From ada281e93dccaa99606a4456afc036273188e52a Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 5 Oct 2026 15:25:33 +0200 Subject: [PATCH] docs(guides): Composing UTXOs guide in all four languages Restore the spend-and-recreate order-update guide and expand the consensus note into a full allowed/forbidden list (exactly one order operation input and one CreateOrder output per transaction; a second command input or order output, spending a CreateOrder output as a UTXO input, and creating an order without a UTXO input are all rejected). - go + python: extend the note, document the 0x00/0x01 input-UTXOs byte format passed to encode_witness - javascript: new guide using the raw wasm-lib encoders plus MintlayerApiProvider for reads - rust: new guide using the native crypto module (gated on the order-signing re-exports in rust-sdk#5) - cross-links from both trading-with-orders guides and the guides index --- docs/guides/go/index.md | 1 + docs/guides/go/staking.md | 2 +- docs/guides/go/trading-with-orders.md | 2 + docs/guides/go/utxo-composition.md | 171 ++++++++++++++ docs/guides/index.md | 2 +- docs/guides/javascript/index.md | 1 + docs/guides/javascript/trading-with-orders.md | 2 + docs/guides/javascript/utxo-composition.md | 208 ++++++++++++++++++ docs/guides/python/index.md | 1 + docs/guides/python/trading-with-orders.md | 2 +- docs/guides/python/utxo-composition.md | 161 ++++++++++++++ docs/guides/rust/index.md | 1 + docs/guides/rust/trading-with-orders.md | 2 +- docs/guides/rust/utxo-composition.md | 196 +++++++++++++++++ 14 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 docs/guides/go/utxo-composition.md create mode 100644 docs/guides/javascript/utxo-composition.md create mode 100644 docs/guides/python/utxo-composition.md create mode 100644 docs/guides/rust/utxo-composition.md diff --git a/docs/guides/go/index.md b/docs/guides/go/index.md index a10941f..e00936a 100644 --- a/docs/guides/go/index.md +++ b/docs/guides/go/index.md @@ -19,3 +19,4 @@ The wallet-cli versions of the same workflows live in the [command-line guides]( | [Atomic swap](atomic-swap.md) | The Mintlayer side of a BTC ⇄ ML swap with HTLCs | | [Wallet & signing (WASM)](wallet-wasm.md) | Generate wallets, derive addresses, sign without a daemon | | [Forging custom transactions](custom-transactions.md) | Mixed inputs/outputs, protocol fees, multi-party signing, intents | +| [Composing UTXOs](utxo-composition.md) | Update an order atomically: conclude input + create output | diff --git a/docs/guides/go/staking.md b/docs/guides/go/staking.md index b1eb54f..f12d544 100644 --- a/docs/guides/go/staking.md +++ b/docs/guides/go/staking.md @@ -119,4 +119,4 @@ To estimate rewards: pool block stats for a time range, the pool's `cost_per_blo ## Manual transaction building -The `wasm` package encodes delegation transactions without the wallet daemon: `EncodeOutputCreateDelegation`, `EncodeOutputDelegateStaking`, `EncodeInputForWithdrawFromDelegation` (nonce from the indexer's `NextNonce`). See [Go SDK: Staking](../../build/sdks/go/staking.md) for the full low-level flow. +The `wasm` package encodes delegation transactions without the wallet daemon: `EncodeOutputCreateDelegation`, `EncodeOutputDelegateStaking`, `EncodeInputForWithdrawFromDelegation` (nonce from the indexer's `NextNonce`). See [Go SDK: Staking](../../build/sdks/go/staking.md) for the full low-level flow, and [Composing UTXOs](utxo-composition.md) for the spend-and-recreate composition pattern. diff --git a/docs/guides/go/trading-with-orders.md b/docs/guides/go/trading-with-orders.md index ab815c3..5f1a7b5 100644 --- a/docs/guides/go/trading-with-orders.md +++ b/docs/guides/go/trading-with-orders.md @@ -130,3 +130,5 @@ freezeInput, err := c.EncodeInputForFreezeOrder(orderID, currentBlockHeight, min ``` Concluding returns the unclaimed `give` remainder plus any accumulated `ask` balance to the order's conclude destination. Only the maker can freeze or conclude. + +To **update** an existing order (new price or amounts), compose a transaction that consumes the conclude-order input and re-creates the order: see [Composing UTXOs](utxo-composition.md). diff --git a/docs/guides/go/utxo-composition.md b/docs/guides/go/utxo-composition.md new file mode 100644 index 0000000..760b7aa --- /dev/null +++ b/docs/guides/go/utxo-composition.md @@ -0,0 +1,171 @@ +--- +title: "Composing UTXOs: Updating an Order" +description: "How to compose UTXOs on Mintlayer: update a DEX order atomically by consuming its conclude-order input and re-creating it with a create-order output." +sidebar_position: 9 +--- + +# Composing UTXOs: Updating an Order + +Mintlayer is a UTXO chain: **there is no mutable state**. A DEX order is an unspent output, and every protocol object behaves the same way, so "updating" an order means spending it and re-creating it *in the same transaction*. This atomic spend-and-recreate pattern is the essence of UTXO composition. + +## The pattern + +An order lives as a `CreateOrder` output holding the `give` balance plus any accumulated `ask` balance. Its spend authority is the maker, gated by the order's **nonce**. To update the order (new price, new amounts), build one transaction that: + +1. consumes the order via a **conclude-order input** (this releases the whole order balance), and +2. re-locks the funds into a fresh **create-order output** with the new parameters. + +```mermaid +flowchart LR + OLD["Order UTXO
(give 100, ask 0)"] -->|"conclude-order input
(nonce n)"| TX["Transaction"] + FEE["Coin UTXO
(fee payer)"] --> TX + TX -->|"create-order output
(give 100, ask 20)"| NEW["New order UTXO
(new order id)"] + TX -->|"change"| MAKER["Maker address"] +``` + +Two consequences worth internalizing: + +- The **new order gets a new ID** (IDs are derived from inputs, see [predicting IDs](custom-transactions.md#predicting-ids)). Publish the new ID and retire the old one. +- If the transaction fails or is dropped, nothing changed: the old order is still live and its nonce unspent. + +:::note[What consensus allows and forbids] + +The pattern above is exactly what consensus permits: **one order operation input** (fill, conclude, or freeze) — a slot shared with token account commands, so you cannot combine an order operation with a mint, an unmint, or any other command input — and **one `CreateOrder` output** per transaction. Concluding and re-creating in the same transaction sits precisely at both limits, which is what makes the atomic update work. + +Everything past those limits is rejected by the verifier: + +- A second command input (order operation or token account command) — `MultipleAccountCommands`. +- A second `CreateOrder` output — `MultipleOrdersCreated`. +- Spending a `CreateOrder` output as a UTXO input — orders are not UTXOs; they are consumed only through order operation inputs (`InvalidInputTypeInTx`). +- Creating an order in a transaction with no UTXO input — the new order's id is derived from the first UTXO outpoint (`NoUtxoInputsForOrderIdCreation`), which is why the fee input below is not optional. + +::: + +## The ingredients + +| Piece | Encoder | Source of truth | +| ----- | ------- | --------------- | +| Conclude input | `EncodeInputForConcludeOrder(orderID, nonce, currentBlockHeight, network)` | `Nonce` from the indexer's order record | +| New order output | `EncodeCreateOrderOutput(askAmount, askTokenID, giveAmount, giveTokenID, concludeAddress, network)` | Your new price/amounts | +| Surplus / change outputs | `EncodeOutputTransfer` / `EncodeOutputTokenTransfer` | Old balance minus new give amount | +| Fee input | `EncodeInputForUtxo` | A coin UTXO you own | +| Witness for the conclude input | `EncodeWitness` with `TxAdditionalInfo` | Order balances from the indexer | +| Witness for the coin input | `EncodeWitness` | n/a | + +## Fetch the order state + +```go +import "github.com/mintlayer/go-sdk/indexer" + +idxClient := indexer.New("http://127.0.0.1:3000") + +order, err := idxClient.GetOrder(ctx, oldOrderID) +if err != nil { + log.Fatal(err) +} +fmt.Printf("give: %s ask: %s nonce: %d\n", + order.GiveBalance.Atoms(), order.AskBalance.Atoms(), order.Nonce) +``` + +The `AskBalance` matters: any asks accumulated from takers are part of the order you are concluding, so the new order (or a change output) must account for them. + +## Build the transaction + +Example: top up the `give` side and raise the ask. Old order: give 100 tokens / ask 10 coins. New order: give 150 / ask 12. The conclude releases 100 tokens + accumulated asks; a separate token UTXO adds the extra 50. + +```go +// 1. Consume the old order +concludeInput, err := c.EncodeInputForConcludeOrder( + oldOrderID, order.Nonce, tip.BlockHeight, mintlayer.Mainnet) + +// 2. Fee payer +srcID, err := c.EncodeOutpointSourceId(feeTxIDBytes, mintlayer.SourceTransaction) +feeInput, err := c.EncodeInputForUtxo(srcID, 0) + +// 3. Re-create the order with updated parameters +var giveToken, askToken *string +newOrderOutput, err := c.EncodeCreateOrderOutput( + mintlayer.NewAmount("12"), askToken, // ask 12 coins (nil token id = coin) + mintlayer.NewAmount("150"), giveToken, // give 150 tokens + concludeAddress, // same conclude destination + mintlayer.Mainnet, +) + +// 4. Any balance not re-locked returns via change outputs +changeOutput, err := c.EncodeOutputTransfer( + mintlayer.NewAmount("2000000000"), makerAddr, mintlayer.Mainnet) // fee change + +tx, err := c.EncodeTransaction( + append(concludeInput, feeInput...), + append(newOrderOutput, changeOutput...), + 0, +) +``` + +Token inputs (e.g. a UTXO holding the additional 50 tokens) encode with `EncodeInputForUtxo` like any other spend; only the *outputs* distinguish coin from token value. + +## Sign it + +The conclude input's sighash requires the order's balances via `TxAdditionalInfo`: this is the part that differs from a plain transfer. Signing is also where the inputs' backing UTXOs come in: `EncodeWitness` takes one entry per input, `0x00` for non-UTXO inputs (the conclude input) and `0x01` plus the encoded output for UTXO inputs (the fee input): + +```go +feeOutput, err := c.EncodeOutputTransfer( + feeAmount, makerAddr, mintlayer.Mainnet, // re-encode the fee UTXO's Transfer output +) +allUtxoBytes := append([]byte{0x00, 0x01}, feeOutput...) + +additionalInfo := mintlayer.TxAdditionalInfo{ + OrderInfo: map[string]mintlayer.OrderInfo{ + oldOrderID: { + InitiallyAsked: order.InitiallyAsked, + InitiallyGiven: order.InitiallyGiven, + AskBalance: order.AskBalance, + GiveBalance: order.GiveBalance, + }, + }, +} + +var witnessBytes []byte +for i := 0; i < 2; i++ { // one witness per input, in input order + w, err := c.EncodeWitness( + mintlayer.SigHashAll, + makerKey, + makerAddr, + tx, + allUtxoBytes, + uint32(i), + additionalInfo, // required for the order input + tip.BlockHeight, + mintlayer.Mainnet, + ) + if err != nil { + log.Fatal(err) + } + witnessBytes = append(witnessBytes, w...) +} + +signedTx, err := c.EncodeSignedTransaction(tx, witnessBytes) +``` + +Submit via the indexer or node as usual, then predict and store the new order ID: + +```go +newOrderID, err := c.GetOrderId(append(concludeInput, feeInput...), mintlayer.Mainnet) +``` + +## Variations of the same pattern + +The conclude-then-recreate transaction is one instance of a general tool. With the same encoders you can: + +- **Cancel an order**: conclude input, no create-order output; the balance flows to change outputs. +- **Self-fill and requote**: conclude input + `FillOrder`-style split: partially fill your own order and re-create a smaller one. (A pure fill by a taker uses `EncodeInputForFillOrder` and needs **no signature** for that input, `EncodeWitnessNoSignature`.) +- **Freeze before restructuring**: `EncodeInputForFreezeOrder` stops takers while you prepare the replacement transaction. +- **Chain protocol objects**: the same spend-and-recreate logic composes delegations (withdraw input + `EncodeOutputDelegateStaking` output) and any future output type. + +:::note + +Exactly which balances must be re-assigned to which outputs is enforced by consensus, not by the encoders, the runtime tells you *if* a transaction is invalid, the indexer tells you *what* the balances are, and the composition is yours to design. + +::: + +For the order model itself (give/ask, conclude key, freeze semantics), see [Trading with Orders](trading-with-orders.md) and the wallet-cli guide [Trading with orders](../cli/trading-with-orders.md). diff --git a/docs/guides/index.md b/docs/guides/index.md index ec78c86..ef3886f 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -30,4 +30,4 @@ New languages can be added alongside these; the workflows are the same, only the | Manage a multisig address | [guide](cli/managing-a-multisig-address.md) | CLI only | CLI only | CLI only | CLI only | | Migrate ERC-20 ML to mainnet | [guide](cli/migrate-erc20-token-to-mainnet.md) | Portal only | Portal only | Portal only | Portal only | -Deeper full-custody dives with the embedded WASM runtime (Go and Python, no wallet daemon): [Wallet & signing](go/wallet-wasm.md) and [Forging custom transactions](go/custom-transactions.md), each also available in the [Python guides](python/index.md). The Rust equivalent is the native `crypto` module, covered by [Cryptography](../build/sdks/rust/crypto.md) and [Building transactions](../build/sdks/rust/transactions.md). +Deeper full-custody dives with the embedded WASM runtime (Go and Python, no wallet daemon): [Wallet & signing](go/wallet-wasm.md), [Forging custom transactions](go/custom-transactions.md), and [Composing UTXOs](go/utxo-composition.md), each also available in the [Python guides](python/index.md). Composing UTXOs also exists in [JavaScript](javascript/utxo-composition.md) (raw `wasm-lib` encoders) and [Rust](rust/utxo-composition.md) (native `crypto` module, covered by [Cryptography](../build/sdks/rust/crypto.md) and [Building transactions](../build/sdks/rust/transactions.md)). diff --git a/docs/guides/javascript/index.md b/docs/guides/javascript/index.md index 040e946..44f2758 100644 --- a/docs/guides/javascript/index.md +++ b/docs/guides/javascript/index.md @@ -16,4 +16,5 @@ The wallet-cli versions of the same workflows live in the [command-line guides]( | [Issue an NFT](issue-nft.md) | MLS-03 issuance and transfers | | [Staking and delegation](staking.md) | Create a delegation, stake, withdraw | | [Trading with orders](trading-with-orders.md) | On-chain orders: create, discover, fill, conclude | +| [Composing UTXOs](utxo-composition.md) | Update an order atomically: conclude input + create output | | [Atomic swap](atomic-swap.md) | The Mintlayer side of a BTC ⇄ ML swap with HTLCs | diff --git a/docs/guides/javascript/trading-with-orders.md b/docs/guides/javascript/trading-with-orders.md index 3a88d96..5252893 100644 --- a/docs/guides/javascript/trading-with-orders.md +++ b/docs/guides/javascript/trading-with-orders.md @@ -59,6 +59,8 @@ The order creator can close an order and reclaim the remaining balance: const signedTx = await client.concludeOrder('ord1...'); ``` +To **update** an existing order (new price or amounts), compose a transaction that consumes the conclude-order input and re-creates the order: see [Composing UTXOs](utxo-composition.md). + ## Example: market-maker loop ```ts diff --git a/docs/guides/javascript/utxo-composition.md b/docs/guides/javascript/utxo-composition.md new file mode 100644 index 0000000..bc1cc6d --- /dev/null +++ b/docs/guides/javascript/utxo-composition.md @@ -0,0 +1,208 @@ +--- +title: "Composing UTXOs: Updating an Order" +description: "How to compose UTXOs in JavaScript: update a DEX order atomically by consuming its conclude-order input and re-creating it with a create-order output." +sidebar_position: 9 +--- + +# Composing UTXOs: Updating an Order + +Mintlayer is a UTXO chain: **there is no mutable state**. A DEX order is an unspent output, and every protocol object behaves the same way, so "updating" an order means spending it and re-creating it *in the same transaction*. This atomic spend-and-recreate pattern is the essence of UTXO composition. + +This guide uses the low-level encoders from `@mintlayer/wasm-lib` — the same WebAssembly module the [JavaScript SDK](../../build/sdks/javascript/index.md) is built on — plus `MintlayerApiProvider` for reading chain state. The same workflow in Go and Python lives in the [Go](../go/utxo-composition.md) and [Python](../python/utxo-composition.md) guides. + +## The pattern + +A maker wants to update an existing order's conditions: lower the `ask` from 10 coins to 12 coins, raise the `give` from 100 tokens to 150 tokens. The transaction must contain: + +```mermaid +flowchart LR + C["Conclude order input
(consumes the old order)"] --> N["Transaction"] + F["Fee input
(UTXO paying the fee)"] --> N + N -->|"create-order output
(give 150, ask 12)"| O["New order"] + N -->|"change"| R["Maker address"] +``` + +All four effects happen atomically. If the transaction fails, the old order is untouched. + +:::note[What consensus allows and forbids] + +The pattern above is exactly what consensus permits: **one order operation input** (fill, conclude, or freeze) — a slot shared with token account commands, so you cannot combine an order operation with a mint, an unmint, or any other command input — and **one `CreateOrder` output** per transaction. Concluding and re-creating in the same transaction sits precisely at both limits, which is what makes the atomic update work. + +Everything past those limits is rejected by the verifier: + +- A second command input (order operation or token account command) — `MultipleAccountCommands`. +- A second `CreateOrder` output — `MultipleOrdersCreated`. +- Spending a `CreateOrder` output as a UTXO input — orders are not UTXOs; they are consumed only through order operation inputs (`InvalidInputTypeInTx`). +- Creating an order in a transaction with no UTXO input — the new order's id is derived from the first UTXO outpoint (`NoUtxoInputsForOrderIdCreation`), which is why the fee input below is not optional. + +::: + +## The ingredients + +| Piece | Encoder | Source of truth | +| ----- | ------- | --------------- | +| Conclude input | `encode_input_for_conclude_order(orderId, nonce, currentBlockHeight, network)` | `nonce` from the indexer's order record | +| New order output | `encode_create_order_output(askAmount, askTokenId, giveAmount, giveTokenId, concludeAddress, network)` | the new conditions | +| Surplus / change outputs | `encode_output_transfer` (coins) / `encode_output_token_transfer` (tokens) | whatever is not re-locked | +| Fee input | `encode_input_for_utxo` with `encode_outpoint_source_id` | a coin UTXO you own | +| Witness for the conclude input | `encode_witness` with a `TxAdditionalInfo` | the order's balances | +| Witness for the coin input | `encode_witness` | the fee UTXO's output | +| New order id | `get_order_id(inputs, network)` | predicted from the inputs | + +## Fetch the order state + +```ts +import { MintlayerApiProvider } from '@mintlayer/sdk'; + +const api = new MintlayerApiProvider('http://127.0.0.1:3000', 'http://127.0.0.1:3000'); // the second URL is only used for batched queries + +const oldOrderId = 'ord1...'; // the order to update +const order = await api.getOrder(oldOrderId); +const tip = await api.getChainTip(); +console.log(`give: ${order.give_balance.atoms} ask: ${order.ask_balance.atoms} nonce: ${order.nonce}`); +``` + +Balances come back as `{atoms, decimal}` string pairs — always compose with atoms. `initially_asked` / `initially_given` capture what the maker originally promised; the sighash for the conclude input is bound to them, so keep them exactly as returned. + +## Build the transaction + +```ts +import initWasm, { + Amount, + encode_create_order_output, + encode_input_for_conclude_order, + encode_input_for_utxo, + encode_outpoint_source_id, + encode_output_transfer, + encode_transaction, + Network, + SourceId, +} from '@mintlayer/wasm-lib'; + +await initWasm(); // required once, before any encoder call + +const network = Network.Mainnet; +const concatBytes = (...parts: Uint8Array[]) => { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +}; + +// 1. Consume the old order +const concludeInput = encode_input_for_conclude_order( + oldOrderId, + BigInt(order.nonce), + BigInt(tip.block_height) + 1n, // predicted inclusion height (tip + 1) + network, +); + +// 2. Fee payer: a coin UTXO you own +const feeTxIdBytes = Uint8Array.from(feeTxId.match(/.{2}/g)!.map((b) => parseInt(b, 16))); +const feeInput = encode_input_for_utxo( + encode_outpoint_source_id(feeTxIdBytes, SourceId.Transaction), + 0, +); + +// 3. Re-create the order with updated parameters +const newOrderOutput = encode_create_order_output( + Amount.from_atoms('12000000000000'), // ask 12 coins + null, // null token id = coin + Amount.from_atoms('15000000000'), // give 150 tokens + giveTokenId, + order.conclude_destination, // where the concluded leftovers return + network, +); + +// 4. Any balance not re-locked returns via change outputs +const changeOutput = encode_output_transfer( + Amount.from_atoms('2000000000'), + makerAddr, + network, +); + +const tx = encode_transaction( + concatBytes(concludeInput, feeInput), + concatBytes(newOrderOutput, changeOutput), + 0n, +); +``` + +If the fee UTXO came from a token transfer instead of a coin transfer, the fee input still needs a **coin** UTXO: fees are always paid in the base coin. Keep a separate small coin UTXO for fees and use the change output to sweep back anything you do not want to re-lock. + +## Sign it + +The conclude input's sighash requires the order's balances via `TxAdditionalInfo`: this is the part that differs from a plain transfer. Signing is also where the inputs' backing UTXOs come in: `encode_witness` takes one entry per input, `0x00` for non-UTXO inputs (the conclude input) and `0x01` plus the encoded output for UTXO inputs (the fee input): + +```ts +import { + encode_witness, + encode_signed_transaction, + encode_output_transfer, + make_private_key, + SignatureHashType, + type TxAdditionalInfo, +} from '@mintlayer/wasm-lib'; + +const makerKeyBytes = make_private_key(); // 32 random bytes — load yours instead +const feeOutput = encode_output_transfer(feeAmountAtoms, makerAddr, network); // re-encode the fee UTXO's output +const inputUtxos = concatBytes(new Uint8Array([0x00, 0x01]), feeOutput); + +const additionalInfo: TxAdditionalInfo = { + pool_info: {}, + order_info: { + [oldOrderId]: { + initially_asked: { coins: { atoms: order.initially_asked.atoms } }, + initially_given: { tokens: { token_id: giveTokenId, amount: { atoms: order.initially_given.atoms } } }, + ask_balance: { atoms: order.ask_balance.atoms }, + give_balance: { atoms: order.give_balance.atoms }, + }, + }, +}; + +let witnessBytes = new Uint8Array(); +for (let i = 0; i < 2; i++) { // one witness per input, in input order + witnessBytes = concatBytes( + witnessBytes, + encode_witness( + SignatureHashType.ALL, + makerKeyBytes, + makerAddr, + tx, + inputUtxos, + i, + additionalInfo, // required for the conclude input + BigInt(tip.block_height) + 1n, + network, + ), + ); +} + +const signedTx = encode_signed_transaction(tx, witnessBytes); +``` + +## Submit and name the new order + +```ts +import { get_order_id } from '@mintlayer/wasm-lib'; + +const txHex = Array.from(signedTx, (b) => b.toString(16).padStart(2, '0')).join(''); +await api.broadcastTransaction(txHex); + +const newOrderId = get_order_id(concatBytes(concludeInput, feeInput), network); +console.log('your updated order:', newOrderId); +``` + +`get_order_id` predicts the id the same way consensus derives it — from the first UTXO outpoint — so you can index the new order immediately. + +## Variations + +- **Cancel instead of update**: keep the conclude input and the fee input, drop the `CreateOrder` output, and transfer everything back. One order operation input, zero created orders — allowed. +- **Self-fill then re-quote**: fill your own order in one transaction (`FillOrder` input + `encode_witness_no_signature` for the witness of the order input) and conclude-and-recreate another; just never combine two order operations in the same transaction. +- **Chain delegations the same way**: the spend-and-recreate pattern applies to delegation outputs too; see [Staking with the wasm module](../go/staking.md#manual-transaction-building) for the Go twin. + +For the higher-level wrappers around these encoders, see [Transactions](../../build/sdks/javascript/transactions.md#manual-building). diff --git a/docs/guides/python/index.md b/docs/guides/python/index.md index 3de2ca3..327f96e 100644 --- a/docs/guides/python/index.md +++ b/docs/guides/python/index.md @@ -19,3 +19,4 @@ The wallet-cli versions of the same workflows live in the [command-line guides]( | [Atomic swap](atomic-swap.md) | The Mintlayer side of a BTC ⇄ ML swap with HTLCs | | [Wallet & signing (WASM)](wallet-wasm.md) | Generate wallets, derive addresses, sign without a daemon | | [Forging custom transactions](custom-transactions.md) | Mixed inputs/outputs, protocol fees, multi-party signing, intents | +| [Composing UTXOs](utxo-composition.md) | Update an order atomically: conclude input + create output | diff --git a/docs/guides/python/trading-with-orders.md b/docs/guides/python/trading-with-orders.md index 63bd108..ed9ee7b 100644 --- a/docs/guides/python/trading-with-orders.md +++ b/docs/guides/python/trading-with-orders.md @@ -83,4 +83,4 @@ Concluding returns the unclaimed `give` remainder plus any accumulated `ask` bal ## Manual flows -With the `wasm` module, `encode_create_order_output` builds the order output and `get_order_id` predicts the id from the inputs; fills use `encode_input_for_fill_order`, whose inputs must **not** be signed (`encode_witness_no_signature`). See [Tokens and NFTs](../../build/sdks/python/tokens.md) in the SDK reference for the encoders. +With the `wasm` module, `encode_create_order_output` builds the order output and `get_order_id` predicts the id from the inputs; fills use `encode_input_for_fill_order`, whose inputs must **not** be signed (`encode_witness_no_signature`). To **update** an existing order (new price or amounts), compose a transaction that consumes the conclude-order input and re-creates the order: see [Composing UTXOs](utxo-composition.md). See [Tokens and NFTs](../../build/sdks/python/tokens.md) in the SDK reference for the encoders. diff --git a/docs/guides/python/utxo-composition.md b/docs/guides/python/utxo-composition.md new file mode 100644 index 0000000..e6d5578 --- /dev/null +++ b/docs/guides/python/utxo-composition.md @@ -0,0 +1,161 @@ +--- +title: "Composing UTXOs: Updating an Order" +description: "How to compose UTXOs on Mintlayer: update a DEX order atomically by consuming its conclude-order input and re-creating it with a create-order output." +sidebar_position: 9 +--- + +# Composing UTXOs: Updating an Order + +Mintlayer is a UTXO chain: **there is no mutable state**. A DEX order is an unspent output, and every protocol object behaves the same way, so "updating" an order means spending it and re-creating it *in the same transaction*. This atomic spend-and-recreate pattern is the essence of UTXO composition. + +## The pattern + +An order lives as a `CreateOrder` output holding the `give` balance plus any accumulated `ask` balance. Its spend authority is the maker, gated by the order's **nonce**. To update the order (new price, new amounts), build one transaction that: + +1. consumes the order via a **conclude-order input** (this releases the whole order balance), and +2. re-locks the funds into a fresh **create-order output** with the new parameters. + +```mermaid +flowchart LR + OLD["Order UTXO
(give 100, ask 0)"] -->|"conclude-order input
(nonce n)"| TX["Transaction"] + FEE["Coin UTXO
(fee payer)"] --> TX + TX -->|"create-order output
(give 100, ask 20)"| NEW["New order UTXO
(new order id)"] + TX -->|"change"| MAKER["Maker address"] +``` + +Two consequences worth internalizing: + +- The **new order gets a new ID** (IDs are derived from inputs; see [predicting IDs](custom-transactions.md#predicting-ids)). Publish the new ID and retire the old one. +- If the transaction fails or is dropped, nothing changed: the old order is still live and its nonce unspent. + +:::note[What consensus allows and forbids] + +The pattern above is exactly what consensus permits: **one order operation input** (fill, conclude, or freeze) — a slot shared with token account commands, so you cannot combine an order operation with a mint, an unmint, or any other command input — and **one `CreateOrder` output** per transaction. Concluding and re-creating in the same transaction sits precisely at both limits, which is what makes the atomic update work. + +Everything past those limits is rejected by the verifier: + +- A second command input (order operation or token account command) — `MultipleAccountCommands`. +- A second `CreateOrder` output — `MultipleOrdersCreated`. +- Spending a `CreateOrder` output as a UTXO input — orders are not UTXOs; they are consumed only through order operation inputs (`InvalidInputTypeInTx`). +- Creating an order in a transaction with no UTXO input — the new order's id is derived from the first UTXO outpoint (`NoUtxoInputsForOrderIdCreation`), which is why the fee input below is not optional. + +::: + +## The ingredients + +| Piece | Encoder | Source of truth | +| ----- | ------- | --------------- | +| Conclude input | `encode_input_for_conclude_order(order_id, nonce, current_block_height, network)` | `nonce` from the indexer's order record | +| New order output | `encode_create_order_output(ask_amount, ask_token_id, give_amount, give_token_id, conclude_address, network)` | Your new price/amounts | +| Surplus / change outputs | `encode_output_transfer` / `encode_output_token_transfer` | Old balance minus new give amount | +| Fee input | `encode_input_for_utxo` | A coin UTXO you own | +| Witness for the conclude input | `encode_witness` with `TxAdditionalInfo` | Order balances from the indexer | +| Witness for the coin input | `encode_witness` | n/a | + +## Fetch the order state + +```python +from mintlayer.indexer import Client + +idx = Client("http://127.0.0.1:3000") + +order = idx.get_order(old_order_id) +print(f"give: {order.give_balance.atoms} ask: {order.ask_balance.atoms} nonce: {order.nonce}") +``` + +The `ask_balance` matters: any asks accumulated from takers are part of the order you are concluding, so the new order (or a change output) must account for them. + +## Build the transaction + +Example: top up the `give` side and raise the ask. Old order: give 100 tokens / ask 10 coins. New order: give 150 / ask 12. The conclude releases 100 tokens plus accumulated asks; a separate token UTXO adds the extra 50. + +```python +# 1. Consume the old order +conclude_input = c.encode_input_for_conclude_order( + old_order_id, order.nonce, tip.block_height, Network.MAINNET +) + +# 2. Fee payer +src_id = c.encode_outpoint_source_id(fee_txid_bytes, SOURCE_TRANSACTION) +fee_input = c.encode_input_for_utxo(src_id, 0) + +# 3. Re-create the order with updated parameters +new_order_output = c.encode_create_order_output( + Amount(atoms="12"), None, # ask 12 coins (None token id = coin) + Amount(atoms="150"), give_token_id, # give 150 tokens + conclude_address, # same conclude destination + Network.MAINNET, +) + +# 4. Any balance not re-locked returns via change outputs +change_output = c.encode_output_transfer( + Amount(atoms="2000000000"), maker_addr, Network.MAINNET # fee change +) + +tx = c.encode_transaction(conclude_input + fee_input, new_order_output + change_output, 0) +``` + +Token inputs (e.g. a UTXO holding the additional 50 tokens) encode with `encode_input_for_utxo` like any other spend; only the *outputs* distinguish coin from token value. + +## Sign it + +The conclude input's sighash requires the order's balances via `TxAdditionalInfo`; this is the part that differs from a plain transfer. Signing is also where the inputs' backing UTXOs come in: `encode_witness` takes one entry per input, `b"\x00"` for non-UTXO inputs (the conclude input) and `b"\x01"` plus the encoded output for UTXO inputs (the fee input): + +```python +fee_output = c.encode_output_transfer(fee_amount, maker_addr, Network.MAINNET) # re-encode the fee UTXO's output +input_utxos = b"\x00\x01" + fee_output + +from mintlayer.wasm import OrderBalance, OrderInfo, SimpleCurrencyAmount, TxAdditionalInfo + +additional_info = TxAdditionalInfo( + order_info={ + old_order_id: OrderInfo( + initially_asked=SimpleCurrencyAmount.coins(order.initially_asked.atoms), + initially_given=SimpleCurrencyAmount.tokens( + give_token_id, order.initially_given.atoms + ), + ask_balance=OrderBalance(atoms=order.ask_balance.atoms), + give_balance=OrderBalance(atoms=order.give_balance.atoms, token_id=give_token_id), + ) + } +) + +witness_bytes = b"" +for i in range(2): # one witness per input, in input order + witness_bytes += c.encode_witness( + SIGHASH_ALL, + maker_key, + maker_addr, + tx, + input_utxos, + i, + additional_info, # required for the order input + tip.block_height, + Network.MAINNET, + ) + +signed_tx = c.encode_signed_transaction(tx, witness_bytes) +``` + +Submit via the indexer or node as usual, then predict and store the new order ID: + +```python +new_order_id = c.get_order_id(conclude_input + fee_input, Network.MAINNET) +``` + +## Variations of the same pattern + +The conclude-then-recreate transaction is one instance of a general tool. With the same encoders you can: + +- **Cancel an order**: conclude input, no create-order output; the balance flows to change outputs. +- **Self-fill and requote**: conclude input + `FillOrder`-style split: partially fill your own order and re-create a smaller one. (A pure fill by a taker uses `encode_input_for_fill_order` and needs **no signature** for that input: `encode_witness_no_signature`.) +- **Freeze before restructuring**: `encode_input_for_freeze_order` stops takers while you prepare the replacement transaction. +- **Chain protocol objects**: the same spend-and-recreate logic composes delegations (withdraw input + `encode_output_delegate_staking` output) and any future output type. + +:::note + +Exactly which balances must be re-assigned to which outputs is enforced by consensus, not by the encoders: the runtime tells you *if* a transaction is invalid, the indexer tells you *what* the balances are, and the composition is yours to design. + +::: + +For the order model itself (give/ask, conclude key, freeze semantics), see [Trading with Orders](trading-with-orders.md) and the wallet-cli guide [Trading with orders](../cli/trading-with-orders.md). diff --git a/docs/guides/rust/index.md b/docs/guides/rust/index.md index 12c306d..e630120 100644 --- a/docs/guides/rust/index.md +++ b/docs/guides/rust/index.md @@ -16,6 +16,7 @@ The wallet-cli versions of the same workflows live in the [command-line guides]( | [Issue an NFT](issue-nft.md) | MLS-03 issuance and transfers | | [Staking and delegation](staking.md) | Pool lifecycle and delegations | | [Trading with orders](trading-with-orders.md) | On-chain orders: create, discover, fill, conclude | +| [Composing UTXOs](utxo-composition.md) | Update an order atomically: conclude input + create output | | [Atomic swap](atomic-swap.md) | The Mintlayer side of a BTC ⇄ ML swap with HTLCs | For full-custody transaction building beyond these recipes, see [Building transactions](../../build/sdks/rust/transactions.md) in the SDK reference; runnable programs live in the repository under [examples/](https://github.com/mintlayer/rust-sdk/tree/main/examples). diff --git a/docs/guides/rust/trading-with-orders.md b/docs/guides/rust/trading-with-orders.md index ba26534..68d77e2 100644 --- a/docs/guides/rust/trading-with-orders.md +++ b/docs/guides/rust/trading-with-orders.md @@ -138,6 +138,6 @@ c.freeze_order(FreezeOrderParams { Concluding returns the unclaimed `give` remainder plus any accumulated `ask` balance to the order's conclude destination. Only the maker can freeze or conclude. -## Manual flows +To **update** an existing order (new price or amounts), compose a transaction that consumes the conclude-order input and re-creates the order: see [Composing UTXOs](utxo-composition.md). With the `crypto` feature, `encode_create_order_output` builds the order output and `get_order_id` predicts the id from the inputs; fills use `encode_input_for_fill_order`, whose inputs must **not** be signed (`encode_witness_no_signature`). Before the orders V1 fork the fill's `nonce` and `destination` parameters are significant; after the fork both are ignored, and `encode_input_for_freeze_order` requires orders V1 (`crypto::Error::OrdersV1NotActivated` before it). See [Tokens and NFTs](../../build/sdks/rust/tokens.md#dex-orders) in the SDK reference. diff --git a/docs/guides/rust/utxo-composition.md b/docs/guides/rust/utxo-composition.md new file mode 100644 index 0000000..7b5604c --- /dev/null +++ b/docs/guides/rust/utxo-composition.md @@ -0,0 +1,196 @@ +--- +title: "Composing UTXOs: Updating an Order" +description: "How to compose UTXOs in Rust: update a DEX order atomically by consuming its conclude-order input and re-creating it with a create-order output." +sidebar_position: 9 +--- + +# Composing UTXOs: Updating an Order + +Mintlayer is a UTXO chain: **there is no mutable state**. A DEX order is an unspent output, and every protocol object behaves the same way, so "updating" an order means spending it and re-creating it *in the same transaction*. This atomic spend-and-recreate pattern is the essence of UTXO composition. + +This guide uses the [crypto module](../../build/sdks/rust/crypto.md) of the Rust SDK and its indexer client. The same workflow in Go, JavaScript, and Python lives in the [Go](../go/utxo-composition.md), [JavaScript](../javascript/utxo-composition.md), and [Python](../python/utxo-composition.md) guides. + +:::note[Requires an unreleased SDK] + +The order-signing re-exports used below (`OrderAdditionalInfo`, `OrderId`, `TokenId`, and the public `parse_addressable`) ship in the next SDK release after v0.1.0 ([mintlayer/rust-sdk#5](https://github.com/mintlayer/rust-sdk/pull/5), unreleased at the time of writing). + +::: + +## The pattern + +A maker wants to update an existing order's conditions: lower the `ask` from 10 coins to 12 coins, raise the `give` from 100 tokens to 150 tokens. The transaction must contain: + +```mermaid +flowchart LR + C["Conclude order input
(consumes the old order)"] --> N["Transaction"] + F["Fee input
(UTXO paying the fee)"] --> N + N -->|"create-order output
(give 150, ask 12)"| O["New order"] + N -->|"change"| R["Maker address"] +``` + +All four effects happen atomically. If the transaction fails, the old order is untouched. + +:::note[What consensus allows and forbids] + +The pattern above is exactly what consensus permits: **one order operation input** (fill, conclude, or freeze) — a slot shared with token account commands, so you cannot combine an order operation with a mint, an unmint, or any other command input — and **one `CreateOrder` output** per transaction. Concluding and re-creating in the same transaction sits precisely at both limits, which is what makes the atomic update work. + +Everything past those limits is rejected by the verifier: + +- A second command input (order operation or token account command) — `MultipleAccountCommands`. +- A second `CreateOrder` output — `MultipleOrdersCreated`. +- Spending a `CreateOrder` output as a UTXO input — orders are not UTXOs; they are consumed only through order operation inputs (`InvalidInputTypeInTx`). +- Creating an order in a transaction with no UTXO input — the new order's id is derived from the first UTXO outpoint (`NoUtxoInputsForOrderIdCreation`), which is why the fee input below is not optional. + +::: + +## The ingredients + +| Piece | Call | Source of truth | +| ----- | ---- | --------------- | +| Conclude input | `encode_input_for_conclude_order(order_id, nonce, current_block_height, network)` | `nonce` from the indexer's order record | +| New order output | `encode_create_order_output(ask_amount, ask_token_id, give_amount, give_token_id, conclude_address, network)` | the new conditions | +| Surplus / change outputs | `encode_output_transfer` (coins) / `encode_output_token_transfer` (tokens) | whatever is not re-locked | +| Fee input | `encode_input_for_utxo` with `encode_outpoint_source_id` | a coin UTXO you own | +| Witness for the conclude input | `encode_witness` with a `TxAdditionalInfo` | the order's balances | +| Witness for the coin input | `encode_witness` | the fee UTXO's output | +| New order id | `get_order_id(&inputs, network)` | predicted from the inputs | + +## Fetch the order state + +```rust +use mintlayer_sdk::indexer; + +let indexer = indexer::Client::new("http://127.0.0.1:3000"); +let network = Network::Testnet; + +let order = indexer.order(OLD_ORDER_ID).await?; +let tip = indexer.tip().await?; +println!( + "give: {} ask: {} nonce: {}", + order.give_balance.atoms, order.ask_balance.atoms, order.nonce.0 +); + +// Predicted inclusion height: the next block after the current tip. +let inclusion_height = tip.block_height.checked_add(1).expect("height overflow"); +``` + +Balances come back as `Amount` — an atom count (u128) plus a `decimal` string — always compose with atoms. `initially_asked` / `initially_given` capture what the maker originally promised; the sighash for the conclude input is bound to them, so keep them exactly as returned. + +## Build the transaction + +```rust +use mintlayer_sdk::crypto::{ + self, + types::{H256, TokenId}, + Amount, SourceId, +}; + +// 1. Consume the old order +let conclude_input = crypto::encode_input_for_conclude_order( + OLD_ORDER_ID, + order.nonce.0, + inclusion_height, + network, +)?; + +// 2. Fee payer: a coin UTXO you own +let fee_tx_id = H256::from_slice(&hex::decode(FEE_TX_ID_HEX)?); +let fee_input = crypto::encode_input_for_utxo( + crypto::encode_outpoint_source_id(fee_tx_id, SourceId::Transaction), + 0, +); + +// 3. Re-create the order with updated parameters +let give_token_id: TokenId = crypto::parse_addressable(network, GIVE_TOKEN_ID)?; +let new_order_output = crypto::encode_create_order_output( + Amount::from_atoms(12_000_000_000_000), // ask 12 coins + None, // None = coin + Amount::from_atoms(15_000_000_000), // give 150 tokens + Some(GIVE_TOKEN_ID), + &order.conclude_destination, + network, +)?; + +// 4. Any balance not re-locked returns via change outputs +let change_output = + crypto::encode_output_transfer(Amount::from_atoms(2_000_000_000), MAKER, network)?; + +let inputs = vec![conclude_input.clone(), fee_input]; +let transaction = + crypto::encode_transaction(inputs.clone(), vec![new_order_output, change_output], 0)?; +``` + +If the fee UTXO came from a token transfer instead of a coin transfer, the fee input still needs a **coin** UTXO: fees are always paid in the base coin. Keep a separate small coin UTXO for fees and use the change output to sweep back anything you do not want to re-lock. + +## Sign it + +The conclude input's sighash requires the order's balances via `TxAdditionalInfo`: this is the part that differs from a plain transfer. Signing is also where the inputs' backing UTXOs come in: `encode_witness` takes one entry per input, `None` for non-UTXO inputs (the conclude input) and `Some(output)` for UTXO inputs (the fee input): + +```rust +use mintlayer_sdk::crypto::{ + self, + types::{Encode, OrderId, OutputValue, TxOutput}, + SigHashType, TxAdditionalInfo, +}; + +let maker = MAKER; +let maker_key = crypto::make_private_key(); +let order_id: OrderId = crypto::parse_addressable(network, OLD_ORDER_ID)?; +let additional_info = TxAdditionalInfo::new().with_order_info( + order_id, + crypto::OrderAdditionalInfo { + initially_asked: OutputValue::Coin(Amount::from_atoms(order.initially_asked.atoms)), + initially_given: OutputValue::TokenV1( + give_token_id, + Amount::from_atoms(order.initially_given.atoms), + ), + ask_balance: Amount::from_atoms(order.ask_balance.atoms), + give_balance: Amount::from_atoms(order.give_balance.atoms), + }, +); + +let input_utxos = [ + None, + Some(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(2_500_000_000)), // re-encode the fee UTXO's output + crypto::encode_destination(maker, network)?, + )), +]; + +let mut signatures = Vec::new(); +for index in 0..inputs.len() { + signatures.push(crypto::encode_witness( + SigHashType::all(), + &maker_key, + maker, + &transaction, + &input_utxos, + index, + &additional_info, + inclusion_height, + network, + )?); +} + +let signed = crypto::encode_signed_transaction(transaction, signatures)?; +let signed_hex = hex::encode(signed.encode()); +``` + +## Submit and name the new order + +```rust +indexer.submit_transaction(&signed_hex).await?; + +let new_order_id = crypto::get_order_id(&inputs, network)?; +println!("your updated order: {new_order_id}"); +``` + +`get_order_id` predicts the id the same way consensus derives it — from the first UTXO outpoint — so you can index the new order immediately. + +## Variations + +- **Cancel instead of update**: keep the conclude input and the fee input, drop the `CreateOrder` output, and transfer everything back. One order operation input, zero created orders — allowed. +- **Self-fill then re-quote**: fill your own order in one transaction (`FillOrder` input + `encode_witness_no_signature` for the witness of the order input) and conclude-and-recreate another; just never combine two order operations in the same transaction. +- **Chain delegations the same way**: the spend-and-recreate pattern applies to delegation outputs too; see [Building transactions](../../build/sdks/rust/transactions.md) for more signed-composition flows. + +For every encoder in the module, see [Cryptography](../../build/sdks/rust/crypto.md).