Quick API proof (curl)
Nyx tx search API quick intro (curl)
The examples below intentionally expose the exact JSON shape this notebook needs: - txs[].body.messages[] execute message type/contract/action, - tx_responses[].logs[].events[] reward/transfer/coin_received attributes, - and pagination.next_key for paging.
NYX_TX_REST = "https://api.nymtech.net/cosmos/tx/v1beta1/txs"
WALLET = "n127c69pasr35p76amfczemusnutr8mtw78s8xl7"
curl -sG " $NYX_TX_REST " --data-urlencode "query=message.sender=' ${WALLET} '" --data-urlencode "pagination.limit=1" --data-urlencode "pagination.count_total=true" --data-urlencode "order_by=ORDER_BY_DESC" | jq '{
pagination: .pagination,
tx_meta: (.tx_responses[0] | {txhash, height, timestamp}),
reward_exec_messages: (
.txs[0].body.messages
| to_entries
| map(
select((.value."@type" // "") | endswith("MsgExecuteContract"))
| {
msg_index: .key,
type: .value."@type",
contract: (.value.contract // ""),
action_keys: (
if (.value.msg | type) == "object" then ((.value.msg // {}) | keys)
elif (.value.msg | type) == "string" then
(try ((.value.msg | @base64d | fromjson) | keys)
catch try ((.value.msg | fromjson) | keys)
catch [])
else [] end
)
}
)
),
relevant_events: [
.tx_responses[0].logs[]?.events[]?
| select((.type == "transfer") or (.type == "coin_received") or (.type | startswith("wasm")))
| {
type,
msg_index: ([.attributes[]? | select(.key == "msg_index") | .value][0]),
amount: ([.attributes[]? | select(.key == "amount") | .value][0]),
recipient: ([.attributes[]? | select((.key == "recipient") or (.key == "receiver")) | .value][0]),
node_id: ([.attributes[]? | select((.key == "node_id") or (.key == "mix_id")) | .value][0])
}
]
}'
{
"pagination": null,
"tx_meta": {
"txhash": "C2AFFF3A8A967B59EB18B4C8EDF4A180688D7EA5E8B1BE3F37916B98723DC420",
"height": "22250074",
"timestamp": "2026-02-04T12:18:10Z"
},
"reward_exec_messages": [
{
"msg_index": 0,
"type": "/cosmwasm.wasm.v1.MsgExecuteContract",
"contract": "n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr",
"action_keys": [
"update_cost_params"
]
}
],
"relevant_events": []
}
NYX_TX_REST = "https://api.nymtech.net/cosmos/tx/v1beta1/txs"
WALLET = "n127c69pasr35p76amfczemusnutr8mtw78s8xl7"
RESP1 = $( curl -sG " $NYX_TX_REST " --data-urlencode "query=message.sender=' ${WALLET} '" --data-urlencode "pagination.limit=1" --data-urlencode "pagination.count_total=true" --data-urlencode "order_by=ORDER_BY_DESC" )
echo " $RESP1 " | jq '{page: 1, next_key: .pagination.next_key, txhash: .tx_responses[0].txhash}'
NEXT = $( echo " $RESP1 " | jq -r '.pagination.next_key' )
if [ " $NEXT " = "null" ] || [ -z " $NEXT " ] ; then
echo "No next_key returned (only one page of results)."
exit 0
fi
RESP2 = $( curl -sG " $NYX_TX_REST " --data-urlencode "query=message.sender=' ${WALLET} '" --data-urlencode "pagination.limit=1" --data-urlencode "pagination.key= $NEXT " --data-urlencode "order_by=ORDER_BY_DESC" )
echo " $RESP2 " | jq '{page: 2, next_key: .pagination.next_key, txhash: .tx_responses[0].txhash}'
{
"page": 1,
"next_key": null,
"txhash": "C2AFFF3A8A967B59EB18B4C8EDF4A180688D7EA5E8B1BE3F37916B98723DC420"
}
No next_key returned (only one page of results).
Imports + constants
We reuse shared helpers from nym_node_reward_tracker.common: - build_session (requests.Session with headers) - request_json (GET JSON with retry/backoff + logging) - numeric parsing helpers (to_int, to_decimal)
Wallet rows
We read wallet-addresses.csv (address,tag) into small Pydantic models so validation and serialization are explicit.
load_wallets
def load_wallets(
wallets_csv:str | Path
)-> List[WalletRow]:
RewardTxRow
def RewardTxRow(
data:Any
)-> None :
Normalized reward-withdrawal transaction row before fiat enrichment/export.
RewardMessage
def RewardMessage(
data:Any
)-> None :
Parsed reward withdrawal message with resolved node_id and action kind.
WalletRow
def WalletRow(
data:Any
)-> None :
Wallet input row from CSV (address, optional tag).
from tempfile import TemporaryDirectory
with TemporaryDirectory() as td:
p = Path(td) / "wallets.csv"
p.write_text("address,tag \n n1abc,test \n " , encoding= "utf-8" )
ws = load_wallets(p)
assert ws == [WalletRow(address= "n1abc" , tag= "test" )]
ws
[WalletRow(address='n1abc', tag='test')]
Reused common date + tx query helpers
The generic infrastructure helpers now live in 00_common.ipynb and are imported here: - parse_rfc3339_to_utc - parse_date_filters - build_tx_query - iter_txs_by_query
This keeps wallet tx pagination and date semantics identical across notebooks.
from nym_node_reward_tracker.common import _FakeResp, _FakeSession
assert parse_rfc3339_to_utc("2025-12-04T16:31:51Z" ).tzinfo == dt.timezone.utc
s, e = parse_date_filters("2025-01-01" , "2025-01-01" )
assert s.isoformat().startswith("2025-01-01T00:00:00" )
assert e.isoformat().startswith("2025-01-02T00:00:00" )
page1 = {
"txs" : [{"body" : {"messages" : []}, "auth_info" : {"fee" : {"amount" : []}}}],
"tx_responses" : [{"txhash" : "A" , "timestamp" : "2025-01-01T00:00:00Z" }],
"pagination" : {"next_key" : "K1" , "total" : "2" },
}
page2 = {
"txs" : [{"body" : {"messages" : []}, "auth_info" : {"fee" : {"amount" : []}}}],
"tx_responses" : [{"txhash" : "B" , "timestamp" : "2025-01-02T00:00:00Z" }],
"pagination" : {"next_key" : None , "total" : "2" },
}
fake = _FakeSession([_FakeResp(page1), _FakeResp(page2)])
rows = list (iter_txs_by_query(fake, nyx_tx_rest= "https://example" , query= "q" , page_limit= 1 ))
assert [r[1 ]["txhash" ] for r in rows] == ["A" , "B" ]
assert build_tx_query("n1x" , "sender" ) == "message.sender='n1x'"
assert build_tx_query("n1x" , "recipient" ) == "transfer.recipient='n1x'"
Decode execute msgs + reward detection
We import shared tx parsing helpers from 00_cosmos_tx_parsing.ipynb and keep only reward-specific message decoding here for optional action-label enrichment.
decode_execute_msg
def decode_execute_msg(
msg_field:Any
)-> Optional[Dict[str , Any]]:
raw = {"withdraw_operator_reward" : {"mix_id" : 7 }}
b64 = base64.b64encode(json.dumps(raw).encode("utf-8" )).decode("utf-8" )
assert decode_execute_msg(raw) == raw
assert decode_execute_msg(b64) == raw
assert extract_action(raw) == "withdraw_operator_reward"
Now we detect rewards from withdraw wasm events (event-first). Optional message decoding is used only to enrich the action label.
logs = {
"logs" : [{
"msg_index" : "0" ,
"events" : [{
"type" : "wasm-v2_withdraw_operator_reward" ,
"attributes" : [{"key" : "_contract_address" , "value" : DEFAULT_MIXNET_CONTRACT}, {"key" : "mix_id" , "value" : "2196" }],
}],
}]
}
by_idx = events_by_msg_index(logs)
found = _reward_withdrawals_from_events_by_index(by_idx, mixnet_contract= DEFAULT_MIXNET_CONTRACT)
assert found == [{"msg_index" : 0 , "action" : "withdraw_operator_reward" , "node_id" : 2196 }]
msgs = [{"@type" : "/cosmwasm.wasm.v1.MsgExecuteContract" , "contract" : DEFAULT_MIXNET_CONTRACT, "msg" : {"withdraw_operator_reward" : {}}}]
assert _enrich_action_from_message(msgs, msg_index= 0 , mixnet_contract= DEFAULT_MIXNET_CONTRACT) == "withdraw_operator_reward"
Event interpretation (amount + node_id fallback)
To compute received NYM and node identifiers, we reuse parse_coins, events_by_msg_index, and incoming-amount helpers from 00_cosmos_tx_parsing.ipynb. Local helpers only handle reward-specific fallback semantics.
node_id_from_tx_response_events
def node_id_from_tx_response_events(
tx_resp:Dict[str , Any]
)-> Optional[int ]:
node_id_from_wasm_events
def node_id_from_wasm_events(
events:List[Dict[str , Any]]
)-> Optional[int ]:
assert parse_coins("100unym,5ufoo" ) == [(100 , "unym" ), (5 , "ufoo" )]
We group events by msg_index with shared logic from 00_cosmos_tx_parsing.ipynb.
evs = events_by_msg_index({"logs" : [{"msg_index" : "0" , "events" : [{"type" : "transfer" , "attributes" : []}]}]})
assert 0 in evs and evs[0 ][0 ]["type" ] == "transfer"
fake_resp = {"logs" : [{"msg_index" : "0" , "events" : [{"type" : "transfer" , "attributes" : []}]}]}
evs = events_by_msg_index(fake_resp, logger= logging.getLogger("t" ), txhash= "x" )
assert 0 in evs and evs[0 ][0 ]["type" ] == "transfer"
sum incoming NYM (shared helper)
addr = "n1abc"
# transfer + coin_received mirror the same movement -> count once
events = [
{"type" : "transfer" , "attributes" : [{"key" : "recipient" , "value" : addr}, {"key" : "amount" , "value" : "2000000unym" }]},
{"type" : "coin_received" , "attributes" : [{"key" : "receiver" , "value" : addr}, {"key" : "amount" , "value" : "2000000unym" }]},
]
assert sum_incoming_nym_from_events(events, addr) == Decimal("2" )
# fallback: if transfer is absent, use coin_received
events_coin_only = [
{"type" : "coin_received" , "attributes" : [{"key" : "receiver" , "value" : addr}, {"key" : "amount" , "value" : "1000000unym" }]},
]
assert sum_incoming_nym_from_events(events_coin_only, addr) == Decimal("1" )
addr = "n1abc"
# transfer + coin_received mirror the same movement -> count once
events = [
{"type" : "transfer" , "attributes" : [{"key" : "recipient" , "value" : addr}, {"key" : "amount" , "value" : "2000000unym" }]},
{"type" : "coin_received" , "attributes" : [{"key" : "receiver" , "value" : addr}, {"key" : "amount" , "value" : "2000000unym" }]},
]
assert sum_incoming_nym_from_events(events, addr) == Decimal("2" )
# fallback: if transfer is absent, use coin_received
events_coin_only = [
{"type" : "coin_received" , "attributes" : [{"key" : "receiver" , "value" : addr}, {"key" : "amount" , "value" : "1000000unym" }]},
]
assert sum_incoming_nym_from_events(events_coin_only, addr) == Decimal("1" )
tx fee in NYM + node_id fallback
sum_fee_nym
def sum_fee_nym(
tx:Dict[str , Any]
)-> Decimal:
tx = {"auth_info" : {"fee" : {"amount" : [{"denom" : "unym" , "amount" : "3000" }]}}}
assert sum_fee_nym(tx) == Decimal("0.003" )
wasm_events = [{"type" : "wasm-v2_withdraw_operator_reward" , "attributes" : [{"key" : "mix_id" , "value" : "2196" }]}]
assert node_id_from_wasm_events(wasm_events) == 2196
tx_resp = {"events" : [{"type" : "wasm-v2_withdraw_operator_reward" , "attributes" : [{"key" : "mix_id" , "value" : "2196" }]}]}
assert node_id_from_tx_response_events(tx_resp) == 2196
Build export rows from a tx
For each reward message we create one row.
If a tx contains multiple reward messages, we allocate the tx fee evenly across them.
Tx URL fallback chain
We resolve a robust tx_url per transaction with this chain: primary tx base (default Nyx tx REST), then explicit tx API base, then RPC /tx query.
resolve_tx_url
def resolve_tx_url(
txhash:str , session:Any | None , explorer_base:str , tx_api_base:str , tx_rpc_base:str , timeout:int ,
logger:logging.Logger
)-> str :
from nym_node_reward_tracker.common import _FakeResp, _FakeSession
sess = _FakeSession([
_FakeResp({}, status_code= 404 ),
_FakeResp({"tx_response" : {"txhash" : "H" }}, status_code= 200 ),
])
url = resolve_tx_url(
"H" ,
session= sess,
explorer_base= "https://example.invalid/nyx/tx" ,
tx_api_base= "https://api.nymtech.net/cosmos/tx/v1beta1/txs" ,
tx_rpc_base= "https://rpc.nymtech.net" ,
timeout= 5 ,
logger= logging.getLogger("t" ),
)
assert url.endswith('/cosmos/tx/v1beta1/txs/H' )
build_reward_rows_for_tx
def build_reward_rows_for_tx(
wallet:WalletRow, tx:Dict[str , Any], tx_resp:Dict[str , Any], mixnet_contract:str , explorer_base:str ,
tx_api_base:str , tx_rpc_base:str , logger:logging.Logger, session:Any | None = None
)-> List[Dict[str , Any]]:
w = WalletRow(address= "n1abc" , tag= "t" )
tx = {
"body" : {
"messages" : [
{
"@type" : "/cosmwasm.wasm.v1.MsgExecuteContract" ,
"contract" : DEFAULT_MIXNET_CONTRACT,
"msg" : {"withdraw_operator_reward" : {}},
}
]
},
"auth_info" : {"fee" : {"amount" : [{"denom" : "unym" , "amount" : "1000" }]}}
}
tx_resp = {
"txhash" : "H" ,
"height" : "1" ,
"timestamp" : "2025-01-01T00:00:00Z" ,
"logs" : [{
"msg_index" : "0" ,
"events" : [
{"type" : "wasm-v2_withdraw_operator_reward" , "attributes" : [{"key" : "_contract_address" , "value" : DEFAULT_MIXNET_CONTRACT}, {"key" : "mix_id" , "value" : "2196" }]},
{"type" : "transfer" , "attributes" : [{"key" : "recipient" , "value" : "n1abc" }, {"key" : "amount" , "value" : "1000000unym" }]},
],
}],
}
rows = build_reward_rows_for_tx(
wallet= w,
tx= tx,
tx_resp= tx_resp,
mixnet_contract= DEFAULT_MIXNET_CONTRACT,
explorer_base= DEFAULT_NYX_TX_REST,
tx_api_base= DEFAULT_TX_API_BASE,
tx_rpc_base= DEFAULT_TX_RPC_BASE,
logger= logging.getLogger("t" ),
)
assert rows and rows[0 ]["node_id" ] == 2196
assert rows[0 ]["amount_nym" ] == "1.000000"
assert rows[0 ]["action" ] == "withdraw_operator_reward"
Pricing (CoinGecko) + nearest-point selection
We fetch a price series over the minimal time window that covers all receipts, then select the nearest price point per tx timestamp.
Note: for interface compatibility we keep the cache_dir argument in run_reward_transactions, but this rewrite intentionally does not cache price responses.
nearest_price
def nearest_price(
points:List[Tuple[int , Decimal]], ts:int
)-> Tuple[Optional[int ], Optional[Decimal], Optional[int ]]:
fetch_coingecko_prices_range
def fetch_coingecko_prices_range(
session:Any, coingecko_base:str , coin_id:str , vs_currency:str , from_ts:int , to_ts:int , api_key:Optional[str ],
timeout:int = 60 , retries:int = 6 , logger:Optional[logging.Logger]= None
)-> List[Tuple[int , Decimal]]:
assert nearest_price([(100 , Decimal("1.0" )), (200 , Decimal("2.0" ))], 180 )[1 ] == Decimal("2.0" )
# Fake CoinGecko response
from nym_node_reward_tracker.common import _FakeResp, _FakeSession
fake = _FakeSession([_FakeResp({"prices" : [[1000 , 0.5 ]]})])
pts = fetch_coingecko_prices_range(
fake,
coingecko_base= "https://example" ,
coin_id= "nym" ,
vs_currency= "eur" ,
from_ts= 0 ,
to_ts= 2 ,
api_key= None ,
)
assert pts == [(1 , Decimal("0.5" ))]
enrich_rows_with_prices
def enrich_rows_with_prices(
rows:List[Dict[str , Any]], session:Any, currency:str , coingecko_base:str , coin_id:str , api_key:Optional[str ],
logger:logging.Logger
)-> None :
rows = [{
"timestamp_utc" : "2025-01-01T00:00:00+00:00" ,
"amount_nym" : "1.000000" ,
}]
fake = _FakeSession([_FakeResp({"prices" : [[1735689600000 , 0.5 ]]})]) # 2025-01-01T00:00:00Z
enrich_rows_with_prices(
rows,
session= fake,
currency= "eur" ,
coingecko_base= "https://example" ,
coin_id= "nym" ,
api_key= None ,
logger= logging.getLogger("t" ),
)
assert rows[0 ]["price_eur_per_nym" ] != ""
assert rows[0 ]["value_eur" ] == "0.50"
Output writing + CLI-friendly table log
We log a compact table (CLI visibility).
We write .csv or .xlsx based on output filename suffix.
write_rows
def write_rows(
rows:List[Dict[str , Any]], currency:str , out_path:str | Path
)-> None :
log_rows_table
def log_rows_table(
rows:List[Dict[str , Any]], currency:str , logger:logging.Logger
)-> None :
from tempfile import TemporaryDirectory
with TemporaryDirectory() as td:
p = Path(td) / "out.csv"
write_rows([{"timestamp_utc" : "x" , "wallet" : "w" }], currency= "eur" , out_path= p)
assert p.exists()
Main entrypoint
This is the CLI-facing function imported by 10_cli.md.
return 2 when wallets CSV is empty / has no wallets,
return 0 when no reward receipts are found (and log a warning),
always query Nyx directly (no SQLite cache involvement).
resolve tx_url via fallback chain: explorer -> tx API -> tx RPC.
run_reward_transactions
def run_reward_transactions(
wallets_csv:str = 'wallet-addresses.csv' , out_csv:str = 'nym_rewards_tax_export.csv' , currency:str = 'eur' ,
start:Optional[str ]= None , end:Optional[str ]= None , nyx_api_base:Optional[str ]= None ,
mixnet_contract:Optional[str ]= None , coin_id:Optional[str ]= None , coingecko_base:Optional[str ]= None ,
coingecko_api_key:Optional[str ]= None , cache_dir:Optional[str ]= '.cache' , search_mode:str = 'sender' ,
explorer_base:Optional[str ]= None , tx_api_base:Optional[str ]= None , tx_rpc_base:Optional[str ]= None ,
log_file:str = 'nym_tax_rewards_export.log' , log_level:str = 'INFO'
)-> int :
from tempfile import TemporaryDirectory
with TemporaryDirectory() as td:
p = Path(td) / "wallets.csv"
p.write_text("address,tag \n " , encoding= "utf-8" )
rc = run_reward_transactions(wallets_csv= str (p), out_csv= str (Path(td) / "out.csv" ))
assert rc == 2
2026-02-12T13:37:07 | ERROR | nym_tax_export | No wallets found in /tmp/tmpwwvd5zuy/wallets.csv
Python live end-to-end demo
import os
from tempfile import TemporaryDirectory
with TemporaryDirectory() as td:
wallets = Path(td) / "wallets.csv"
wallets.write_text("address,tag \n n127c69pasr35p76amfczemusnutr8mtw78s8xl7,operator \n " , encoding= "utf-8" )
out = Path(td) / "rewards.csv"
rc = run_reward_transactions(
wallets_csv= str (wallets),
out_csv= str (out),
currency= "eur" ,
start= "2025-01-01" ,
end= "2025-12-31" ,
search_mode= "sender" ,
tx_api_base= "https://api.nymtech.net/cosmos/tx/v1beta1/txs" ,
tx_rpc_base= "https://rpc.nymtech.net" ,
log_file= None ,
log_level= "INFO" ,
)
print ("rc=" , rc)
print ("out exists:" , out.exists(), out)
Wallets: 0%| | 0/1 [00:00<?, ?wallet/s]2026-02-12T13:37:07 | INFO | nym_tax_export | Scanning wallet=n127c69pasr35p76amfczemusnutr8mtw78s8xl7 tag=operator query="message.sender='n127c69pasr35p76amfczemusnutr8mtw78s8xl7'"
Txs n127c69p: 0tx [00:00, ?tx/s]Txs n127c69p: 14tx [00:00, 1086.08tx/s]
Wallets: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 7.45wallet/s]Wallets: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 7.42wallet/s]
2026-02-12T13:37:07 | INFO | nym_tax_export | Collected 1 reward rows; fetching prices (eur)
2026-02-12T13:37:07 | INFO | nym_tax_export | CoinGecko price window unix=1764862311..1764869511 (eur)
2026-02-12T13:37:07 | INFO | nym_tax_export | Loaded 2 price points
2026-02-12T13:37:07 | INFO | nym_tax_export |
2026-02-12T13:37:07 | INFO | nym_tax_export | Reward transactions (table)
2026-02-12T13:37:07 | INFO | nym_tax_export |
| timestamp_utc | wallet | action | node_id | amount_nym | net_nym | price_eur_per_nym | value_eur |
|---------------------------|------------------------------------------|--------------------------|-----------|--------------|-------------|---------------------|-------------|
| 2025-12-04T16:31:51+00:00 | n127c69pasr35p76amfczemusnutr8mtw78s8xl7 | withdraw_operator_reward | 2196 | 3009.552561 | 3009.547398 | 0.04261800 | 128.26 |
2026-02-12T13:37:07 | INFO | nym_tax_export | Wrote 1 rows to: /tmp/tmpomn4uzy6/rewards.csv
rc= 0
out exists: True /tmp/tmpomn4uzy6/rewards.csv