From 060382d8a14f87dbb44064e88d8538bd93bbaf47 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Sat, 28 Oct 2023 07:25:37 +0100 Subject: [PATCH] clients/py: fixed-up unit tests + packaging + readme --- clients/py/.gitignore | 6 +- clients/py/Makefile | 16 +++ clients/py/README.md | 16 +++ clients/py/kat.py | 108 ------------------ clients/py/sapphirepy/deoxysii.py | 11 +- .../py/sapphirepy/{cipher.py => envelope.py} | 51 +++++---- clients/py/sapphirepy/error.py | 3 + clients/py/sapphirepy/sapphire.py | 42 +++++-- clients/py/sapphirepy/tests/__init__.py | 0 clients/py/sapphirepy/tests/test_deoxysii.py | 103 +++++++++++++++++ clients/py/sapphirepy/tests/test_e2e.py | 88 ++++++++++++++ .../Deoxys-II-256-128-official-20190608.json | 0 .../tests}/testdata/Deoxys-II-256-128.json | 0 .../py/sapphirepy/tests/testdata/Greeter.abi | 1 + .../py/sapphirepy/tests/testdata/Greeter.bin | 1 + .../py/sapphirepy/tests/testdata/Greeter.sol | 33 ++++++ clients/py/setup.py | 28 ++--- clients/py/test.py | 60 ---------- 18 files changed, 345 insertions(+), 222 deletions(-) create mode 100644 clients/py/Makefile create mode 100644 clients/py/README.md delete mode 100644 clients/py/kat.py rename clients/py/sapphirepy/{cipher.py => envelope.py} (75%) create mode 100644 clients/py/sapphirepy/error.py create mode 100644 clients/py/sapphirepy/tests/__init__.py create mode 100644 clients/py/sapphirepy/tests/test_deoxysii.py create mode 100644 clients/py/sapphirepy/tests/test_e2e.py rename clients/py/{ => sapphirepy/tests}/testdata/Deoxys-II-256-128-official-20190608.json (100%) rename clients/py/{ => sapphirepy/tests}/testdata/Deoxys-II-256-128.json (100%) create mode 100644 clients/py/sapphirepy/tests/testdata/Greeter.abi create mode 100644 clients/py/sapphirepy/tests/testdata/Greeter.bin create mode 100644 clients/py/sapphirepy/tests/testdata/Greeter.sol delete mode 100644 clients/py/test.py diff --git a/clients/py/.gitignore b/clients/py/.gitignore index ed8ebf583..c54cd045c 100644 --- a/clients/py/.gitignore +++ b/clients/py/.gitignore @@ -1 +1,5 @@ -__pycache__ \ No newline at end of file +__pycache__ +dist +build +.mypy_cache +*.egg-info \ No newline at end of file diff --git a/clients/py/Makefile b/clients/py/Makefile new file mode 100644 index 000000000..f1eb1b80c --- /dev/null +++ b/clients/py/Makefile @@ -0,0 +1,16 @@ +PYTHON?=python3 +MODULE=sapphirepy + +all: mypy test wheel + +mypy: + $(PYTHON) -mmypy $(MODULE) --check-untyped-defs + +test: + $(PYTHON) -munittest discover + +clean: + rm -rf sapphirepy/__pycache__ build dist sapphire.py.egg-info .mypy_cache + +wheel: + $(PYTHON) setup.py bdist_wheel diff --git a/clients/py/README.md b/clients/py/README.md new file mode 100644 index 000000000..147543c24 --- /dev/null +++ b/clients/py/README.md @@ -0,0 +1,16 @@ +# sapphire.py + +```python +from web3 import Web3 +from sapphirepy import sapphire + +# Setup your Web3 provider with a signing account +w3 = Web3(Web3.HTTPProvider('http://localhost:8545')) +w3.middleware_onion.add(construct_sign_and_send_raw_middleware(account)) + +# Finally, wrap the provider to add Sapphire end-to-end encryption +w3 = sapphire.wrap(w3) +``` + +The Sapphire middleware for Web3.py ensures all transactions, gas estimates and view calls are end-to-end encrypted between your application and the smart contract. + diff --git a/clients/py/kat.py b/clients/py/kat.py deleted file mode 100644 index 9698ecb66..000000000 --- a/clients/py/kat.py +++ /dev/null @@ -1,108 +0,0 @@ -import os -import sys -from base64 import b64decode -from dataclasses import dataclass -import json - -TESTDATA = os.path.join(os.path.dirname(__file__), 'testdata') - -from deoxysii import VartimeInstance, TagSize - -def katTests(): - fn = os.path.join(TESTDATA, 'Deoxys-II-256-128.json') - with open(fn, 'r') as handle: - data = json.load(handle) - key = b64decode(data['Key']) - nonce = b64decode(data['Nonce']) - assert len(nonce) == 15 - msg = b64decode(data['MsgData']) - ad = b64decode(data['AADData']) - x = VartimeInstance(key) - - off = 0 - - for row in data['KnownAnswers']: - ptLen = int(row['Length']) - m, a = msg[:ptLen], ad[:ptLen] - - expectedDst = bytearray() - expectedDst += b64decode(row['Ciphertext']) - expectedDst += b64decode(row['Tag']) - expectedC = expectedDst[off:] - - dst = bytearray(ptLen + TagSize) - x.E(nonce, dst, a, m) - c = dst[off:] - assert c[:ptLen] == expectedC[:ptLen] - - p = bytearray(ptLen) - assert x.D(nonce, p, a, c) - - # Test malformed ciphertext (or tag). - badC = c[:] - badC[ptLen] ^= 0x23 - p = bytearray(ptLen) - assert False == x.D(nonce, p, a, badC) - - # Test malformed AD. - if ptLen > 0: - badA = bytearray(a[:]) - badA[ptLen-1] ^= 0x23 - p = bytearray(ptLen) - assert False == x.D(nonce, p, badA, c) - - -@dataclass -class OfficialTestVector: - name: str - key: bytes - nonce: bytes - ad: bytes|None - msg: bytes - sealed: bytes - -def officialTests(): - fn = os.path.join(TESTDATA, 'Deoxys-II-256-128-official-20190608.json') - with open(fn, 'r') as handle: - data = json.load(handle) - for row in data: - t = OfficialTestVector( - row['Name'], - bytes.fromhex(row['Key']), - bytes.fromhex(row['Nonce']), - bytes.fromhex(row['AssociatedData']) if row['AssociatedData'] else b"", - bytes.fromhex(row['Message']) if row['Message'] else b"", - bytes.fromhex(row['Sealed']) - ) - #print(t.name) - #print('\t Key:', t.key.hex()) - #print('\t Nonce:', t.nonce.hex()) - #print('\t AD:', t.ad.hex()) - #print('\t Msg:', t.msg.hex()) - #print('\tSealed:', t.sealed.hex()) - - x = VartimeInstance(t.key) - - # Verify encryption matches - ciphertext = bytearray(len(t.sealed)) - x.E(t.nonce, ciphertext, t.ad, t.msg) - #print('\t Enc:', ciphertext == t.sealed) - assert ciphertext == t.sealed - - # Verify decryption matches - plaintext = bytearray(len(t.msg) if t.msg else 0) - result = x.D(t.nonce, plaintext, t.ad, t.sealed) - #print('\t Dec:', result, plaintext == t.msg) - #print('\t PT:', plaintext.hex()) - assert result - assert plaintext == t.msg - #print() - return 0 - -def main(bn, *args): - officialTests() - katTests() - return 0 - -if __name__ == "__main__": - sys.exit(main(*sys.argv)) diff --git a/clients/py/sapphirepy/deoxysii.py b/clients/py/sapphirepy/deoxysii.py index 2e15f10ad..7b6e51434 100644 --- a/clients/py/sapphirepy/deoxysii.py +++ b/clients/py/sapphirepy/deoxysii.py @@ -24,6 +24,7 @@ import struct import array +import hmac BlockSize = 16 @@ -377,10 +378,10 @@ def __init__(self, key:bytes|bytearray): self.derivedKs = STKDeriveK(key) @property - def name(self): + def implementation(self): return "vartime" - def E(self, nonce:bytes|bytearray, dst:bytearray, ad:bytes|bytearray, msg:bytes|bytearray): + def E(self, nonce:bytes|bytearray, dst:bytearray, ad:bytes|bytearray|None, msg:bytes|bytearray): assert len(nonce) == (BlockSize-1) tweak = bytearray(TweakSize) tmp = bytearray(BlockSize) @@ -460,7 +461,7 @@ def E(self, nonce:bytes|bytearray, dst:bytearray, ad:bytes|bytearray, msg:bytes| dst[len(dst)-TagSize:] = tag - def D(self, nonce:bytes|bytearray, dst:bytearray, ad:bytes|bytearray, ciphertext:bytes|bytearray): + def D(self, nonce:bytes|bytearray, dst:bytearray, ad:bytes|bytearray|None, ciphertext:bytes|bytearray): assert len(nonce) == TagSize-1 assert len(dst) == len(ciphertext) - TagSize @@ -542,8 +543,8 @@ def D(self, nonce:bytes|bytearray, dst:bytearray, ad:bytes|bytearray, ciphertext decNonce[0] = PrefixTag << PrefixShift bcEncrypt(tagP, self.derivedKs, decNonce, tagP) - # Tag verification, XXX: variable time - return tag == tagP + # Tag verification + return hmac.compare_digest(tag, tagP) def deriveSubTweakKeys(derivedKs:list[bytearray], t:bytearray): assert len(derivedKs) == STKCount diff --git a/clients/py/sapphirepy/cipher.py b/clients/py/sapphirepy/envelope.py similarity index 75% rename from clients/py/sapphirepy/cipher.py rename to clients/py/sapphirepy/envelope.py index 3945fa54c..97ecb7e34 100644 --- a/clients/py/sapphirepy/cipher.py +++ b/clients/py/sapphirepy/envelope.py @@ -2,12 +2,13 @@ from binascii import unhexlify import hmac -from os import urandom +import cbor2 from nacl.bindings.crypto_scalarmult import crypto_scalarmult +from nacl.utils import random from nacl.public import PrivateKey, PublicKey -import cbor2 from .deoxysii import DeoxysII, NonceSize, TagSize +from .error import SapphireError ############################################################################### # CBOR encoded envelope formats @@ -36,7 +37,7 @@ class RequestBody(TypedDict): pk: bytes data: bytes nonce: bytes - epoch: Optional[int] + epoch: int class RequestEnvelope(TypedDict): body: dict @@ -45,13 +46,13 @@ class RequestEnvelope(TypedDict): ############################################################################### # Errors relating to encryption, decryption & envelope format -class SapphireBaseError(Exception): +class EnvelopeError(SapphireError): pass -class SapphireFailError(SapphireBaseError): +class DecryptError(SapphireError): pass -class SapphireUnknownError(SapphireBaseError): +class CallFailure(SapphireError): pass ############################################################################### @@ -61,63 +62,67 @@ def _deriveSharedSecret(pk:PublicKey, sk:PrivateKey): msg = crypto_scalarmult(sk.encode(), pk._public_key) return hmac.new(key, msg, digestmod='sha512_256').digest() -class TransactionEncrypter: - epoch:int|None +class TransactionCipher: + epoch:int cipher:DeoxysII - myPublicKey:bytes - def __init__(self, peerPublicKey:PublicKey|str, peerEpoch:int|None=None): + ephemeralPublicKey:bytes + + def __init__(self, peerPublicKey:PublicKey|str, peerEpoch:int): if isinstance(peerPublicKey, str): if len(peerPublicKey) != 66 or peerPublicKey[:2] != "0x": - raise RuntimeError('peerPublicKey.invalid', peerPublicKey) + raise ValueError('peerPublicKey.invalid', peerPublicKey) peerPublicKey = PublicKey(unhexlify(peerPublicKey[2:])) sk = PrivateKey.generate() - self.myPublicKey = sk.public_key.encode() + self.ephemeralPublicKey = sk.public_key.encode() self.cipher = DeoxysII(_deriveSharedSecret(peerPublicKey, sk)) self.epoch = peerEpoch def _encryptCallData(self, calldata: bytes): - nonce = urandom(NonceSize) + nonce = random(NonceSize) plaintext = cbor2.dumps({'body': calldata}, canonical=True) ciphertext = bytearray(len(plaintext) + TagSize) - self.cipher.E(nonce=nonce, dst=ciphertext, ad=b"", msg=plaintext) + self.cipher.E(nonce=nonce, dst=ciphertext, ad=None, msg=plaintext) return ciphertext, nonce def encrypt(self, plaintext: bytes): ciphertext, nonce = self._encryptCallData(plaintext) envelope:RequestEnvelope = { 'body': { - 'pk': self.myPublicKey, + 'pk': self.ephemeralPublicKey, 'data': ciphertext, - 'nonce': nonce + 'nonce': nonce, + 'epoch': self.epoch }, 'format': FORMAT_Encrypted_X25519DeoxysII } - if self.epoch: - envelope['body']['epoch'] = self.epoch return cbor2.dumps(envelope, canonical=True) def _decodeInner(self, plaintext:bytes) -> bytes: innerResult = cast(ResultInner, cbor2.loads(plaintext)) if innerResult.get('ok', None) is not None: return innerResult['ok'] - raise SapphireFailError(innerResult['fail']) + raise CallFailure(innerResult['fail']) def _decryptInner(self, envelope: AeadEnvelope): plaintext = bytearray(len(envelope['data']) - TagSize) decryptOk = self.cipher.D( nonce=envelope['nonce'], dst=plaintext, - ad=b"", + ad=None, ciphertext=envelope['data']) if not decryptOk: - raise RuntimeError('Failed to decrypt') + raise DecryptError() return self._decodeInner(plaintext) def decrypt(self, response: bytes): callResult = cast(ResultOuter, cbor2.loads(response)) + if not isinstance(callResult, dict): + raise EnvelopeError('callResult', type(callResult)) if callResult.get('failure', None) is not None: - raise SapphireFailError(callResult['failure']) + raise CallFailure(callResult['failure']) ok = callResult.get('ok', None) + if not isinstance(ok, dict): + raise EnvelopeError('callResult.ok', type(ok)) if ok is not None: return self._decryptInner(ok) - raise RuntimeError("No 'ok in call result!") + raise EnvelopeError("No 'ok in call result!") diff --git a/clients/py/sapphirepy/error.py b/clients/py/sapphirepy/error.py new file mode 100644 index 000000000..b1ab0f758 --- /dev/null +++ b/clients/py/sapphirepy/error.py @@ -0,0 +1,3 @@ + +class SapphireError(Exception): + pass diff --git a/clients/py/sapphirepy/sapphire.py b/clients/py/sapphirepy/sapphire.py index 80d750e98..a496ff0a8 100644 --- a/clients/py/sapphirepy/sapphire.py +++ b/clients/py/sapphirepy/sapphire.py @@ -5,12 +5,12 @@ from web3.types import RPCEndpoint, RPCResponse, TxParams from eth_typing import HexStr -from .cipher import TransactionEncrypter +from .envelope import TransactionCipher # Should transactions which deploy contracts be encrypted? ENCRYPT_DEPLOYS = False -# Number of epochs to keep from the latest one +# Number of epochs to keep public keys for EPOCH_LIMIT = 5 class CalldataPublicKey(TypedDict): @@ -27,7 +27,7 @@ def __init__(self): def _trim_and_sort(self, latestEpoch:int): self._keys = sorted([v for v in self._keys if v['epoch'] >= latestEpoch - EPOCH_LIMIT], - key=lambda o: o['epoch']) + key=lambda o: o['epoch'])[-EPOCH_LIMIT:] @property def newest(self): @@ -42,7 +42,7 @@ def add(self, pk:CalldataPublicKey): else: self._keys.append(pk) -def _shouldIntercept(method: RPCEndpoint, params: Any): +def _shouldIntercept(method: RPCEndpoint, params:tuple[TxParams]): if not ENCRYPT_DEPLOYS: if method in ('eth_sendTransaction', 'eth_estimateGas'): # When 'to' flag is missing, we assume it's a deployment @@ -50,14 +50,14 @@ def _shouldIntercept(method: RPCEndpoint, params: Any): return False return method in ('eth_estimateGas', 'eth_sendTransaction', 'eth_call') -def _encryptTxParams(pk:CalldataPublicKey, params: tuple[TxParams]): - c = TransactionEncrypter(peerPublicKey=pk['key'], peerEpoch=pk['epoch']) +def _encryptTxParams(pk:CalldataPublicKey, params:tuple[TxParams]): + c = TransactionCipher(peerPublicKey=pk['key'], peerEpoch=pk['epoch']) data = params[0]['data'] if isinstance(data, bytes): dataBytes = data elif isinstance(data, str): if len(data) < 2 or data[:2] != '0x': - raise RuntimeError('Data is not hex encoded!', data) + raise ValueError('Data is not hex encoded!', data) dataBytes = unhexlify(data[2:]) else: raise TypeError("Invalid 'data' type", type(data)) @@ -68,6 +68,24 @@ def _encryptTxParams(pk:CalldataPublicKey, params: tuple[TxParams]): def sapphire_middleware( make_request: Callable[[RPCEndpoint, Any], Any], w3: "Web3" ) -> Callable[[RPCEndpoint, Any], RPCResponse]: + """ + Transparently encrypt the calldata for: + + - eth_estimateGas + - eth_sendTransaction + - eth_call + + The calldata public key, which used to derive a shared secret with an + ephemeral key, is retrieved upon the first request. This key is rotated by + Sapphire every epoch, and only transactions encrypted with keys from the + last 5 epochs are considered valid. + + Deployment transactions will not be encrypted, unless the global + ENCRYPT_DEPLOYS flag is set. Encrypting deployments will prevent contracts + from being verified. + + Pre-signed transactions can't be encrypted if submitted via this instance. + """ manager = CalldataPublicKeyManager() def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: if _shouldIntercept(method, params): @@ -87,16 +105,16 @@ def middleware(method: RPCEndpoint, params: Any) -> RPCResponse: c = _encryptTxParams(pk, params) # We may encounter three errors here: - # core: invalid call format: epoch too far in the past - # core: invalid call format: Tag verification failed - # core: invalid call format: epoch in the future + # 'core: invalid call format: epoch too far in the past' + # 'core: invalid call format: Tag verification failed' + # 'core: invalid call format: epoch in the future' + # We can only do something meaningful with the first! result = cast(RPCResponse, make_request(method, params)) if result.get('error', None) is not None: error = result['error'] if not isinstance(error, str) and error['code'] == -32000: if error['message'] == 'core: invalid call format: epoch too far in the past': - # We can only handle this, we have outdated key - # force the re-fetch, and re-try submitting encrypted + # force the re-fetch, and encrypt with new key doFetch = True pk = None continue diff --git a/clients/py/sapphirepy/tests/__init__.py b/clients/py/sapphirepy/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/clients/py/sapphirepy/tests/test_deoxysii.py b/clients/py/sapphirepy/tests/test_deoxysii.py new file mode 100644 index 000000000..b3aa64730 --- /dev/null +++ b/clients/py/sapphirepy/tests/test_deoxysii.py @@ -0,0 +1,103 @@ +import os +import sys +import json +import unittest +from base64 import b64decode +from dataclasses import dataclass + +from sapphirepy.deoxysii import DeoxysII, TagSize + +TESTDATA = os.path.join(os.path.dirname(__file__), 'testdata') + +@dataclass +class OfficialTestVector: + name: str + key: bytes + nonce: bytes + ad: bytes|None + msg: bytes + sealed: bytes + +class TestDeoxysII(unittest.TestCase): + def test_kat(self): + fn = os.path.join(TESTDATA, 'Deoxys-II-256-128.json') + with open(fn, 'r') as handle: + data = json.load(handle) + key = b64decode(data['Key']) + nonce = b64decode(data['Nonce']) + assert len(nonce) == 15 + msg = b64decode(data['MsgData']) + ad = b64decode(data['AADData']) + x = DeoxysII(key) + + off = 0 + + for row in data['KnownAnswers']: + ptLen = int(row['Length']) + m, a = msg[:ptLen], ad[:ptLen] + + expectedDst = bytearray() + expectedDst += b64decode(row['Ciphertext']) + expectedDst += b64decode(row['Tag']) + expectedC = expectedDst[off:] + + dst = bytearray(ptLen + TagSize) + x.E(nonce, dst, a, m) + c = dst[off:] + self.assertEqual(c[:ptLen], expectedC[:ptLen]) + + p = bytearray(ptLen) + self.assertTrue(x.D(nonce, p, a, c)) + + # Test malformed ciphertext (or tag). + badC = c[:] + badC[ptLen] ^= 0x23 + p = bytearray(ptLen) + self.assertFalse(x.D(nonce, p, a, badC)) + + # Test malformed AD. + if ptLen > 0: + badA = bytearray(a[:]) + badA[ptLen-1] ^= 0x23 + p = bytearray(ptLen) + self.assertFalse(x.D(nonce, p, badA, c)) + + def test_official(self): + fn = os.path.join(TESTDATA, 'Deoxys-II-256-128-official-20190608.json') + with open(fn, 'r') as handle: + data = json.load(handle) + for row in data: + t = OfficialTestVector( + row['Name'], + bytes.fromhex(row['Key']), + bytes.fromhex(row['Nonce']), + bytes.fromhex(row['AssociatedData']) if row['AssociatedData'] else b"", + bytes.fromhex(row['Message']) if row['Message'] else b"", + bytes.fromhex(row['Sealed']) + ) + #print(t.name) + #print('\t Key:', t.key.hex()) + #print('\t Nonce:', t.nonce.hex()) + #print('\t AD:', t.ad.hex()) + #print('\t Msg:', t.msg.hex()) + #print('\tSealed:', t.sealed.hex()) + + x = DeoxysII(t.key) + + # Verify encryption matches + ciphertext = bytearray(len(t.sealed)) + x.E(t.nonce, ciphertext, t.ad, t.msg) + #print('\t Enc:', ciphertext == t.sealed) + self.assertEqual(ciphertext, t.sealed) + + # Verify decryption matches + plaintext = bytearray(len(t.msg) if t.msg else 0) + result = x.D(t.nonce, plaintext, t.ad, t.sealed) + #print('\t Dec:', result, plaintext == t.msg) + #print('\t PT:', plaintext.hex()) + self.assertTrue(result) + self.assertEqual(plaintext, t.msg) + #print() + +if __name__ == '__main__': + unittest.main() diff --git a/clients/py/sapphirepy/tests/test_e2e.py b/clients/py/sapphirepy/tests/test_e2e.py new file mode 100644 index 000000000..1b5e0286a --- /dev/null +++ b/clients/py/sapphirepy/tests/test_e2e.py @@ -0,0 +1,88 @@ +import os +import json +import unittest +from typing import Type + +from web3 import Web3 +from web3.exceptions import ContractLogicError, ContractCustomError +from web3.contract.contract import Contract +from web3.middleware import construct_sign_and_send_raw_middleware +from eth_account import Account +from eth_account.signers.local import LocalAccount + +from sapphirepy import sapphire + +TESTDATA = os.path.join(os.path.dirname(__file__), 'testdata') +GREETER_ABI = os.path.join(TESTDATA, 'Greeter.abi') +GREETER_BIN = os.path.join(TESTDATA, 'Greeter.bin') +GREETER_SOL = os.path.join(TESTDATA, 'Greeter.sol') + +def compiledTestContract(): + if not os.path.exists(GREETER_ABI): + from solcx import compile_source # type: ignore + with open(GREETER_SOL, 'r') as handle: + compiled_sol = compile_source( + handle.read(), + output_values=['abi', 'bin'] + ) + _, contract_interface = compiled_sol.popitem() + with open(GREETER_ABI, 'w') as handle: + handle.write(json.dumps(contract_interface['abi'])) + with open(GREETER_BIN, 'w') as handle: + handle.write(json.dumps(contract_interface['bin'])) + return { + 'abi': contract_interface['abi'], + 'bin': contract_interface['bin'], + } + with open(GREETER_ABI, 'r') as abi_handle: + with open(GREETER_BIN, 'r') as bin_handle: + return { + 'abi': json.load(abi_handle), + 'bin': json.load(bin_handle) + } + +class TestEndToEnd(unittest.TestCase): + greeter:Contract|Type[Contract] + w3: Web3 + + def setUp(self): + account: LocalAccount = Account.from_key("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + + w3 = Web3(Web3.HTTPProvider('http://localhost:8545')) + w3.middleware_onion.add(construct_sign_and_send_raw_middleware(account)) + self.w3 = w3 = sapphire.wrap(w3) + + w3.eth.default_account = account.address + + iface = compiledTestContract() + + Greeter = w3.eth.contract(abi=iface['abi'], bytecode=iface['bin']) + tx_hash = Greeter.constructor().transact({'gasPrice': w3.eth.gas_price}) + tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + self.greeter = w3.eth.contract(address=tx_receipt['contractAddress'], abi=iface['abi']) + + def test_viewCallRevertCustom(self): + with self.assertRaises(ContractCustomError) as cm: + self.greeter.functions.revertWithCustomError().call() + self.assertEqual(cm.exception.args[0], '0xb98c01130000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c746869734973437573746f6d0000000000000000000000000000000000000000') + + def test_viewCallRevertWithReason(self): + with self.assertRaises(ContractLogicError) as cm: + self.greeter.functions.revertWithReason().call() + self.assertEqual(cm.exception.message, 'execution reverted: reasonGoesHere') + + def test_viewCall(self): + self.assertEqual(self.greeter.functions.greet().call(), 'Hello') + + def test_transaction(self): + w3 = self.w3 + greeter = self.greeter + + x = self.greeter.functions.blah().transact({'gasPrice': w3.eth.gas_price}) + y = w3.eth.wait_for_transaction_receipt(x) + z = greeter.events.Greeting().process_receipt(y) + self.assertEqual(z[0].args['g'], 'Hello') + +if __name__ == '__main__': + unittest.main() diff --git a/clients/py/testdata/Deoxys-II-256-128-official-20190608.json b/clients/py/sapphirepy/tests/testdata/Deoxys-II-256-128-official-20190608.json similarity index 100% rename from clients/py/testdata/Deoxys-II-256-128-official-20190608.json rename to clients/py/sapphirepy/tests/testdata/Deoxys-II-256-128-official-20190608.json diff --git a/clients/py/testdata/Deoxys-II-256-128.json b/clients/py/sapphirepy/tests/testdata/Deoxys-II-256-128.json similarity index 100% rename from clients/py/testdata/Deoxys-II-256-128.json rename to clients/py/sapphirepy/tests/testdata/Deoxys-II-256-128.json diff --git a/clients/py/sapphirepy/tests/testdata/Greeter.abi b/clients/py/sapphirepy/tests/testdata/Greeter.abi new file mode 100644 index 000000000..6bf409320 --- /dev/null +++ b/clients/py/sapphirepy/tests/testdata/Greeter.abi @@ -0,0 +1 @@ +[{"inputs": [], "stateMutability": "nonpayable", "type": "constructor"}, {"inputs": [{"internalType": "string", "name": "blah", "type": "string"}], "name": "MyCustomError", "type": "error"}, {"anonymous": false, "inputs": [{"indexed": false, "internalType": "string", "name": "g", "type": "string"}], "name": "Greeting", "type": "event"}, {"inputs": [], "name": "blah", "outputs": [], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [], "name": "greet", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "greeting", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "revertWithCustomError", "outputs": [], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "revertWithReason", "outputs": [], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "string", "name": "_greeting", "type": "string"}], "name": "setGreeting", "outputs": [], "stateMutability": "nonpayable", "type": "function"}] \ No newline at end of file diff --git a/clients/py/sapphirepy/tests/testdata/Greeter.bin b/clients/py/sapphirepy/tests/testdata/Greeter.bin new file mode 100644 index 000000000..58b596c2f --- /dev/null +++ b/clients/py/sapphirepy/tests/testdata/Greeter.bin @@ -0,0 +1 @@ +"608060405234801562000010575f80fd5b506040518060400160405280600581526020017f48656c6c6f0000000000000000000000000000000000000000000000000000008152505f9081620000569190620002c1565b50620003a5565b5f81519050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f6002820490506001821680620000d957607f821691505b602082108103620000ef57620000ee62000094565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f60088302620001537fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8262000116565b6200015f868362000116565b95508019841693508086168417925050509392505050565b5f819050919050565b5f819050919050565b5f620001a9620001a36200019d8462000177565b62000180565b62000177565b9050919050565b5f819050919050565b620001c48362000189565b620001dc620001d382620001b0565b84845462000122565b825550505050565b5f90565b620001f2620001e4565b620001ff818484620001b9565b505050565b5b8181101562000226576200021a5f82620001e8565b60018101905062000205565b5050565b601f82111562000275576200023f81620000f5565b6200024a8462000107565b810160208510156200025a578190505b62000272620002698562000107565b83018262000204565b50505b505050565b5f82821c905092915050565b5f620002975f19846008026200027a565b1980831691505092915050565b5f620002b1838362000286565b9150826002028217905092915050565b620002cc826200005d565b67ffffffffffffffff811115620002e857620002e762000067565b5b620002f48254620000c1565b620003018282856200022a565b5f60209050601f83116001811462000337575f841562000322578287015190505b6200032e8582620002a4565b8655506200039d565b601f1984166200034786620000f5565b5f5b82811015620003705784890151825560018201915060208501945060208101905062000349565b868310156200039057848901516200038c601f89168262000286565b8355505b6001600288020188555050505b505050505050565b61096380620003b35f395ff3fe608060405234801561000f575f80fd5b5060043610610060575f3560e01c806346fc4bb1146100645780635b2dd1001461006e578063a413686214610078578063cfae321714610094578063e39dcc21146100b2578063ef690cc0146100bc575b5f80fd5b61006c6100da565b005b610076610115565b005b610092600480360381019061008d9190610409565b610157565b005b61009c610169565b6040516100a991906104ca565b60405180910390f35b6100ba6101f8565b005b6100c4610231565b6040516100d191906104ca565b60405180910390f35b6040517fb98c011300000000000000000000000000000000000000000000000000000000815260040161010c90610534565b60405180910390fd5b5f610155576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161014c9061059c565b60405180910390fd5b565b805f908161016591906107bd565b5050565b60605f8054610177906105e7565b80601f01602080910402602001604051908101604052809291908181526020018280546101a3906105e7565b80156101ee5780601f106101c5576101008083540402835291602001916101ee565b820191905f5260205f20905b8154815290600101906020018083116101d157829003601f168201915b5050505050905090565b7fa5263230ff6fc3abbbad333e24faf0f402b4e050b83a1d30ad4051f4e5d0f7275f604051610227919061090d565b60405180910390a1565b5f805461023d906105e7565b80601f0160208091040260200160405190810160405280929190818152602001828054610269906105e7565b80156102b45780601f1061028b576101008083540402835291602001916102b4565b820191905f5260205f20905b81548152906001019060200180831161029757829003601f168201915b505050505081565b5f604051905090565b5f80fd5b5f80fd5b5f80fd5b5f80fd5b5f601f19601f8301169050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b61031b826102d5565b810181811067ffffffffffffffff8211171561033a576103396102e5565b5b80604052505050565b5f61034c6102bc565b90506103588282610312565b919050565b5f67ffffffffffffffff821115610377576103766102e5565b5b610380826102d5565b9050602081019050919050565b828183375f83830152505050565b5f6103ad6103a88461035d565b610343565b9050828152602081018484840111156103c9576103c86102d1565b5b6103d484828561038d565b509392505050565b5f82601f8301126103f0576103ef6102cd565b5b813561040084826020860161039b565b91505092915050565b5f6020828403121561041e5761041d6102c5565b5b5f82013567ffffffffffffffff81111561043b5761043a6102c9565b5b610447848285016103dc565b91505092915050565b5f81519050919050565b5f82825260208201905092915050565b5f5b8381101561048757808201518184015260208101905061046c565b5f8484015250505050565b5f61049c82610450565b6104a6818561045a565b93506104b681856020860161046a565b6104bf816102d5565b840191505092915050565b5f6020820190508181035f8301526104e28184610492565b905092915050565b7f746869734973437573746f6d00000000000000000000000000000000000000005f82015250565b5f61051e600c8361045a565b9150610529826104ea565b602082019050919050565b5f6020820190508181035f83015261054b81610512565b9050919050565b7f726561736f6e476f6573486572650000000000000000000000000000000000005f82015250565b5f610586600e8361045a565b915061059182610552565b602082019050919050565b5f6020820190508181035f8301526105b38161057a565b9050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b5f60028204905060018216806105fe57607f821691505b602082108103610611576106106105ba565b5b50919050565b5f819050815f5260205f209050919050565b5f6020601f8301049050919050565b5f82821b905092915050565b5f600883026106737fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82610638565b61067d8683610638565b95508019841693508086168417925050509392505050565b5f819050919050565b5f819050919050565b5f6106c16106bc6106b784610695565b61069e565b610695565b9050919050565b5f819050919050565b6106da836106a7565b6106ee6106e6826106c8565b848454610644565b825550505050565b5f90565b6107026106f6565b61070d8184846106d1565b505050565b5b81811015610730576107255f826106fa565b600181019050610713565b5050565b601f8211156107755761074681610617565b61074f84610629565b8101602085101561075e578190505b61077261076a85610629565b830182610712565b50505b505050565b5f82821c905092915050565b5f6107955f198460080261077a565b1980831691505092915050565b5f6107ad8383610786565b9150826002028217905092915050565b6107c682610450565b67ffffffffffffffff8111156107df576107de6102e5565b5b6107e982546105e7565b6107f4828285610734565b5f60209050601f831160018114610825575f8415610813578287015190505b61081d85826107a2565b865550610884565b601f19841661083386610617565b5f5b8281101561085a57848901518255600182019150602085019450602081019050610835565b868310156108775784890151610873601f891682610786565b8355505b6001600288020188555050505b505050505050565b5f8154610898816105e7565b6108a2818661045a565b9450600182165f81146108bc57600181146108d257610904565b60ff198316865281151560200286019350610904565b6108db85610617565b5f5b838110156108fc578154818901526001820191506020810190506108dd565b808801955050505b50505092915050565b5f6020820190508181035f830152610925818461088c565b90509291505056fea2646970667358221220eef121bb90d745d701fb8e6fe22d2d18057d9700a161b3daa60cf8271ba6d89464736f6c63430008160033" \ No newline at end of file diff --git a/clients/py/sapphirepy/tests/testdata/Greeter.sol b/clients/py/sapphirepy/tests/testdata/Greeter.sol new file mode 100644 index 000000000..9e5f8fd1f --- /dev/null +++ b/clients/py/sapphirepy/tests/testdata/Greeter.sol @@ -0,0 +1,33 @@ +pragma solidity ^0.8.0; + +contract Greeter { + string public greeting; + + constructor() { + greeting = 'Hello'; + } + + function setGreeting(string memory _greeting) public { + greeting = _greeting; + } + + function greet() view public returns (string memory) { + return greeting; + } + + event Greeting(string g); + + function blah() external { + emit Greeting(greeting); + } + + function revertWithReason() external view { + require(false, "reasonGoesHere"); + } + + error MyCustomError(string blah); + + function revertWithCustomError() external view { + revert MyCustomError("thisIsCustom"); + } +} \ No newline at end of file diff --git a/clients/py/setup.py b/clients/py/setup.py index f61a48189..4f35df640 100644 --- a/clients/py/setup.py +++ b/clients/py/setup.py @@ -1,31 +1,33 @@ from setuptools import find_packages, setup -readme = "" +with open('README.md', 'r') as handle: + README = handle.read() + +with open('requirements.txt', 'r') as handle: + REQUIREMENTS = [_.strip() for _ in handle.readlines()] setup( author="oasisprotocol", - author_email="devops@oasisprotocol.org", + author_email="team@oasisprotocol.org", classifiers=[ - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: OS Independent", + "Typing :: Typed" ], - description="Sapphire transaction wrapper", - install_requires=[], + description="Oasis Sapphire encryption for Web3.py", name="sapphire.py", license="Apache Software License 2.0", - long_description=readme, + long_description=README, long_description_content_type="text/markdown", include_package_data=True, python_requires='>=3.8', - packages=find_packages( - include=[ - "sapphirepy", - ] - ), + packages=find_packages(include=["sapphirepy"]), + install_requires=REQUIREMENTS, url="https://github.com/oasisprotocol/sapphire-paratime", - version="0.0.1", + version="0.3.0", zip_safe=True, ) diff --git a/clients/py/test.py b/clients/py/test.py deleted file mode 100644 index 26b3c3777..000000000 --- a/clients/py/test.py +++ /dev/null @@ -1,60 +0,0 @@ -from web3 import Web3 -from eth_account import Account -from eth_account.signers.local import LocalAccount -from web3.middleware import construct_sign_and_send_raw_middleware -from solcx import compile_source - -from sapphirepy import sapphire - -compiled_sol = compile_source( - ''' - pragma solidity ^0.8.0; - - contract Greeter { - string public greeting; - - constructor() public { - greeting = 'Hello'; - } - - function setGreeting(string memory _greeting) public { - greeting = _greeting; - } - - function greet() view public returns (string memory) { - return greeting; - } - - event Greeting(string g); - - function blah() external { - emit Greeting(greeting); - } - } - ''', - output_values=['abi', 'bin'] -) -contract_id, contract_interface = compiled_sol.popitem() -bytecode = contract_interface['bin'] -abi = contract_interface['abi'] - -account: LocalAccount = Account.from_key("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") - -w3 = Web3(Web3.HTTPProvider('http://localhost:8545')) -w3.middleware_onion.add(construct_sign_and_send_raw_middleware(account)) -w3 = sapphire.wrap(w3) - -w3.eth.default_account = account.address - -Greeter = w3.eth.contract(abi=abi, bytecode=bytecode) -tx_hash = Greeter.constructor().transact({'gasPrice': w3.eth.gas_price}) -tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - -greeter = w3.eth.contract(address=tx_receipt.contractAddress, abi=abi) - -assert greeter.functions.greet().call() == 'Hello' - -x = greeter.functions.blah().transact({'gasPrice': w3.eth.gas_price}) -y = w3.eth.wait_for_transaction_receipt(x) -z = greeter.events.Greeting().process_receipt(y) -assert z[0].args['g'] == 'Hello'