|
| 1 | +import logging |
| 2 | +import pytest |
| 3 | + |
| 4 | +from e2e_tests.test_utils.utxo_selector import UtxoSelector |
| 5 | + |
| 6 | +logger = logging.getLogger(__name__) |
| 7 | + |
| 8 | + |
| 9 | +def _extract_token_bundle_from_utxo(utxo: dict) -> list: |
| 10 | + """ |
| 11 | + Extract the tokenBundle list from an account/coins UTXO entry. |
| 12 | +
|
| 13 | + Supports the Rosetta representation where coin.metadata is a map keyed by |
| 14 | + the UTXO identifier. Falls back to legacy shapes if necessary. |
| 15 | + """ |
| 16 | + md = utxo.get("metadata") or {} |
| 17 | + utxo_id = utxo.get("coin_identifier", {}).get("identifier") |
| 18 | + if utxo_id and isinstance(md.get(utxo_id), list): |
| 19 | + return md[utxo_id] |
| 20 | + # Fallback (legacy): a flat assets list |
| 21 | + if isinstance(md.get("assets"), list): |
| 22 | + # Convert a legacy flat list into a tokenBundle-like list if possible |
| 23 | + # Expect entries like { policyId, tokens: [...] } |
| 24 | + return md["assets"] |
| 25 | + return [] |
| 26 | + |
| 27 | + |
| 28 | +def _negate_token_bundle_values(bundle: list) -> list: |
| 29 | + """Return a deep-copied tokenBundle list with token values negated (for inputs).""" |
| 30 | + out = [] |
| 31 | + for policy in bundle: |
| 32 | + if not isinstance(policy, dict): |
| 33 | + continue |
| 34 | + tokens = [] |
| 35 | + for t in policy.get("tokens", []) or []: |
| 36 | + try: |
| 37 | + v = int(str(t.get("value", "0"))) |
| 38 | + except Exception: |
| 39 | + v = 0 |
| 40 | + tokens.append({ |
| 41 | + "value": str(-v), |
| 42 | + "currency": t.get("currency") |
| 43 | + }) |
| 44 | + out.append({ |
| 45 | + "policyId": policy.get("policyId"), |
| 46 | + "tokens": tokens |
| 47 | + }) |
| 48 | + return out |
| 49 | + |
| 50 | + |
| 51 | +@pytest.mark.e2e |
| 52 | +def test_native_asset_self_transfer( |
| 53 | + rosetta_client, |
| 54 | + test_wallet, |
| 55 | + transaction_orchestrator, |
| 56 | + signing_handler, |
| 57 | + utxo_selector, |
| 58 | +): |
| 59 | + """ |
| 60 | + E2E: Spend a UTXO carrying native tokens and recreate the token bundle in an output to self. |
| 61 | +
|
| 62 | + - Select a UTXO with a tokenBundle (native asset) |
| 63 | + - Add one ADA-only UTXO to comfortably pay the fee |
| 64 | + - Create input ops for both UTXOs; attach tokenBundle metadata only to the asset input |
| 65 | + - Create two outputs to self: one reproducing the token bundle with its ADA; one change output for remaining ADA |
| 66 | + - Build, sign, submit, and wait for confirmation |
| 67 | + - Verify the confirmed transaction includes the tokenBundle on an output |
| 68 | + """ |
| 69 | + sender_addr = test_wallet.get_address() |
| 70 | + |
| 71 | + # 1) Select asset UTXO |
| 72 | + asset_utxo = UtxoSelector.find_first_asset_utxo(rosetta_client, sender_addr) |
| 73 | + asset_utxo_id = asset_utxo["coin_identifier"]["identifier"] |
| 74 | + asset_utxo_ada = int(asset_utxo["amount"]["value"]) # ADA contained in the asset UTXO |
| 75 | + token_bundle = _extract_token_bundle_from_utxo(asset_utxo) |
| 76 | + assert token_bundle, "Selected UTXO must contain token bundle" |
| 77 | + |
| 78 | + logger.info(f"Using asset UTXO {asset_utxo_id} with {asset_utxo_ada} lovelace and token bundle") |
| 79 | + |
| 80 | + # 2) Add an ADA-only UTXO to cover fee and keep change positive |
| 81 | + ada_only_inputs = utxo_selector.select_utxos( |
| 82 | + rosetta_client, |
| 83 | + sender_addr, |
| 84 | + required_amount=5_000_000, |
| 85 | + exclude_utxos=[asset_utxo_id], |
| 86 | + strategy="single", |
| 87 | + utxo_count=1, |
| 88 | + ) |
| 89 | + ada_utxo = ada_only_inputs[0] |
| 90 | + ada_utxo_id = ada_utxo["coin_identifier"]["identifier"] |
| 91 | + ada_utxo_ada = int(ada_utxo["amount"]["value"]) # ADA-only amount |
| 92 | + |
| 93 | + total_input_ada = asset_utxo_ada + ada_utxo_ada |
| 94 | + |
| 95 | + # 3) Build operations |
| 96 | + ops = [] |
| 97 | + |
| 98 | + # Input: asset UTXO (ADA negative; tokenBundle values negative) |
| 99 | + ops.append({ |
| 100 | + "operation_identifier": {"index": len(ops)}, |
| 101 | + "type": "input", |
| 102 | + "account": {"address": sender_addr}, |
| 103 | + "amount": {"value": f"-{asset_utxo_ada}", "currency": {"symbol": "ADA", "decimals": 6}}, |
| 104 | + "coin_change": { |
| 105 | + "coin_identifier": {"identifier": asset_utxo_id}, |
| 106 | + "coin_action": "coin_spent", |
| 107 | + }, |
| 108 | + "metadata": { |
| 109 | + "tokenBundle": _negate_token_bundle_values(token_bundle) |
| 110 | + }, |
| 111 | + }) |
| 112 | + |
| 113 | + # Input: ADA-only UTXO |
| 114 | + ops.append({ |
| 115 | + "operation_identifier": {"index": len(ops)}, |
| 116 | + "type": "input", |
| 117 | + "account": {"address": sender_addr}, |
| 118 | + "amount": {"value": f"-{ada_utxo_ada}", "currency": {"symbol": "ADA", "decimals": 6}}, |
| 119 | + "coin_change": { |
| 120 | + "coin_identifier": {"identifier": ada_utxo_id}, |
| 121 | + "coin_action": "coin_spent", |
| 122 | + }, |
| 123 | + }) |
| 124 | + |
| 125 | + # Output: token bundle to self, ADA equals the ADA in the asset UTXO |
| 126 | + ops.append({ |
| 127 | + "operation_identifier": {"index": len(ops)}, |
| 128 | + "type": "output", |
| 129 | + "account": {"address": sender_addr}, |
| 130 | + "amount": {"value": str(asset_utxo_ada), "currency": {"symbol": "ADA", "decimals": 6}}, |
| 131 | + "metadata": { |
| 132 | + "tokenBundle": token_bundle |
| 133 | + }, |
| 134 | + }) |
| 135 | + |
| 136 | + # Output: change to self, ADA is remaining; fee will be deducted by orchestrator |
| 137 | + change_ada = total_input_ada - asset_utxo_ada |
| 138 | + assert change_ada > 0, "Change must be positive before fee deduction" |
| 139 | + ops.append({ |
| 140 | + "operation_identifier": {"index": len(ops)}, |
| 141 | + "type": "output", |
| 142 | + "account": {"address": sender_addr}, |
| 143 | + "amount": {"value": str(change_ada), "currency": {"symbol": "ADA", "decimals": 6}}, |
| 144 | + }) |
| 145 | + |
| 146 | + # 4) Build unsigned tx (let orchestrator fetch suggested fee and adjust change) |
| 147 | + unsigned_tx, payloads, metadata, fee = transaction_orchestrator.build_transaction(ops) |
| 148 | + logger.info(f"Unsigned tx built. Suggested fee: {fee} lovelace") |
| 149 | + |
| 150 | + # 5) Sign and submit; wait for confirmation |
| 151 | + sign_fn = signing_handler.get_signing_function() # payment key |
| 152 | + tx_hash, tx_details = transaction_orchestrator.sign_and_submit( |
| 153 | + unsigned_transaction=unsigned_tx, |
| 154 | + payloads=payloads, |
| 155 | + signing_function=sign_fn, |
| 156 | + wait_for_confirmation=True, |
| 157 | + confirmation_timeout=180, |
| 158 | + ) |
| 159 | + |
| 160 | + logger.info(f"Native asset tx confirmed · {tx_hash}") |
| 161 | + |
| 162 | + # 6) Verify the confirmed tx includes an output with the tokenBundle |
| 163 | + assert tx_details and "transaction" in tx_details, "Missing transaction details after confirmation" |
| 164 | + parsed_ops = tx_details["transaction"].get("operations", []) |
| 165 | + assert parsed_ops, "Confirmed transaction should include operations" |
| 166 | + |
| 167 | + has_token_output = False |
| 168 | + for op in parsed_ops: |
| 169 | + if op.get("type") == "output": |
| 170 | + md = op.get("metadata") or {} |
| 171 | + tb = md.get("tokenBundle") |
| 172 | + if isinstance(tb, list) and len(tb) > 0: |
| 173 | + has_token_output = True |
| 174 | + break |
| 175 | + |
| 176 | + assert has_token_output, "Expected an output operation with tokenBundle metadata" |
| 177 | + |
0 commit comments