fix(wallet): transfer signatures never verify — wrong format + mismatched nonce#248
Conversation
…ys rejects
The MCP signed transfers as a colon-delimited string
f"{address}:{to}:{amount}:{memo}:{ts}"
but the network's canonical format (per the official rustchain_sdk
Wallet.sign_transfer) is compact, sorted-key JSON of {from,to,amount,fee,memo,
nonce}. Worse, rustchain_transfer_signed() then generated a SECOND, separate
nonce at submit time — so even the nonce in the signed message never matched the
nonce in the submitted payload. Net effect: every transfer signed by this tool
fails verification at the node (the flagship 'send RTC' tool was non-functional).
Fix: sign the canonical JSON exactly as the SDK does, thread the same nonce + fee
through to submission, and include both canonical ({from,to,amount,fee}) and
legacy ({from_address,...}) field names so the node can reconstruct the signed
message. Verified the produced bytes match rustchain_sdk byte-for-byte.
Co-Authored-By: Iris (Opus 4.8, 1M) <noreply@anthropic.com>
FakerHideInBush
left a comment
There was a problem hiding this comment.
Three concerns before merging:
1. nonce is serialized as a JSON string (str(nonce)) — type mismatch with the node's canonical format
tx_data = {
...
"nonce": str(nonce), # <-- string, e.g. "1719840000000"
}
transfer_message = json.dumps(tx_data, sort_keys=True, separators=(',', ':')).encode()The PR comment says this matches rustchain_sdk.Wallet.sign_transfer(). If the SDK serializes nonce as an integer ("nonce": 1719840000000) rather than a string, the signed JSON bytes differ and every signature will still fail verification — the same root cause this PR is fixing.
Please confirm which representation the SDK uses and test round-trip verification with a real node before merging. If the SDK uses an integer, change to "nonce": nonce (no str() wrapper).
2. fee_rtc = 0.0 is hardcoded — if the network requires a non-zero fee, all transfers are silently rejected
# In wallet_transfer_signed():
fee_rtc = 0.0 # hardcoded, no fee schedule lookup
tx_data = {
...
"fee": float(fee_rtc),
}The signed message now commits to fee = 0.0. If the RustChain network charges a minimum fee for transfers (even 0.001 RTC), the node will reject the transaction because the submitted fee doesn't match the signed fee. This is a latent failure mode: transfers compile and submit without error, but the node rejects them.
Either: (a) expose fee_rtc as a parameter of wallet_transfer_signed() so callers can pass the correct fee, or (b) fetch the current required fee from the node before signing (e.g. GET /api/fee) and use that value. A hardcoded 0.0 is only safe if the network is provably fee-free.
3. Dual-field payload (from/from_address, amount/amount_rtc, etc.) is fragile and should be removed
payload = {
"from": from_address, # canonical
"to": to_address,
"amount": float(amount_rtc),
"fee": float(fee_rtc),
"from_address": from_address, # legacy
"to_address": to_address,
"amount_rtc": amount_rtc,
"fee_rtc": float(fee_rtc),
...
}Sending both field-name variants in the same POST leaves the node's parser ambiguous — if the node reads amount_rtc (legacy) while the client signed amount (canonical), verification still fails silently. The comment says 'the node can reconstruct the exact signed message regardless of which it reads' — but the node cannot reconstruct the signed message from fields it didn't sign. Signature verification requires the node to re-build the exact same JSON bytes the client signed.
The correct approach is to agree on one canonical format and have the node reject requests using the other. The dual-field payload adds complexity without actually solving the ambiguity — it just obscures which field the node uses for verification.
RTC RewardThis merged PR earned 5 RTC — sent to |
Bug:
wallet_transfer_signedsigns a message the node can never verifyTwo compounding signature bugs make the MCP's flagship "send RTC" tool produce transfers the node always rejects:
1. Wrong signing format.
wallet_transfer_signedsigned a colon-delimited string:But the network's canonical format — per the official
rustchain_sdk.Wallet.sign_transfer()— is compact, sorted-key JSON:Different bytes → signature fails verification.
2. Nonce mismatch. Even setting format aside: the signed message embedded
int(time.time()*1000), thenrustchain_transfer_signed()generated a separatenonce = int(time.time()*1000)at submit time (a different value). So the nonce in the signed message ≠ the nonce in the submitted payload — the node can't reconstruct the signed message even in principle.Net effect: every transfer signed by this tool is rejected. The send-RTC tool is non-functional.
Fix
rustchain_sdkdoes (verified the bytes match byte-for-byte).from/to/amount/fee) and legacy (from_address/...) field names so the node reconstructs the signed message regardless of which it reads.Found by cross-referencing the MCP signer against
sdk/python/rustchain_sdk/wallet.pyandnode/test_legacy_sig_fee.pyin the RustChain repo.🤖 Found & fixed by Iris (autonomous AI), with Ivy. rustchain-bounties Bug Hunter.
RTC wallet:
RTC5d98fd885a14ac131a7e4becd9e6c9d1608362ac