diff --git a/mkdocs/config/mkdocs.en.yml b/mkdocs/config/mkdocs.en.yml index 41e4fc22a..09367ec46 100644 --- a/mkdocs/config/mkdocs.en.yml +++ b/mkdocs/config/mkdocs.en.yml @@ -86,6 +86,8 @@ nav: - devbook/accounts/create-from-mnemonic.md - devbook/accounts/testnet-faucet.md - devbook/accounts/query-balance.md + - Transactions: + - devbook/transactions/transfer-xem.md - Reference Guides: - Python SDK: devbook/reference/py/ - TypeScript SDK: devbook/reference/ts/ diff --git a/mkdocs/pages/en/devbook/transactions/transfer-xem.md b/mkdocs/pages/en/devbook/transactions/transfer-xem.md new file mode 100644 index 000000000..f0d28b27f --- /dev/null +++ b/mkdocs/pages/en/devbook/transactions/transfer-xem.md @@ -0,0 +1,222 @@ +--- +title: Transfer XEM +tutorial_level: beginner +--- + +# Sending XEM with a Transfer Transaction + +Sending from one to another is the most basic action on the NEM blockchain, and every other type of + follows the same general pattern. + +```dot +digraph "Transfer XEM" { + rankdir="LR"; + node [fontsize=12]; + + A [label="A"]; + B [label="B"]; + + A -> B [label="1 XEM"]; +} +``` + +This tutorial shows how to create, sign, and announce a that sends 1 XEM between two accounts, +and then poll the transaction's status until it is confirmed. + +## Prerequisites + +Before you start, make sure to: + +* Set up your development environment. + See [Setting Up a Development Environment](../start/setup.md). +* Create an to send the transfer transaction, either + [from code](../accounts/create-from-private-key.md) or + [by using a wallet](../../userbook/wallet/create-account.md). +* Obtain to pay for the transaction fee and transfer amount. + See [Getting Testnet Funds from the Faucet](../accounts/testnet-faucet.md). + +## Full Code + +{% import 'tutorial.jinja2' as tutorial with context %} + +{{ tutorial.code_full_tagged('devbook/transactions/transfer_xem', ['py', 'js']) }} + +The whole code is wrapped in a single `try` block to provide simple error handling, +but applications will probably want to use more fine-grained control. + +## Code Explanation + +### Setting Up the Accounts + +{{ tutorial.code_snippet_tagged('step-1') }} + +Every transfer transaction involves two accounts: a **sender** and a **recipient**. + +The **sender** is the that signs the transaction and pays the fee. +Its private key is loaded from the `SIGNER_PRIVATE_KEY` environment variable. +If not provided, a test key is used as default. + +The **recipient** is the account that receives the XEM. +Its is loaded from the `RECIPIENT_ADDRESS` environment variable. +If not provided, a test address is used as default. + +### Defining the Transfer Amount + +{{ tutorial.code_snippet_tagged('step-2') }} + +The snippet defines the transfer amount in the `xem` variable, loaded as a number from the `XEM_AMOUNT` +environment variable. +If not provided, a default of 1 XEM is used. + +The transaction's `amount` field requires atomic units, not whole XEM. +XEM has a of 6, so one XEM equals one million atomic units. +The snippet derives `amount` by multiplying `xem` by 1'000'000. + +### Fetching Network Time + +{{ tutorial.code_snippet_tagged('step-3') }} + +Every NEM transaction contains two time fields, both expressed in , +the number of seconds since the NEM nemesis block: + +* `timestamp`: The moment the transaction is created, set here to the current network time. +* `deadline`: How long the network keeps trying to confirm the transaction before discarding it. + It must be after the timestamp and no more than 24 hours later. + Otherwise, the node rejects the transaction. + This example sets it two hours after the timestamp, well within the limit. + +Building a transfer therefore needs an accurate network time. + +The endpoint reports the node's current network time. +The node returns this value in milliseconds, so the code divides it by 1000 to obtain the seconds that transactions +expect. + +However, applications do not need to query the network time before every transaction. +It can be fetched once and then adjusted using the local system clock when needed. +This provides a good balance between accuracy and performance. + +### Calculating the Transaction Fee + +{{ tutorial.code_snippet_tagged('step-4') }} + +Every transaction pays a fee to the that includes it in a block. + +NEM uses a fixed fee schedule, so the snippet calculates the fee locally without contacting a node. +For a XEM-only transfer, the fee starts at 0.05 XEM for small amounts and grows with the XEM sent, up to a cap of +1.25 XEM. +See [Fees](../../textbook/transfer_transactions.md#fees) for the full rules, including the mosaic and message costs. + +The snippet implements this schedule: + +* `fee_steps` is the number of full 10'000-XEM increments in `xem`, clamped between 1 and 25. +* `fee` is `fee_steps` multiplied by 50'000 atomic units, the value of one increment (0.05 XEM). + +With the default 1 XEM, the fee falls in the first increment (0.05 XEM). + +### Building the Transaction + +{{ tutorial.code_snippet_tagged('step-5') }} + +The snippet calls with a descriptor that supplies every required property of +the transfer transaction: + +* `type`: This tutorial uses , the current transfer version, which can carry both XEM and + other . + No mosaics are attached here, so the transaction sends XEM only. + +* `signer_public_key`: The signer is the account that will pay the fee. + In a transfer transaction, it is also the source of the transferred XEM. + +* `fee`: The value calculated in the previous step. For 1 XEM, this is `50_000` atomic units (0.05 XEM). + +* `timestamp` and `deadline`: The values computed in the network time step. + +* `recipient_address`: The address that will receive the XEM. + +* `amount`: The atomic-unit value computed earlier. For 1 XEM, this is `1_000_000`. + +!!! info "Sending a mosaic or a message" + + A can also carry other instead of XEM, or include a , with the fee + calculated differently in each case. + See the [Transfer Mosaics](./transfer-mosaics.md) and [Transfer with a Message](./messages.md) tutorials. + +### Signing and Serializing + +{{ tutorial.code_snippet_tagged('step-6') }} + +Once the transaction is created, it must be signed with the signing account's private key. +Signing ensures the transaction is authentic and authorized by the sender. + + returns a encoded as a hexadecimal string. + + adds the signature to the transaction and serializes it into a JSON payload +ready to be submitted directly to a node for announcement. + +### Announcing the Transaction + +{{ tutorial.code_snippet_tagged('step-7') }} + +The signed payload is submitted to the endpoint of any NEM . + +The node validates the transaction as soon as it is announced and reports the outcome in the response. +A result of `SUCCESS` means the transaction passed this first check and was added to the . +Any other result means the node did not accept it, and the response message explains why, for example that the +account does not hold enough XEM to cover the amount and the fee. + +!!! warning "Do not rely on unconfirmed transactions" + + A `SUCCESS` result only means the transaction reached the unconfirmed pool. + It is not yet guaranteed to be included in a block. + Wait until it is [confirmed](#waiting-for-confirmation), and ideally past the , before relying + on it. + +### Waiting for Confirmation + +{{ tutorial.code_snippet_tagged('step-8') }} + +The snippet above repeatedly queries the endpoint using the hash of the announced transaction. + +!!! note "Polling vs WebSockets" + + This step uses polling to check whether the transaction has been confirmed. + Polling is used here for illustration purposes, but it is not the recommended approach for real applications. + + [WebSockets](../websockets/listen-transaction-flow.md) provide a more responsive solution without the overhead of + repeated API calls. + +While the transaction is still unconfirmed, the endpoint responds with an error, and the code waits one second +before retrying, for up to 120 attempts (about two minutes). + +Once the transaction is included in a block, the endpoint returns it together with the block height, and the loop +ends. + +NEM produces a block roughly once per minute, so confirmation usually takes from a few seconds to a couple of minutes. + +## Output + +The output shown below corresponds to a typical run of the program. + +```text +--8<-- 'devbook/transactions/transfer_xem.log' +``` + +The number of `pending` checks depends on how soon the next block is harvested, so it varies between runs. + +To see the transaction from the network's perspective, you can search for the transaction hash on a [NEM testnet +explorer](https://testnet.nem.fyi/). +The hash is printed in the line that says `Waiting for confirmation from /transaction/get?hash=...`. + +## Conclusion + +This tutorial showed how to: + +| Step | Related documentation | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------| +| [Obtain the network time](#fetching-network-time) | | +| [Build the transaction](#building-the-transaction) | | +| [Sign the transaction](#signing-and-serializing) |
| +| [Announce the transaction](#announcing-the-transaction) | | +| [Wait for confirmation](#waiting-for-confirmation) | | + +Most other NEM transaction types are created, signed, and announced in the same way. diff --git a/mkdocs/pages/en/textbook/transactions.md b/mkdocs/pages/en/textbook/transactions.md index 5f9f752d8..68d13b3ca 100755 --- a/mkdocs/pages/en/textbook/transactions.md +++ b/mkdocs/pages/en/textbook/transactions.md @@ -275,3 +275,40 @@ and validation steps, but differ in purpose and required fields. | `Mosaic Supply Change` | Change the total supply of a mosaic. | + +## Transaction Fees + +Every transaction pays a fee that compensates the that includes it in a block. + +NEM fees are not market-driven. +The network publishes a fixed schedule, so the cost of any transaction can be calculated up front without contacting a +node. + +### Fee Schedule + +The current schedule is: + +| Transaction | Cost | Notes | +| --------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------| +| `Transfer` | From 0.05 XEM | Depends on the XEM amount, attached mosaics, and message length. See [Fees](./transfer_transactions.md#fees). | +| `Account Key Link` | 0.15 XEM | | +| `Multisig Account Modification` | 0.5 XEM | Paid by the multisig account (or, when converting a regular account into a multisig, by that account). | +| `Multisig Cosignature` | 0.15 XEM | Paid by the multisig account, not the cosignatory. | +| `Multisig` (wrapper) | 0.15 XEM | Paid by the multisig account, on top of the inner transaction's fee. | +| `Namespace Registration` | 0.15 XEM | Plus a [lease fee](./namespaces.md#lease-fee) paid to a network sink address. | +| `Mosaic Definition` | 0.15 XEM | Plus a [creation fee](./mosaics.md#creation-fee) paid to a network sink address. | +| `Mosaic Supply Change` | 0.15 XEM | | + +### Floor and Bidding + +The amounts in the schedule are minimums. +A transaction whose fee is below the minimum is rejected by validators. + +A higher fee than the minimum is accepted and increases the chance of inclusion: + +* When a harvester builds a block, it picks transactions sorted by fee, highest first. +* During network congestion, a node's spam filter ranks pending transactions by a combination of the signer's + and a small fee bonus, so higher-fee transactions are more likely to enter the . + +`Multisig Cosignature` fees are additionally capped at 1000 XEM, which protects the multisig account from being drained +by a single cosignatory bidding an extreme fee. diff --git a/mkdocs/pages/en/textbook/transfer_transactions.md b/mkdocs/pages/en/textbook/transfer_transactions.md index ce1544d6a..d60943b1e 100644 --- a/mkdocs/pages/en/textbook/transfer_transactions.md +++ b/mkdocs/pages/en/textbook/transfer_transactions.md @@ -127,3 +127,86 @@ plaintext under AES-CBC and **996 bytes** under AES-GCM. Nodes accept and store both schemes under the same `0x0002` flag without inspecting the payload. A recipient can only decrypt a secure message when its tooling implements the same scheme the sender used. Messages produced by GCM tooling cannot be decrypted by CBC-only tooling, and vice versa. + +## Fees + +A transfer transaction's fee depends on what is sent. +It is the sum of two components: + +* The **transfer fee**, based on the XEM amount or the attached mosaics. +* The **message fee**, based on the length of any attached message. + +### Transfer Fee + +For a XEM-only transfer, the fee scales with the amount sent: + +| Amount sent | Cost | +| ---------------------------- | --------- | +| Up to 10'000 XEM | 0.05 XEM | +| Each additional 10'000 XEM | +0.05 XEM | +| 250'000 XEM or more | 1.25 XEM | + +For a mosaic transfer, the fee is the sum of every attached mosaic's individual fee. +Each mosaic is priced as follows: + +* **Tiny, indivisible mosaics** (supply ≤ 10'000 and divisibility 0) pay a flat **0.05 XEM**. +* **All other mosaics** are priced from their **XEM-equivalent value**, derived from the transferred quantity and the + mosaic's total supply. + This value maps to the same 0.05-to-1.25 XEM fee tiers used for XEM-only transfers, with a **supply discount** + that grows as the mosaic's total supply shrinks. + +The minimum per-mosaic fee is **0.05 XEM**. + +??? info "Mosaic Fee Calculation" + + Computing a non-tiny mosaic's fee takes three steps: compute the transferred quantity's XEM-equivalent value, + look that value up on the fee tiers to get a base fee, then subtract the supply discount. + + **1. XEM-equivalent value** + + \[ + \text{xem\_equivalent} \approx \frac{8.999 \times 10^9 \cdot \text{atomic\_quantity} \cdot \text{multiplier}}{\text{total\_atomic\_supply}} + \] + + where: + + * `8.999 × 10^9` is the initial XEM supply, in whole units. + * `atomic_quantity` is the amount of the mosaic being transferred, in atomic units. + * `multiplier` is the [XEM-amount multiplier](#xem-amount) (typically 1). + * `total_atomic_supply` is the mosaic's total supply, in atomic units: `supply × 10^divisibility`. + + **2. Base fee** + + The resulting value is then priced on the same 0.05-to-1.25 XEM fee tiers as a XEM-only transfer, + yielding the mosaic's **base fee**. + + **3. Supply discount** + + A **supply discount** is then subtracted from that base fee: + + \[ + \text{discount} = \left\lfloor 0.8 \cdot \ln \!\left( \frac{9 \times 10^{15}}{\text{total\_atomic\_supply}} \right) \right\rfloor \cdot 0.05 \text{ XEM} + \] + + where `9 × 10^15` is the largest mosaic quantity NEM allows. + + Scarcer mosaics receive a larger discount, because the logarithm grows as the supply shrinks. + + The final fee is **never less than 0.05 XEM**, even when the discount exceeds the base fee. + +### Message Fee + +A non-empty message costs **0.05 XEM** as a base, plus **0.05 XEM** for every additional 32 bytes of +payload, up to the 1024-byte maximum: + +| Message length | Added cost | +| ---------------------- | ---------- | +| No message | — | +| 1 to 31 bytes | 0.05 XEM | +| 32 to 63 bytes | 0.10 XEM | +| 64 to 95 bytes | 0.15 XEM | +| … | … | +| 1024 bytes (maximum) | 1.65 XEM | + +The fee is calculated on the stored payload size, so [secure messages](#secure-message-conventions) are billed on their +encrypted payload, not the plaintext. diff --git a/mkdocs/snippets/devbook/transactions/transfer_xem.log b/mkdocs/snippets/devbook/transactions/transfer_xem.log new file mode 100644 index 000000000..0401e6610 --- /dev/null +++ b/mkdocs/snippets/devbook/transactions/transfer_xem.log @@ -0,0 +1,34 @@ +Using node http://libertalia.nemtest.net:7890 +Fetching current network time from /time-sync/network-time + Network time: 351947283 s since the nemesis block + Transaction fee: 0.05 XEM +Built transaction: +{ + type: 257, + version: 2, + network: 152, + timestamp: 351947283, + signerPublicKey: '462EE976890916E54FA825D26BDD0235F5EB5B6A143C199AB0AE5EE9328E08CE', + signature: '17FF7D37A63F3CFC43C790300A7B7F1C8A2A2B1C5D64546007C7D02C771516EE8872A6BBAE414486DC2D4750BBAACB41B3140D45CC319F51B7CBC1D524545C06', + fee: '50000', + deadline: 351954483, + recipientAddress: '5442554C4541554732435A51495355523434324857413655414B47574958484441424A5649505334', + amount: '1000000', + mosaics: [] +} +Announcing transaction to /transaction/announce + Result: SUCCESS +Waiting for confirmation from /transaction/get?hash=436A43BDD3CE1F0A5A5EFC40A9027D3732D59AB3C507818A1B436EC7B32BF099 + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending + Transaction status: pending +Transaction confirmed in block 626588 diff --git a/mkdocs/snippets/devbook/transactions/transfer_xem.mjs b/mkdocs/snippets/devbook/transactions/transfer_xem.mjs new file mode 100644 index 000000000..722ecd888 --- /dev/null +++ b/mkdocs/snippets/devbook/transactions/transfer_xem.mjs @@ -0,0 +1,100 @@ +import { PrivateKey } from 'symbol-sdk'; +import { NemFacade } from 'symbol-sdk/nem'; + +const NODE_URL = process.env.NODE_URL || + 'http://libertalia.nemtest.net:7890'; +console.log('Using node', NODE_URL); +// [>step-1] +const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY || + '0000000000000000000000000000000000000000000000000000000000000000'; +const signerKeyPair = new NemFacade.KeyPair( + new PrivateKey(SIGNER_PRIVATE_KEY)); + +const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS || + 'TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4'; +// [step-2] +const xem = parseFloat(process.env.XEM_AMOUNT || '1'); +const amount = BigInt(Math.round(xem * 1_000_000)); +// [step-3] + const timePath = '/time-sync/network-time'; + console.log('Fetching current network time from', timePath); + const timeResponse = await fetch(`${NODE_URL}${timePath}`); + const timeJSON = await timeResponse.json(); + const networkTime = Math.floor(timeJSON.receiveTimeStamp / 1000); + console.log(' Network time:', networkTime, + 's since the nemesis block'); + + // Derived fields from network time + const timestamp = networkTime; + const deadline = networkTime + (2 * 60 * 60); + // [step-4] + const feeSteps = Math.max(1, Math.min(25, Math.floor(xem / 10_000))); + const fee = BigInt(feeSteps * 50_000); + console.log(` Transaction fee: ${Number(fee) / 1_000_000} XEM`); + // [step-5] + const transaction = facade.transactionFactory.create({ + type: 'transfer_transaction_v2', + signerPublicKey: signerKeyPair.publicKey.toString(), + fee, + timestamp, + deadline, + recipientAddress: RECIPIENT_ADDRESS, + amount + }); + // [step-6] + const signature = facade.signTransaction(signerKeyPair, transaction); + const jsonPayload = facade.transactionFactory.static.attachSignature( + transaction, signature); + console.log('Built transaction:'); + console.dir(transaction.toJson(), { colors: true }); + // [step-7] + const announcePath = '/transaction/announce'; + console.log('Announcing transaction to', announcePath); + const announceResponse = await fetch(`${NODE_URL}${announcePath}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: jsonPayload + }); + const announceResult = await announceResponse.json(); + console.log(' Result:', announceResult.message); + // [step-8] + if ('SUCCESS' === announceResult.message) { + const transactionHash = facade.hashTransaction(transaction) + .toString(); + const statusPath = `/transaction/get?hash=${transactionHash}`; + console.log('Waiting for confirmation from', statusPath); + + let isConfirmed = false; + for (let attempt = 1; 120 >= attempt; ++attempt) { + const response = await fetch(`${NODE_URL}${statusPath}`); + + if (response.ok) { + const confirmed = await response.json(); + console.log('Transaction confirmed in block', + confirmed.meta.height); + isConfirmed = true; + break; + } + console.log(' Transaction status: pending'); + await new Promise(resolve => { setTimeout(resolve, 1000); }); + } + if (!isConfirmed) + console.warn('Confirmation took too long.'); + } else { + console.log('Transaction rejected:', announceResult.message); + } + // [step-1] +SIGNER_PRIVATE_KEY = os.getenv( + 'SIGNER_PRIVATE_KEY', + '0000000000000000000000000000000000000000000000000000000000000000') +signer_key_pair = NemFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY)) + +RECIPIENT_ADDRESS = os.getenv( + 'RECIPIENT_ADDRESS', + 'TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4') +# [step-2] +xem = float(os.getenv('XEM_AMOUNT', '1')) +amount = round(xem * 1_000_000) +# [step-3] + time_path = '/time-sync/network-time' + print(f'Fetching current network time from {time_path}') + with urllib.request.urlopen(f'{NODE_URL}{time_path}') as response: + response_json = json.loads(response.read().decode()) + network_time = response_json['receiveTimeStamp'] // 1000 + print(f' Network time: {network_time} s since the nemesis block') + + # Derived fields from network time + timestamp = network_time + deadline = network_time + 2 * 60 * 60 + # [step-4] + fee_steps = max(1, min(25, xem // 10_000)) + fee = fee_steps * 50_000 + print(f' Transaction fee: {fee / 1_000_000} XEM') + # [step-5] + transaction = facade.transaction_factory.create({ + 'type': 'transfer_transaction_v2', + 'signer_public_key': signer_key_pair.public_key, + 'fee': fee, + 'timestamp': timestamp, + 'deadline': deadline, + 'recipient_address': RECIPIENT_ADDRESS, + 'amount': amount + }) + # [step-6] + signature = facade.sign_transaction(signer_key_pair, transaction) + json_payload = facade.transaction_factory.attach_signature( + transaction, signature) + print('Built transaction:') + print(json.dumps(transaction.to_json(), indent=2)) + # [step-7] + announce_path = '/transaction/announce' + print(f'Announcing transaction to {announce_path}') + announce_request = urllib.request.Request( + f'{NODE_URL}{announce_path}', + data=json_payload.encode(), + headers={'Content-Type': 'application/json'}, + method='POST' + ) + with urllib.request.urlopen(announce_request) as response: + announce_result = json.loads(response.read().decode()) + print(f' Result: {announce_result['message']}') + # [step-8] + if 'SUCCESS' == announce_result['message']: + status_path = ( + f'/transaction/get?hash={ + facade.hash_transaction(transaction)}') + print(f'Waiting for confirmation from {status_path}') + is_confirmed = False + for attempt in range(120): + try: + with urllib.request.urlopen( + f'{NODE_URL}{status_path}' + ) as response: + confirmed = json.loads(response.read().decode()) + height = confirmed['meta']['height'] + print(f'Transaction confirmed in block {height}') + is_confirmed = True + break + except urllib.error.HTTPError: + print(' Transaction status: pending') + time.sleep(1) + if not is_confirmed: + print('Confirmation took too long.') + else: + print(f'Transaction rejected: {announce_result['message']}') + # [