diff --git a/content/_code-samples/tx-serialization/serialize.py b/content/_code-samples/tx-serialization/serialize.py index 9b8bf15467..e72e92c63f 100755 --- a/content/_code-samples/tx-serialization/serialize.py +++ b/content/_code-samples/tx-serialization/serialize.py @@ -9,6 +9,7 @@ import logging import re from address import decode_address +from xrpl_num import IssuedAmount logger = logging.getLogger(__name__) logger.addHandler(logging.StreamHandler()) @@ -81,6 +82,11 @@ def bytes_from_uint(i, bits): return i.to_bytes(bits // 8, byteorder="big", signed=False) def amount_to_bytes(a): + """ + Serializes an "Amount" type, which can be either XRP or an issued currency: + - XRP: 64 bits; 0, followed by 1 ("is positive"), followed by 62 bit UInt amount + - Issued Currency: 64 bits of amount, followed by + """ if type(a) == str: # is XRP xrp_amt = int(a) @@ -95,32 +101,42 @@ def amount_to_bytes(a): if sorted(a.keys()) != ["currency", "issuer", "value"]: raise ValueError("amount must have currency, value, issuer only (actually had: %s)" % sorted(a.keys())) - #TODO: canonicalize mantissa/exponent/etc. of issued currency amount - # https://developers.ripple.com/currency-formats.html#issued-currency-math - # temporarily returning all zeroes - issued_amt = bytes(8) + + issued_amt = IssuedAmount(a["value"]).to_bytes() + logger.debug("Issued amount: %s"%issued_amt.hex()) currency_code = currency_code_to_bytes(a["currency"]) return issued_amt + currency_code + decode_address(a["issuer"]) else: raise ValueError("amount must be XRP string or {currency, value, issuer}") +def issued_amount_as_bytes(strnum): + num = Decimal(strnum) + + def tx_type_to_bytes(txtype): type_uint = DEFINITIONS["TRANSACTION_TYPES"][txtype] return type_uint.to_bytes(2, byteorder="big", signed=False) -def currency_code_to_bytes(code_string): +def currency_code_to_bytes(code_string, xrp_ok=False): if re.match(r"^[A-Za-z0-9?!@#$%^&*<>(){}\[\]|]{3}$", code_string): # ISO 4217-like code if code_string == "XRP": + if xrp_ok: + # Rare, but when the currency code "XRP" is serialized, it's + # a special-case all zeroes. + logger.debug("Currency code(XRP): "+("0"*40)) + return bytes(20) raise ValueError("issued currency can't be XRP") + code_ascii = code_string.encode("ASCII") + logger.debug("Currency code ASCII: %s"%code_ascii.hex()) # standard currency codes: https://developers.ripple.com/currency-formats.html#standard-currency-codes # 8 bits type code (0x00) - # 96 bits reserved (0's) + # 88 bits reserved (0's) # 24 bits ASCII - # 8 bits version (0x00) + # 16 bits version (0x00) # 24 bits reserved (0's) - return b''.join( ( bytes(13), code_ascii, bytes(4) ) ) + return b''.join( ( bytes(12), code_ascii, bytes(5) ) ) elif re.match(r"^[0-9a-fA-F]{40}$", code_string): # raw hex code return bytes.fromhex(code_string) # requires Python 3.5+ @@ -197,6 +213,58 @@ def object_to_bytes(obj): fields_as_bytes.append(field_id("ObjectEndMarker")) return b''.join(fields_as_bytes) +def pathset_to_bytes(pathset): + """ + Serialize a PathSet, which is an array of arrays, + where each inner array represents one possible payment path. + A path consists of "path step" objects in sequence, each with one or + more of "account", "currency", and "issuer" fields, plus (ignored) "type" + and "type_hex" fields which indicate which fields are present. + (We re-create the type field for serialization based on which of the core + 3 fields are present.) + """ + + if not len(pathset): + raise ValueError("PathSet type must not be empty") + + paths_as_bytes = [] + for n in range(len(pathset)): + path = path_as_bytes(pathset[n]) + logger.debug("Path %d: %s"%(n, path.hex())) + paths_as_bytes.append(path) + if n + 1 == len(pathset): # last path; add an end byte + paths_as_bytes.append(bytes.fromhex("00")) + else: # add a path separator byte + paths_as_bytes.append(bytes.fromhex("ff")) + + return b''.join(paths_as_bytes) + +def path_as_bytes(path): + """ + Helper function for representing one member of a pathset as a bytes object + """ + + if not len(path): + raise ValueError("Path must not be empty") + + path_contents = [] + for step in path: + step_data = [] + type_byte = 0 + if "account" in step.keys(): + type_byte |= 0x01 + step_data.append(decode_address(step["account"])) + if "currency" in step.keys(): + type_byte |= 0x10 + step_data.append(currency_code_to_bytes(step["currency"], xrp_ok=True)) + if "issuer" in step.keys(): + type_byte |= 0x20 + step_data.append(decode_address(step["issuer"])) + step_data = [bytes_from_uint(type_byte, 8)] + step_data + path_contents += step_data + + return b''.join(path_contents) + def field_to_bytes(field_name, field_val): """ @@ -221,7 +289,7 @@ def field_to_bytes(field_name, field_val): "Hash128": hash_to_bytes, "Hash160": hash_to_bytes, "Hash256": hash_to_bytes, - # TODO: PathSet + "PathSet": pathset_to_bytes, "STArray": array_to_bytes, "STObject": object_to_bytes, "UInt8" : lambda x:bytes_from_uint(x, 8), @@ -270,7 +338,7 @@ if __name__ == "__main__": # "Sequence": 2 # } - with open("test-cases/tx2-nometa.json") as f: + with open("test-cases/tx3-nometa.json") as f: example_tx = json.load(f) serialize_tx(example_tx) diff --git a/content/_code-samples/tx-serialization/test-cases/tx3-binary.txt b/content/_code-samples/tx-serialization/test-cases/tx3-binary.txt new file mode 100644 index 0000000000..d1bc34834c --- /dev/null +++ b/content/_code-samples/tx-serialization/test-cases/tx3-binary.txt @@ -0,0 +1 @@ +1200002200000000240000034A201B009717BE61400000000098968068400000000000000C69D4564B964A845AC0000000000000000000000000555344000000000069D33B18D53385F8A3185516C2EDA5DEDB8AC5C673210379F17CFA0FFD7518181594BE69FE9A10471D6DE1F4055C6D2746AFD6CF89889E74473045022100D55ED1953F860ADC1BC5CD993ABB927F48156ACA31C64737865F4F4FF6D015A80220630704D2BD09C8E99F26090C25F11B28F5D96A1350454402C2CED92B39FFDBAF811469D33B18D53385F8A3185516C2EDA5DEDB8AC5C6831469D33B18D53385F8A3185516C2EDA5DEDB8AC5C6F9EA7C06636C69656E747D077274312E312E31E1F1011201F3B1997562FD742B54D4EBDEA1D6AEA3D4906B8F100000000000000000000000000000000000000000FF014B4E9C06F24296074F7BC48F92A97916C6DC5EA901DD39C650A96EDA48334E70CC4A85B8B2E8502CD310000000000000000000000000000000000000000000 diff --git a/content/_code-samples/tx-serialization/test-cases/tx3-nometa.json b/content/_code-samples/tx-serialization/test-cases/tx3-nometa.json new file mode 100644 index 0000000000..71078f7da9 --- /dev/null +++ b/content/_code-samples/tx-serialization/test-cases/tx3-nometa.json @@ -0,0 +1,57 @@ +{ + "Account": "rweYz56rfmQ98cAdRaeTxQS9wVMGnrdsFp", + "Amount": "10000000", + "Destination": "rweYz56rfmQ98cAdRaeTxQS9wVMGnrdsFp", + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 9902014, + "Memos": [ + { + "Memo": { + "MemoData": "7274312E312E31", + "MemoType": "636C69656E74" + } + } + ], + "Paths": [ + [ + { + "account": "rPDXxSZcuVL3ZWoyU82bcde3zwvmShkRyF", + "type": 1, + "type_hex": "0000000000000001" + }, + { + "currency": "XRP", + "type": 16, + "type_hex": "0000000000000010" + } + ], + [ + { + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "type": 1, + "type_hex": "0000000000000001" + }, + { + "account": "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q", + "type": 1, + "type_hex": "0000000000000001" + }, + { + "currency": "XRP", + "type": 16, + "type_hex": "0000000000000010" + } + ] + ], + "SendMax": { + "currency": "USD", + "issuer": "rweYz56rfmQ98cAdRaeTxQS9wVMGnrdsFp", + "value": "0.6275558355" + }, + "Sequence": 842, + "SigningPubKey": "0379F17CFA0FFD7518181594BE69FE9A10471D6DE1F4055C6D2746AFD6CF89889E", + "TransactionType": "Payment", + "TxnSignature": "3045022100D55ED1953F860ADC1BC5CD993ABB927F48156ACA31C64737865F4F4FF6D015A80220630704D2BD09C8E99F26090C25F11B28F5D96A1350454402C2CED92B39FFDBAF", + "hash": "B521424226FC100A2A802FE20476A5F8426FD3F720176DC5CCCE0D75738CC208" +} diff --git a/content/_code-samples/tx-serialization/xrpl_num.py b/content/_code-samples/tx-serialization/xrpl_num.py new file mode 100644 index 0000000000..ad82690496 --- /dev/null +++ b/content/_code-samples/tx-serialization/xrpl_num.py @@ -0,0 +1,65 @@ +# Serializes issued currency amounts from string number representations, +# matching the precision of the XRP Ledger. + +from decimal import getcontext, Decimal + +class IssuedAmount: + MIN_MANTISSA = 10**15 + MAX_MANTISSA = 10**16 - 1 + MIN_EXP = -96 + MAX_EXP = 80 + def __init__(self, strnum): + self.context = getcontext() + self.context.prec = 15 + self.context.Emin = self.MIN_EXP + self.context.Emax = self.MAX_EXP + + self.dec = Decimal(strnum) + + def to_bytes(self): + if self.dec.is_zero(): + return self.canonical_zero_serial() + + # Convert components to integers --------------------------------------- + sign, digits, exp = self.dec.as_tuple() + mantissa = int("".join([str(d) for d in digits])) + + # Canonicalize to expected range --------------------------------------- + while mantissa < self.MIN_MANTISSA and exp > self.MIN_EXP: + mantissa *= 10 + exp -= 1 + + while mantissa > self.MAX_MANTISSA: + if exp >= self.MAX_EXP: + raise ValueError("amount overflow") + mantissa //= 10 + exp += 1 + + if exp < self.MIN_EXP or mantissa < self.MIN_MANTISSA: + # Round to zero + return self.canonical_zero_serial() + + if exp > self.MAX_EXP or mantissa > self.MAX_MANTISSA: + raise ValueError("amount overflow") + + # Convert to bytes ----------------------------------------------------- + serial = 0x8000000000000000 # "Not XRP" bit set + if sign == 0: + serial |= 0x4000000000000000 # "Is positive" bit set + serial |= ((exp+97) << 54) # next 8 bits are exponent + serial |= mantissa # last 54 bits are mantissa + + return serial.to_bytes(8, byteorder="big", signed=False) + + + def canonical_zero_serial(self): + """ + Returns canonical format for zero: + - "Not XRP" bit = 1 + - "Sign bit" = 1 (for positive !!) + - exponent ??? + """ + # Mantissa is all zeroes. Must be positive zero. + #bitval = 0b1100000001000000000000000000000000000000000000000000000000000000 + #return bitval.to_bytes(8, byteorder="big", signed=False) + return (0x8000000000000000).to_bytes(8, byteorder="big", signed=False)