From cedd75a8a4608493ce7c2cd1c9841496d4273395 Mon Sep 17 00:00:00 2001 From: Patrick Niemeyer Date: Thu, 22 Feb 2024 20:43:19 -0600 Subject: [PATCH] app: Port Orchid lottery ticket code to Dart with tests. --- gui-orchid/lib/api/orchid_crypto.dart | 5 + gui-orchid/lib/api/orchid_eth/abi_encode.dart | 43 +++++ .../lib/api/orchid_eth/orchid_ticket.dart | 152 ++++++++++++++++++ gui-orchid/lib/api/orchid_keys.dart | 3 +- gui-orchid/lib/util/hex.dart | 1 + gui-orchid/test/ticket_test.dart | 102 ++++++++++++ 6 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 gui-orchid/lib/api/orchid_eth/orchid_ticket.dart create mode 100644 gui-orchid/test/ticket_test.dart diff --git a/gui-orchid/lib/api/orchid_crypto.dart b/gui-orchid/lib/api/orchid_crypto.dart index 81e72cd26..d8ce23dbd 100644 --- a/gui-orchid/lib/api/orchid_crypto.dart +++ b/gui-orchid/lib/api/orchid_crypto.dart @@ -89,6 +89,11 @@ class Crypto { static String uuid() { return Uuid(options: {'grng': UuidUtil.cryptoRNG}).v4(); } + + static String formatSecretFixed(BigInt private) { + return private.toRadixString(16).padLeft(64, '0'); + } + } class EthereumKeyPair { diff --git a/gui-orchid/lib/api/orchid_eth/abi_encode.dart b/gui-orchid/lib/api/orchid_eth/abi_encode.dart index ec560af70..622eae887 100644 --- a/gui-orchid/lib/api/orchid_eth/abi_encode.dart +++ b/gui-orchid/lib/api/orchid_eth/abi_encode.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import '../orchid_crypto.dart'; import 'package:orchid/util/strings.dart'; @@ -16,6 +18,10 @@ class AbiEncode { return value.toRadixString(16).padLeft(64, '0'); } + static String toHexBytes32(BigInt val) { + return '0x' + int256(val); + } + static String uint128(BigInt value) { return value.toUnsigned(128).toRadixString(16).padLeft(64, '0'); } @@ -69,3 +75,40 @@ class AbiEncodePacked { return (value & 0xff).toRadixString(16).padLeft(2, '0'); } } + +// Add some extension methods to BigInt +extension BigIntExtension on BigInt { + Uint8List toBytes32() { + return toBytesUint256(); + } + + Uint8List toBytesUint256() { + final number = this; + // Assert the number is non-negative and fits within 256 bits + assert(number >= BigInt.zero && number < (BigInt.one << 256), + 'Number must be non-negative and less than 2^256'); + var byteData = number.toRadixString(16).padLeft(64, '0'); // Ensure 32 bytes + var result = Uint8List(32); + for (int i = 0; i < byteData.length; i += 2) { + var byteString = byteData.substring(i, i + 2); + var byteValue = int.parse(byteString, radix: 16); + result[i ~/ 2] = byteValue; + } + return result; + } + + Uint8List toBytesUint128() { + final number = this; + // Assert the number is non-negative and fits within 128 bits + assert(number >= BigInt.zero && number < (BigInt.one << 128), + 'Number must be non-negative and less than 2^128'); + var byteData = number.toRadixString(16).padLeft(32, '0'); // Ensure 16 bytes + var result = Uint8List(16); + for (int i = 0; i < byteData.length; i += 2) { + var byteString = byteData.substring(i, i + 2); + var byteValue = int.parse(byteString, radix: 16); + result[i ~/ 2] = byteValue; + } + return result; + } +} diff --git a/gui-orchid/lib/api/orchid_eth/orchid_ticket.dart b/gui-orchid/lib/api/orchid_eth/orchid_ticket.dart new file mode 100644 index 000000000..27050ce63 --- /dev/null +++ b/gui-orchid/lib/api/orchid_eth/orchid_ticket.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'package:convert/convert.dart'; +import 'package:orchid/util/hex.dart'; +import 'dart:typed_data'; +import 'package:web3dart/crypto.dart'; +import '../orchid_crypto.dart'; +import 'abi_encode.dart'; +import 'package:web3dart/credentials.dart' as web3; + +// Orchid Lottery ticket serialization and evaluation. +// This is direct port of the JS version of this class. +// @see ticket_test for validation. +class OrchidTicket { + final uint64 = BigInt.from(2).pow(64) - BigInt.one; + final uint128 = BigInt.from(2).pow(128) - BigInt.one; + final addrtype = (BigInt.from(2).pow(160)) - BigInt.one; + + late BigInt packed0, packed1; + late String sig_r, sig_s; + + OrchidTicket({ + required BigInt data, // uint256 + required EthereumAddress lotaddr, + required EthereumAddress token, + required BigInt amount, // uint128 + required BigInt ratio, // uint64 + required EthereumAddress funder, + required EthereumAddress recipient, + required BigInt commitment, // bytes32 + required BigInt privateKey, // uint256 + int? millisecondsSinceEpoch, + }) { + this.initTicketData(data, lotaddr, token, amount, ratio, funder, recipient, + commitment, privateKey, + millisecondsSinceEpoch: millisecondsSinceEpoch); + } + + OrchidTicket.fromPacked( + this.packed0, + this.packed1, + this.sig_r, + this.sig_s, + ); + + OrchidTicket.fromSerialized(String str) { + final ticket = []; + for (var i = 0; i < str.length; i += 64) { + ticket.add(str.substring(i, i + 64)); + } + this.packed0 = BigInt.parse(ticket[0], radix: 16); + this.packed1 = BigInt.parse(ticket[1], radix: 16); + this.sig_r = ticket[2].startsWith("0x") ? ticket[2] : '0x' + ticket[2]; + this.sig_s = ticket[3].startsWith("0x") ? ticket[3] : '0x' + ticket[3]; + } + + void initTicketData( + BigInt data, // uint256 + EthereumAddress lotaddr, + EthereumAddress token, + BigInt amount, + BigInt ratio, + EthereumAddress funder, + EthereumAddress recipient, + BigInt commitment, + BigInt privateKey, { + int? millisecondsSinceEpoch, + }) { + DateTime nowUtc; + if (millisecondsSinceEpoch != null) { + // print('millisecondsSinceEpoch: $millisecondsSinceEpoch'); + nowUtc = DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch, + isUtc: true); + } else { + nowUtc = DateTime.now().toUtc(); + } + final dateForNonce = + '${nowUtc.toIso8601String().replaceFirst('T', ' ').replaceFirst('Z', '')}000'; + Uint8List hash = keccak256(Uint8List.fromList(utf8.encode(dateForNonce))); + final hashhex = bytesToHex(hash); + final hashint = BigInt.parse(hashhex, radix: 16); + final l2nonce = hashint & uint64; + BigInt expire = BigInt.from(2).pow(31) - BigInt.from(1); + final issued = BigInt.from(nowUtc.millisecondsSinceEpoch / 1000); + BigInt packed0 = (issued << 192) | (l2nonce << 128) | amount; + BigInt packed1 = (expire << 224) | + (ratio << 160) | + BigInt.parse(funder.toString().substring(2), radix: 16); + + final encoded = '' + + AbiEncodePacked.bytes1(0x19) + + AbiEncodePacked.bytes1(0x00) + + AbiEncodePacked.address(lotaddr) + + '64'.padLeft(64, '0') + + AbiEncodePacked.address(token) + + AbiEncodePacked.address(recipient) + + bytesToHex(keccak256(commitment.toBytes32())) + + AbiEncodePacked.uint256(packed0) + + AbiEncodePacked.uint256(packed1) + + AbiEncodePacked.uint256(data); + + final payload = Uint8List.fromList(hex.decode(encoded)); + final credentials = + web3.EthPrivateKey.fromHex(Crypto.formatSecretFixed(privateKey)); + MsgSignature sig = sign(keccak256(payload), credentials.privateKey); + + packed1 = (packed1 << 1) | BigInt.from((sig.v - 27) & 1); + this.packed0 = packed0; + this.packed1 = packed1; + this.sig_r = AbiEncode.toHexBytes32(sig.r); + this.sig_s = AbiEncode.toHexBytes32(sig.s); + } + + String serializeTicket() { + return AbiEncode.uint256(this.packed0) + + AbiEncode.uint256(this.packed1) + + Hex.remove0x(sig_r) + + Hex.remove0x(sig_s); + } + + BigInt nonce() { + return (packed0 >> 128) & uint64; + } + + bool isWinner(String reveal) { + final ratio = uint64 & (packed1 >> 161); + final revealBytes = Hex.parseBigInt(reveal).toBytesUint256(); + final nonceBytes = nonce().toBytesUint128(); + final message = Uint8List.fromList([...revealBytes, ...nonceBytes]); + final Uint8List digest = keccak256(message); + final hash = BigInt.parse(bytesToHex(digest), radix: 16); + final comp = uint64 & hash; + return ratio >= comp; + } + + void printTicket() { + final amount = packed0 & uint128; + final funder = addrtype & (packed1 >> 1); + final ratio = uint64 & (packed1 >> 161); + + print('Ticket data:'); + // print(' Data: ${parseInt(this.data, 16)}'); + // print(' Reveal: ${this.commitment}'); + print(' Packed0: ${this.packed0}'); + print(' Packed1: ${this.packed1}'); + print('Packed data:'); + print(' Amount: $amount'); + print(' Nonce: ${nonce()}'); + print(' Funder: $funder'); + print(' Ratio: $ratio'); + print('r: ${this.sig_r}\ns: ${this.sig_s}'); + } +} diff --git a/gui-orchid/lib/api/orchid_keys.dart b/gui-orchid/lib/api/orchid_keys.dart index eb098fea3..0793ebd06 100644 --- a/gui-orchid/lib/api/orchid_keys.dart +++ b/gui-orchid/lib/api/orchid_keys.dart @@ -55,9 +55,10 @@ class StoredEthereumKey { /// Format the secret as a 64 character hex string, zero padded, without prefix. String formatSecretFixed() { - return private.toRadixString(16).padLeft(64, '0'); + return Crypto.formatSecretFixed(private); } + String toExportString() { return 'account={ secret: "${formatSecretFixed()}" }'; } diff --git a/gui-orchid/lib/util/hex.dart b/gui-orchid/lib/util/hex.dart index 0dcaef734..ddc881802 100644 --- a/gui-orchid/lib/util/hex.dart +++ b/gui-orchid/lib/util/hex.dart @@ -39,6 +39,7 @@ class Hex { } } + // Or use hex.decode() from: 'package:convert/convert.dart'; static List decodeBytes(String hexStr) { hexStr = remove0x(hexStr); if (hexStr.isEmpty) { diff --git a/gui-orchid/test/ticket_test.dart b/gui-orchid/test/ticket_test.dart new file mode 100644 index 000000000..4c5ce661e --- /dev/null +++ b/gui-orchid/test/ticket_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:orchid/api/orchid_crypto.dart'; +import 'package:orchid/api/orchid_eth/orchid_ticket.dart'; + +void main() { + group('ticket tests', () { + /* + JS version output: + Ticket data: + Data: NaN + Reveal: undefined + Packed0: 0 + Packed1: 74418616462743697996404837125958524653305997647975479859375788807275769590323 + r: 0x0000000065d6bdd66f8fcd69afdfec200000000000000000016345785d8a0000 s: 0xffffffffccccccccccccd000e04d6ec797cfa9ce4093d4cfd1264c8654a1df09 + Packed data: + Amount: 0 + Nonce: 0 + Funder: 840389638478969449152772927472898064667089090841 + Ratio: 10077190556129977236 + */ + test('Test serialization', () async { + print("Test serialization round trip"); + final ser1 = + '0000000000000000000000000000000000000000000000000000000000000000' + 'a48771bb17b2bed6d018c7292668bfda9baa2ccc048b99752fdb684789d97233' + '0000000065d6bdd66f8fcd69afdfec200000000000000000016345785d8a0000' + 'ffffffffccccccccccccd000e04d6ec797cfa9ce4093d4cfd1264c8654a1df09'; + // Two extra fields in this serialized version? + // The JS lib ignores these as does our impl. + // '7b50455687184c0a1f5ff0be3b4b802a3b2a15205a94d33655b1a7529967c9c9' + // '75f7ee1ed8af30f42902eee69e91c6ca7a936942db9878f34442b187203c2cbb'; + final ticket = OrchidTicket.fromSerialized(ser1); + // ticket.printTicket(); + expect(ticket.packed0, BigInt.zero); + expect(ticket.packed1.toString(), + '74418616462743697996404837125958524653305997647975479859375788807275769590323'); + final ser2 = ticket.serializeTicket(); + expect(ser2, ser1); + }); + + /* + JS output: + Ticket data: + Packed0: 10725299090569305319344573190133412787358038370907851216529272602624 + Packed1: 115792089210356248756420345214244490354657239511130867306431705402380410144357 + Packed data: + Amount: 2000000000000000000 + Nonce: 10444928296939929927 + Funder: 111798794203442759563723844757346937785445376818 + Ratio: 9223372036854775808 + r: 0xd73c001751ebd66407ef8cb61ffc2a77757d2da4e7ea853af744d1123e68fb4a + s: 0x03cc65f3e60f1b007609f69f464a1ec2ae10b39805116434f8c647d63c1e7bb6 + */ + test('Test construction', () async { + print("Test construction"); + final funder = + EthereumAddress.from('0x13953B378987A76c65F7041BE8CE983381d5E332'); + final signer_key = BigInt.parse( + '0x1cf5423866f216ecc2ed50c79447249604d274099e1f8e106dde3a5a6eaea365'); + final recipient = + EthereumAddress.from('0x405BC10E04e3f487E9925ad5815E4406D78B769e'); + final amountf = 1.0; + final amount = BigInt.from(2000000000000000000) * BigInt.from(amountf); + final data = BigInt.zero; + final lotaddr = + EthereumAddress.from('0x6dB8381b2B41b74E17F5D4eB82E8d5b04ddA0a82'); + final token = EthereumAddress.zero; + final ratio = BigInt.parse('9223372036854775808'); + final commit = BigInt.parse('0x100'); + final ticket = OrchidTicket( + data: data, + lotaddr: lotaddr, + token: token, + amount: amount, + ratio: ratio, + funder: funder, + recipient: recipient, + commitment: commit, + privateKey: signer_key, + millisecondsSinceEpoch: 1708638722494, + ); + // ticket.printTicket(); + expect(ticket.packed0.toString(), + '10725299090569305319344573190133412787358038370907851216529272602624'); + expect(ticket.packed1.toString(), + '115792089210356248756420345214244490354657239511130867306431705402380410144357'); + expect(ticket.sig_r, + '0xd73c001751ebd66407ef8cb61ffc2a77757d2da4e7ea853af744d1123e68fb4a'); + expect(ticket.sig_s, + '0x03cc65f3e60f1b007609f69f464a1ec2ae10b39805116434f8c647d63c1e7bb6'); + + print("Test winner"); + // test winner (values from the JS test) + expect(ticket.isWinner('0x00'), true); + expect(ticket.isWinner('0x01'), true); + expect(ticket.isWinner('0x05'), false); + expect(ticket.isWinner('0x07'), false); + expect(ticket.isWinner('0x0D'), true); + + }); + }); +}