import 'dart:typed_data'; import 'package:hex/hex.dart'; import 'package:convert/convert.dart'; // The Structure enum enum Structure { HasAmount, HasNFT, HasCommitmentLength, } // The Capability enum enum Capability { NoCapability, Mutable, Minting, } // Used as a "custom tuple" for the supporting functions of readCompactSize to return // a convenient data structure. class CompactSizeResult { final int amount; final int bytesRead; CompactSizeResult({required this.amount, required this.bytesRead}); } // This class is a data structure representing the entire output, comprised of both the // normal Script pub key and the token data. We get this after we parse/unwrap the raw // output. class ParsedOutput { List? script_pub_key; TokenOutputData? token_data; ParsedOutput({this.script_pub_key, this.token_data}); } // This is equivalent to the Electron Cash python's "OutputData" in token.py. // Named here specifically as "TokenOutputData" to reflect the fact that // it is specifically for tokens, whereas the other class ParsedOutput represents // the entire output, comprised of both the normal Script pub key and the token data. class TokenOutputData { Uint8List? id; int? amount; Uint8List? commitment; Uint8List? bitfield; // A byte (Uint8List of length 1) // Constructor TokenOutputData({ this.id, this.amount, this.commitment, this.bitfield, }); // Get the "capability", see Capability enum. int getCapability() { if (bitfield != null) { return bitfield![0] & 0x0f; } return 0; } // functions to return attributes of the token bitfield. bool hasCommitmentLength() { if (bitfield != null) { return (bitfield![0] & 0x40) != 0; } return false; } bool hasAmount() { if (bitfield != null) { return (bitfield![0] & 0x10) != 0; } return false; } bool hasNFT() { if (bitfield != null) { return (bitfield![0] & 0x20) != 0; } return false; } // Functions to return specific attributes based on the Capability. bool isMintingNFT() { return hasNFT() && getCapability() == Capability.Minting.index; } bool isMutableNFT() { return hasNFT() && getCapability() == Capability.Mutable.index; } bool isImmutableNFT() { return hasNFT() && getCapability() == Capability.NoCapability.index; } // This function validates if the bitfield makes sense or violates known rules/logic. bool isValidBitfield() { if (bitfield == null) { return false; } int s = bitfield![0] & 0xf0; if (s >= 0x80 || s == 0x00) { return false; } if (bitfield![0] & 0x0f > 2) { return false; } if (!hasNFT() && !hasAmount()) { return false; } if (!hasNFT() && (bitfield![0] & 0x0f) != 0) { return false; } if (!hasNFT() && hasCommitmentLength()) { return false; } return true; } // The serialze and deserialize functions are the nuts and bolts of how we unpack // and pack outputs. These are called by the wrap and unwrap functions. int deserialize(Uint8List buffer, {int cursor = 0, bool strict = false}) { try { this.id = buffer.sublist(cursor, cursor + 32); cursor += 32; this.bitfield = Uint8List.fromList([buffer[cursor]]); cursor += 1; if (this.hasCommitmentLength()) { // Read the first byte to determine the length of the commitment data int commitmentLength = buffer[cursor]; // Move cursor to the next byte cursor += 1; // Read 'commitmentLength' bytes for the commitment data this.commitment = buffer.sublist(cursor, cursor + commitmentLength); // Adjust the cursor by the length of the commitment data cursor += commitmentLength; } else { this.commitment = null; } if (this.hasAmount()) { // Use readCompactSize that returns CompactSizeResult CompactSizeResult result = readCompactSize(buffer, cursor, strict: strict); this.amount = result.amount; cursor += result.bytesRead; } else { this.amount = 0; } if (!this.isValidBitfield() || (this.hasAmount() && this.amount == 0) || (this.amount! < 0 || this.amount! > (1 << 63) - 1) || (this.hasCommitmentLength() && this.commitment!.isEmpty) || (this.amount! == 0 && !this.hasNFT()) ) { throw Exception('Unable to parse token data or token data is invalid'); } return cursor; // Return the number of bytes read } catch (e) { throw Exception('Deserialization failed: $e'); } } // Serialize method Uint8List serialize() { var buffer = BytesBuilder(); // write ID and bitfield buffer.add(this.id!); buffer.addByte(this.bitfield![0]); // Write optional fields if (this.hasCommitmentLength()) { buffer.add(this.commitment!); } if (this.hasAmount()) { List compactSizeBytes = writeCompactSize(this.amount!); buffer.add(compactSizeBytes); } return buffer.toBytes(); } } //END OF OUTPUTDATA CLASS // The prefix byte is specified by the CashTokens spec. final List PREFIX_BYTE = [0xef]; // This function wraps a "normal" output together with token data. ParsedOutput wrap_spk(TokenOutputData? token_data, Uint8List script_pub_key) { ParsedOutput parsedOutput = ParsedOutput(); if (token_data == null) { parsedOutput.script_pub_key = script_pub_key; return parsedOutput; } final buf = BytesBuilder(); buf.add(PREFIX_BYTE); buf.add(token_data.serialize()); buf.add(script_pub_key); parsedOutput.script_pub_key = buf.toBytes(); parsedOutput.token_data = token_data; return parsedOutput; } // This function unwraps any output, either "normal" (containing no token data) // or an output with token data. If no token data, just the output is returned, // and if token data exists, both the output and token data are returned. // Note that the data returend in both cases in of ParsedOutput type, which // holds both the script pub key and token data. ParsedOutput unwrap_spk(Uint8List wrapped_spk) { ParsedOutput parsedOutput = ParsedOutput(); if (wrapped_spk.isEmpty || wrapped_spk[0] != PREFIX_BYTE[0]) { parsedOutput.script_pub_key = wrapped_spk; return parsedOutput; } int read_cursor = 1; // Start after the PREFIX_BYTE TokenOutputData token_data = TokenOutputData(); Uint8List wrapped_spk_without_prefix_byte; try { // Deserialize updates read_cursor by the number of bytes read wrapped_spk_without_prefix_byte= wrapped_spk.sublist(read_cursor); int bytesRead = token_data.deserialize(wrapped_spk_without_prefix_byte); read_cursor += bytesRead; parsedOutput.token_data = token_data; parsedOutput.script_pub_key = wrapped_spk.sublist(read_cursor); } catch (e) { // If unable to deserialize, return all bytes as the full scriptPubKey parsedOutput.script_pub_key = wrapped_spk; } return parsedOutput; } // Just a testing function which can be called in standalone fashion. // Replace "var1" with a hex string containing an output (script pub key) void testUnwrapSPK() { // Example Hex format string String var1 = "YOUR-SCRIPT-PUBKEY-AS-HEX-STRING-FOR-TESTING-GOES-HERE"; // Convert the Hex string to Uint8List Uint8List wrapped_spk = Uint8List.fromList(HEX.decode(var1)); // Call unwrap_spk ParsedOutput parsedOutput = unwrap_spk(wrapped_spk); print("Parsed Output: $parsedOutput"); // Access token_data inside parsedOutput TokenOutputData? tokenData = parsedOutput.token_data; // Check if tokenData is null if (tokenData != null) { // Print specific fields if (tokenData.id != null) { print("ID: ${hex.encode(tokenData.id!)}"); // hex is imported } else { print("ID: null"); } print ("amount of tokens"); print (tokenData.amount); print("Is it an NFT?: ${tokenData.hasNFT()}"); } else { print("Token data is null."); } } //end function // HELPER FUNCTIONS //These are part of a "length value " scheme where the length (and endianness) are given first // and inform the program of how many bytes to grab next. These are in turn used by the serialize // and deserialize functions.- CompactSizeResult readCompactSize(Uint8List buffer, int cursor, {bool strict = false}) { int bytesRead = 0; // Variable to count bytes read int val; try { val = buffer[cursor]; cursor += 1; bytesRead += 1; int minVal; if (val == 253) { val = buffer.buffer.asByteData().getUint16(cursor, Endian.little); cursor += 2; bytesRead += 2; minVal = 253; } else if (val == 254) { val = buffer.buffer.asByteData().getUint32(cursor, Endian.little); cursor += 4; bytesRead += 4; minVal = 1 << 16; } else if (val == 255) { val = buffer.buffer.asByteData().getInt64(cursor, Endian.little); cursor += 8; bytesRead += 8; minVal = 1 << 32; } else { minVal = 0; } if (strict && val < minVal) { throw Exception("CompactSize is not minimally encoded"); } return CompactSizeResult(amount: val, bytesRead: bytesRead); } catch (e) { throw Exception("attempt to read past end of buffer"); } } Uint8List writeCompactSize(int size) { var buffer = ByteData(9); // Maximum needed size for compact size is 9 bytes if (size < 0) { throw Exception("attempt to write size < 0"); } else if (size < 253) { return Uint8List.fromList([size]); } else if (size < (1 << 16)) { buffer.setUint8(0, 253); buffer.setUint16(1, size, Endian.little); return buffer.buffer.asUint8List(0, 3); } else if (size < (1 << 32)) { buffer.setUint8(0, 254); buffer.setUint32(1, size, Endian.little); return buffer.buffer.asUint8List(0, 5); } else if (size < (1 << 64)) { buffer.setUint8(0, 255); buffer.setInt64(1, size, Endian.little); return buffer.buffer.asUint8List(0, 9); } else { throw Exception("Size too large to represent as CompactSize"); } }