Skip to content

Commit e5fce1a

Browse files
authored
feat: add e2e test for native asset transfer functionality (#576)
1 parent b0cb29a commit e5fce1a

File tree

3 files changed

+239
-3
lines changed

3 files changed

+239
-3
lines changed

e2e_tests/README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ End-to-end testing framework for Cardano's Rosetta API implementation.
55
## Features
66

77
- Basic ADA transfers
8+
- Native token transfers (tokenBundle)
89
- UTXO management
910
- Transaction construction and signing
1011
- Multi-input/output transactions
@@ -29,6 +30,7 @@ e2e_tests/
2930
│ └── pycardano_wallet.py # PyCardano wallet wrapper
3031
└── tests/ # Test cases
3132
├── test_multi_io_transactions.py # Multi-input/output tests
33+
├── test_native_asset_transfer.py # Native asset transfer test
3234
└── test_stake_operations.py # Stake operations tests
3335
```
3436

@@ -39,7 +41,8 @@ e2e_tests/
3941
- Python 3.11+
4042
- pip
4143
- Cardano Rosetta API endpoint
42-
- Test wallet with funds and at least 11 ada-only utxos
44+
- Test wallet with funds and at least 11 ada-only UTXOs
45+
- For native asset tests: at least 1 UTXO containing a token bundle and at least 1 ADA-only UTXO (≥ 5 ADA recommended to cover fee)
4346

4447
### Quick Start
4548

@@ -69,7 +72,6 @@ DREP_SCRIPT_HASH_ID=2d4cb680b5f400d3521d272b4295d61150e0eff3950ef4285406a953 # R
6972
POOL_REGISTRATION_CERT=<hex-encoded-cert> # Required for poolRegistrationWithCert test
7073
POOL_GOVERNANCE_PROPOSAL_ID=df58f714c0765f3489afb6909384a16c31d600695be7e86ff9c59cf2e8a48c7900 # Required for pool governance vote tests
7174
POOL_VOTE_CHOICE=yes # Optional: Vote choice for pool governance vote (yes/no/abstain, default: yes)
72-
```
7375
7476
## Usage
7577
@@ -140,6 +142,11 @@ pytest --log-cli-level=INFO tests/test_pool_operations.py::test_pool_registratio
140142
pytest --log-cli-level=INFO tests/test_pool_operations.py::test_pool_registration_with_cert
141143
pytest --log-cli-level=INFO tests/test_pool_operations.py::test_pool_governance_vote
142144
pytest --log-cli-level=INFO tests/test_pool_operations.py::test_pool_retirement
145+
146+
# Native asset tests
147+
148+
# Run the native asset self-transfer test (spends a token UTXO and recreates the token bundle to self)
149+
pytest --log-cli-level=INFO tests/test_native_asset_transfer.py::test_native_asset_self_transfer
143150
```
144151

145152
## Test Scenarios
@@ -151,6 +158,13 @@ pytest --log-cli-level=INFO tests/test_pool_operations.py::test_pool_retirement
151158
3. **Fan-out**: Single input → Multiple outputs
152159
4. **Complex**: Multiple inputs → Multiple outputs
153160
5. **Fixed Fee**: Transaction with a fixed 4 ADA fee (4,000,000 lovelaces) that bypasses suggested fee
161+
6. **Native Asset Self-Transfer**: Spend a UTXO carrying native tokens (tokenBundle) and recreate the same bundle to self; add an ADA-only UTXO to comfortably cover fees.
162+
163+
### Notes for Native Asset Tests
164+
165+
- The Rosetta response for `/account/coins` is expected to include token bundles on UTXOs in `coin.metadata`.
166+
- The test attaches a `tokenBundle` to the input (with negative values) and to the token output (with positive values), matching the documented format in `docs/user-guides/multi-assets.md`.
167+
- Ensure your wallet has at least one UTXO that carries a token bundle and a separate ADA-only UTXO with enough ADA to pay fees.
154168

155169
### Stake Operations
156170

e2e_tests/test_utils/utxo_selector.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,49 @@ def select_utxos(
167167
total_selected = sum(int(utxo["amount"]["value"]) for utxo in selected_utxos)
168168
logger.debug(f"Selected {len(selected_utxos)} UTXOs with total {total_selected} lovelace")
169169

170-
return selected_utxos
170+
return selected_utxos
171+
172+
@staticmethod
173+
def find_first_asset_utxo(client: RosettaClient, address: str, exclude_utxos: Optional[List[str]] = None) -> Dict:
174+
"""
175+
Find the first UTXO that carries native assets (token bundle) for the given address.
176+
177+
Supports the Cardano Rosetta representation where `coin.metadata` contains
178+
a map keyed by the UTXO identifier with a list of policyId + tokens.
179+
180+
Args:
181+
client: RosettaClient instance
182+
address: Address to inspect
183+
exclude_utxos: Optional list of UTXO IDs to exclude
184+
185+
Returns:
186+
The first matching UTXO dict containing assets
187+
188+
Raises:
189+
ValidationError: If no asset UTXO found
190+
"""
191+
coins = client.get_utxos(address)
192+
if not coins:
193+
raise ValidationError(f"No UTXOs found for address {address}")
194+
195+
for utxo in coins:
196+
utxo_id = utxo.get("coin_identifier", {}).get("identifier")
197+
if not utxo_id:
198+
continue
199+
if exclude_utxos and utxo_id in exclude_utxos:
200+
continue
201+
202+
md = utxo.get("metadata") or {}
203+
# Newer shape: metadata keyed by utxo id -> [ { policyId, tokens: [...] }, ... ]
204+
bundle_list = md.get(utxo_id)
205+
if isinstance(bundle_list, list) and len(bundle_list) > 0:
206+
# Double-check presence of tokens in the first bundle
207+
tokens = bundle_list[0].get("tokens") if isinstance(bundle_list[0], dict) else None
208+
if isinstance(tokens, list) and len(tokens) > 0:
209+
return utxo
210+
211+
# Fallback for older shape (if any): metadata["assets"] exists and non-empty
212+
if isinstance(md.get("assets"), list) and len(md.get("assets")) > 0:
213+
return utxo
214+
215+
raise ValidationError(f"No native-asset UTXOs found for address {address}")
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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

Comments
 (0)