diff --git a/.travis.yml b/.travis.yml index 2aec645..945db2e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ sudo: required language: python +dist: xenial python: + - "3.7" - "3.6" services: diff --git a/Pipfile b/Pipfile index 0a8a2bd..042598c 100644 --- a/Pipfile +++ b/Pipfile @@ -7,17 +7,12 @@ name = "pypi" python_version = "3.7" [packages] -schematics = "==2.0.1" -kin-base = "==1.0.7" +schematics = "==2.1.*" +kin-base = "==1.3.0" [dev-packages] -attrs = "==17.4.0" -codecov = "==2.0.15" -coverage = "==4.5.1" -funcsigs = "==1.0.2" -pluggy = "==0.6.0" -py = "==1.5.2" -pytest = "==3.4.0" -pytest-cov = "==2.5.1" -#[pipenv] -#keep_outdated = true +codecov = "*" +coverage = "*" +pytest = "*" +pytest-cov = "*" +pytest-asyncio = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 4a67d4e..c60ece4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5ce413a399d5244cafeba64f23e5660e2e62de6e9d49bab5d21e5d4110f68c8b" + "sha256": "43d80c0418d74141fec19031ebd0377b047d79ffe5bc481318921c181bb769df" }, "pipfile-spec": 6, "requires": { @@ -16,12 +16,53 @@ ] }, "default": { - "certifi": { + "aiohttp": { + "hashes": [ + "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55", + "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed", + "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10", + "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5", + "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1", + "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939", + "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390", + "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa", + "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc", + "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5", + "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d", + "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf", + "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6", + "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72", + "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12", + "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366", + "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4", + "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300", + "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d", + "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303", + "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6", + "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889" + ], + "version": "==3.5.4" + }, + "aiohttp-sse-client": { + "hashes": [ + "sha256:468ce4e1120b896d37141d8f9d235fd58212c8024a909ec88c20ce143253cebd", + "sha256:65511b1823f5ba68fdf6c0210689f2432b94878c843384df7b69ce6ba4decad4" + ], + "version": "==0.1.4" + }, + "async-timeout": { + "hashes": [ + "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", + "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" + ], + "version": "==3.0.1" + }, + "attrs": { "hashes": [ - "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", - "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" ], - "version": "==2018.11.29" + "version": "==19.1.0" }, "chardet": { "hashes": [ @@ -47,18 +88,18 @@ }, "idna": { "hashes": [ - "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", - "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" ], - "version": "==2.7" + "version": "==2.8" }, "kin-base": { "hashes": [ - "sha256:0f9b13a256bfc79372111389e1f80ec6f10a5bd85aaf1a6acd1c156173a81123", - "sha256:ed3994c12dcbf175eef101d49090dc829e2057714d22a640ae12aea9e118ee66" + "sha256:1be39e39f356fee42157f3105348d6a0de68530ac31d5a5b3dd42eceafae7d55", + "sha256:73260264ee0cf58c9deb9a70f94a93084c82df2628c1ccee392b697cb00191d9" ], "index": "pypi", - "version": "==1.0.7" + "version": "==1.3.0" }, "mnemonic": { "hashes": [ @@ -66,38 +107,39 @@ ], "version": "==0.18" }, - "numpy": { + "multidict": { "hashes": [ - "sha256:1b1cf8f7300cf7b11ddb4250b3898c711a6187df05341b5b7153db23ffe5d498", - "sha256:27a0d018f608a3fe34ac5e2b876f4c23c47e38295c47dd0775cc294cd2614bc1", - "sha256:3fde172e28c899580d32dc21cb6d4a1225d62362f61050b654545c662eac215a", - "sha256:497d7c86df4f85eb03b7f58a7dd0f8b948b1f582e77629341f624ba301b4d204", - "sha256:4e28e66cf80c09a628ae680efeb0aa9a066eb4bb7db2a5669024c5b034891576", - "sha256:58be95faf0ca2d886b5b337e7cba2923e3ad1224b806a91223ea39f1e0c77d03", - "sha256:5b4dfb6551eaeaf532054e2c6ef4b19c449c2e3a709ebdde6392acb1372ecabc", - "sha256:63f833a7c622e9082df3cbaf03b4fd92d7e0c11e2f9d87cb57dbf0e84441964b", - "sha256:71bf3b7ca15b1967bba3a1ef6a8e87286382a8b5e46ac76b42a02fe787c5237d", - "sha256:733dc5d47e71236263837825b69c975bc08728ae638452b34aeb1d6fa347b780", - "sha256:82f00a1e2695a0e5b89879aa25ea614530b8ebdca6d49d4834843d498e8a5e92", - "sha256:866bf72b9c3bfabe4476d866c70ee1714ad3e2f7b7048bb934892335e7b6b1f7", - "sha256:8aeac8b08f4b8c52129518efcd93706bb6d506ccd17830b67d18d0227cf32d9e", - "sha256:8d2cfb0aef7ec8759736cce26946efa084cdf49797712333539ef7d135e0295e", - "sha256:981224224bbf44d95278eb37996162e8beb6f144d2719b144e86dfe2fce6c510", - "sha256:981daff58fa3985a26daa4faa2b726c4e7a1d45178100125c0e1fdaf2ac64978", - "sha256:9ad36dbfdbb0cba90a08e7343fadf86f43cf6d87450e8d2b5d71d7c7202907e4", - "sha256:a251570bb3cb04f1627f23c234ad09af0e54fc8194e026cf46178f2e5748d647", - "sha256:b5ff7dae352fd9e1edddad1348698e9fea14064460a7e39121ef9526745802e6", - "sha256:c898f9cca806102fcacb6309899743aa39efb2ad2a302f4c319f54db9f05cd84", - "sha256:cf4b970042ce148ad8dce4369c02a4078b382dadf20067ce2629c239d76460d1", - "sha256:d1569013e8cc8f37e9769d19effdd85e404c976cd0ca28a94e3ddc026c216ae8", - "sha256:dca261e85fe0d34b2c242ecb31c9ab693509af2cf955d9caf01ee3ef3669abd0", - "sha256:ec8bf53ef7c92c99340972519adbe122e82c81d5b87cbd955c74ba8a8cd2a4ad", - "sha256:f2e55726a9ee2e8129d6ce6abb466304868051bcc7a09d652b3b07cd86e801a2", - "sha256:f4dee74f2626c783a3804df9191e9008946a104d5a284e52427a53ff576423cb", - "sha256:f592fd7fe1f20b5041928cce1330937eca62f9058cb41e69c2c2d83cffc0d1e3", - "sha256:ffab5b80bba8c86251291b8ce2e6c99a61446459d4c6637f5d5cc8c9ce37c972" + "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f", + "sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3", + "sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef", + "sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b", + "sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73", + "sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc", + "sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3", + "sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd", + "sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351", + "sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941", + "sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d", + "sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1", + "sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b", + "sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a", + "sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3", + "sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7", + "sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0", + "sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0", + "sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014", + "sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5", + "sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036", + "sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d", + "sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a", + "sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce", + "sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1", + "sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a", + "sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9", + "sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7", + "sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b" ], - "version": "==1.15.2" + "version": "==4.5.2" }, "pbkdf2": { "hashes": [ @@ -105,63 +147,52 @@ ], "version": "==1.3" }, - "requests": { - "hashes": [ - "sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c", - "sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279" - ], - "version": "==2.20.0" - }, "schematics": { "hashes": [ - "sha256:d9798a9ba0e1e1f2bde4a15780baa95ed66f748fa52d22bb89893d66ad0fac55", - "sha256:eaecac4ae5a86faa111f16befa26510bc66dc093c52df200f3aad54459e39640" + "sha256:8fcc6182606fd0b24410a1dbb066d9bbddbe8da9c9509f47b743495706239283", + "sha256:a40b20635c0e43d18d3aff76220f6cd95ea4decb3f37765e49529b17d81b0439" ], "index": "pypi", - "version": "==2.0.1" - }, - "six": { - "hashes": [ - "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", - "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" - ], - "version": "==1.11.0" - }, - "stellar-base-sseclient": { - "hashes": [ - "sha256:2a500f3015dede4e9fac0f9d6d9d85f4fdd7fe1c9c10b2b111a6ae190cc5dc00" - ], - "version": "==0.0.21" + "version": "==2.1.0" }, - "toml": { + "yarl": { "hashes": [ - "sha256:8e86bd6ce8cc11b9620cb637466453d94f5d57ad86f17e98a98d1f73e3baab2d" + "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9", + "sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f", + "sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb", + "sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320", + "sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842", + "sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0", + "sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829", + "sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310", + "sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4", + "sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8", + "sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1" ], - "version": "==0.9.4" - }, - "urllib3": { - "hashes": [ - "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", - "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" - ], - "version": "==1.24.1" + "version": "==1.3.0" } }, "develop": { + "atomicwrites": { + "hashes": [ + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" + ], + "version": "==1.3.0" + }, "attrs": { "hashes": [ - "sha256:1c7960ccfd6a005cd9f7ba884e6316b5e430a3f1a6c37c5f87d8b43f83b54ec9", - "sha256:a17a9573a6f475c99b551c0e0a812707ddda1ec9653bed04c13841404ed6f450" + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" ], - "index": "pypi", - "version": "==17.4.0" + "version": "==19.1.0" }, "certifi": { "hashes": [ - "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", - "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", + "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" ], - "version": "==2018.11.29" + "version": "==2019.3.9" }, "chardet": { "hashes": [ @@ -180,104 +211,107 @@ }, "coverage": { "hashes": [ - "sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba", - "sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed", - "sha256:0bf8cbbd71adfff0ef1f3a1531e6402d13b7b01ac50a79c97ca15f030dba6306", - "sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95", - "sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640", - "sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd", - "sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162", - "sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1", - "sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508", - "sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249", - "sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694", - "sha256:3c79a6f7b95751cdebcd9037e4d06f8d5a9b60e4ed0cd231342aa8ad7124882a", - "sha256:3d72c20bd105022d29b14a7d628462ebdc61de2f303322c0212a054352f3b287", - "sha256:3eb42bf89a6be7deb64116dd1cc4b08171734d721e7a7e57ad64cc4ef29ed2f1", - "sha256:4635a184d0bbe537aa185a34193898eee409332a8ccb27eea36f262566585000", - "sha256:56e448f051a201c5ebbaa86a5efd0ca90d327204d8b059ab25ad0f35fbfd79f1", - "sha256:5a13ea7911ff5e1796b6d5e4fbbf6952381a611209b736d48e675c2756f3f74e", - "sha256:69bf008a06b76619d3c3f3b1983f5145c75a305a0fea513aca094cae5c40a8f5", - "sha256:6bc583dc18d5979dc0f6cec26a8603129de0304d5ae1f17e57a12834e7235062", - "sha256:701cd6093d63e6b8ad7009d8a92425428bc4d6e7ab8d75efbb665c806c1d79ba", - "sha256:7608a3dd5d73cb06c531b8925e0ef8d3de31fed2544a7de6c63960a1e73ea4bc", - "sha256:76ecd006d1d8f739430ec50cc872889af1f9c1b6b8f48e29941814b09b0fd3cc", - "sha256:7aa36d2b844a3e4a4b356708d79fd2c260281a7390d678a10b91ca595ddc9e99", - "sha256:7d3f553904b0c5c016d1dad058a7554c7ac4c91a789fca496e7d8347ad040653", - "sha256:7e1fe19bd6dce69d9fd159d8e4a80a8f52101380d5d3a4d374b6d3eae0e5de9c", - "sha256:8c3cb8c35ec4d9506979b4cf90ee9918bc2e49f84189d9bf5c36c0c1119c6558", - "sha256:9d6dd10d49e01571bf6e147d3b505141ffc093a06756c60b053a859cb2128b1f", - "sha256:be6cfcd8053d13f5f5eeb284aa8a814220c3da1b0078fa859011c7fffd86dab9", - "sha256:c1bb572fab8208c400adaf06a8133ac0712179a334c09224fb11393e920abcdd", - "sha256:de4418dadaa1c01d497e539210cb6baa015965526ff5afc078c57ca69160108d", - "sha256:e05cb4d9aad6233d67e0541caa7e511fa4047ed7750ec2510d466e806e0255d6", - "sha256:f05a636b4564104120111800021a92e43397bc12a5c72fed7036be8556e0029e", - "sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80" + "sha256:3684fabf6b87a369017756b551cef29e505cb155ddb892a7a29277b978da88b9", + "sha256:39e088da9b284f1bd17c750ac672103779f7954ce6125fd4382134ac8d152d74", + "sha256:3c205bc11cc4fcc57b761c2da73b9b72a59f8d5ca89979afb0c1c6f9e53c7390", + "sha256:465ce53a8c0f3a7950dfb836438442f833cf6663d407f37d8c52fe7b6e56d7e8", + "sha256:48020e343fc40f72a442c8a1334284620f81295256a6b6ca6d8aa1350c763bbe", + "sha256:5296fc86ab612ec12394565c500b412a43b328b3907c0d14358950d06fd83baf", + "sha256:5f61bed2f7d9b6a9ab935150a6b23d7f84b8055524e7be7715b6513f3328138e", + "sha256:68a43a9f9f83693ce0414d17e019daee7ab3f7113a70c79a3dd4c2f704e4d741", + "sha256:6b8033d47fe22506856fe450470ccb1d8ba1ffb8463494a15cfc96392a288c09", + "sha256:7ad7536066b28863e5835e8cfeaa794b7fe352d99a8cded9f43d1161be8e9fbd", + "sha256:7bacb89ccf4bedb30b277e96e4cc68cd1369ca6841bde7b005191b54d3dd1034", + "sha256:839dc7c36501254e14331bcb98b27002aa415e4af7ea039d9009409b9d2d5420", + "sha256:8f9a95b66969cdea53ec992ecea5406c5bd99c9221f539bca1e8406b200ae98c", + "sha256:932c03d2d565f75961ba1d3cec41ddde00e162c5b46d03f7423edcb807734eab", + "sha256:988529edadc49039d205e0aa6ce049c5ccda4acb2d6c3c5c550c17e8c02c05ba", + "sha256:998d7e73548fe395eeb294495a04d38942edb66d1fa61eb70418871bc621227e", + "sha256:9de60893fb447d1e797f6bf08fdf0dbcda0c1e34c1b06c92bd3a363c0ea8c609", + "sha256:9e80d45d0c7fcee54e22771db7f1b0b126fb4a6c0a2e5afa72f66827207ff2f2", + "sha256:a545a3dfe5082dc8e8c3eb7f8a2cf4f2870902ff1860bd99b6198cfd1f9d1f49", + "sha256:a5d8f29e5ec661143621a8f4de51adfb300d7a476224156a39a392254f70687b", + "sha256:aca06bfba4759bbdb09bf52ebb15ae20268ee1f6747417837926fae990ebc41d", + "sha256:bb23b7a6fd666e551a3094ab896a57809e010059540ad20acbeec03a154224ce", + "sha256:bfd1d0ae7e292105f29d7deaa9d8f2916ed8553ab9d5f39ec65bcf5deadff3f9", + "sha256:c62ca0a38958f541a73cf86acdab020c2091631c137bd359c4f5bddde7b75fd4", + "sha256:c709d8bda72cf4cd348ccec2a4881f2c5848fd72903c185f363d361b2737f773", + "sha256:c968a6aa7e0b56ecbd28531ddf439c2ec103610d3e2bf3b75b813304f8cb7723", + "sha256:df785d8cb80539d0b55fd47183264b7002077859028dfe3070cf6359bf8b2d9c", + "sha256:f406628ca51e0ae90ae76ea8398677a921b36f0bd71aab2099dfed08abd0322f", + "sha256:f46087bbd95ebae244a0eda01a618aff11ec7a069b15a3ef8f6b520db523dcf1", + "sha256:f8019c5279eb32360ca03e9fac40a12667715546eed5c5eb59eb381f2f501260", + "sha256:fc5f4d209733750afd2714e9109816a29500718b32dd9a5db01c0cb3a019b96a" ], "index": "pypi", - "version": "==4.5.1" + "version": "==4.5.3" }, - "funcsigs": { + "idna": { "hashes": [ - "sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca", - "sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50" + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" ], - "index": "pypi", - "version": "==1.0.2" + "version": "==2.8" }, - "idna": { + "more-itertools": { "hashes": [ - "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", - "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" + "sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40", + "sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1" ], - "version": "==2.7" + "markers": "python_version > '2.7'", + "version": "==6.0.0" }, "pluggy": { "hashes": [ - "sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff", - "sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c", - "sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5" + "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", + "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" ], - "index": "pypi", - "version": "==0.6.0" + "version": "==0.9.0" }, "py": { "hashes": [ - "sha256:8cca5c229d225f8c1e3085be4fcf306090b00850fefad892f9d96c7b6e2f310f", - "sha256:ca18943e28235417756316bfada6cd96b23ce60dd532642690dcfdaba988a76d" + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" ], - "index": "pypi", - "version": "==1.5.2" + "version": "==1.8.0" }, "pytest": { "hashes": [ - "sha256:6074ea3b9c999bd6d0df5fa9d12dd95ccd23550df2a582f5f5b848331d2e82ca", - "sha256:95fa025cd6deb5d937e04e368a00552332b58cae23f63b76c8c540ff1733ab6d" + "sha256:592eaa2c33fae68c7d75aacf042efc9f77b27c08a6224a4f59beab8d9a420523", + "sha256:ad3ad5c450284819ecde191a654c09b0ec72257a2c711b9633d677c71c9850c4" + ], + "index": "pypi", + "version": "==4.3.1" + }, + "pytest-asyncio": { + "hashes": [ + "sha256:9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf", + "sha256:d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b" ], "index": "pypi", - "version": "==3.4.0" + "version": "==0.10.0" }, "pytest-cov": { "hashes": [ - "sha256:03aa752cf11db41d281ea1d807d954c4eda35cfa1b21d6971966cc041bbf6e2d", - "sha256:890fe5565400902b0c78b5357004aab1c814115894f4f21370e2433256a3eeec" + "sha256:0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33", + "sha256:230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f" ], "index": "pypi", - "version": "==2.5.1" + "version": "==2.6.1" }, "requests": { "hashes": [ - "sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c", - "sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279" + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" ], - "version": "==2.20.0" + "version": "==2.21.0" }, "six": { "hashes": [ - "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", - "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" ], - "version": "==1.11.0" + "version": "==1.12.0" }, "urllib3": { "hashes": [ diff --git a/README.md b/README.md index 36b97b3..f193d59 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,9 @@ # KIN Python SDK for Kin Blockchain - -## Disclaimer - -The SDK is still in beta. No warranties are given, use on your own discretion. - ## Requirements. -Python >= 3.4 +Python >= 3.6 ## Installation @@ -21,17 +16,27 @@ pip install kin-sdk ### Initialization -The sdk has two main components, KinClient and KinAccount. -**KinClient** - Used to query the blockchain and perform actions that don't require authentication (e.g Get account balance) +The sdk has two main components, KinClient and KinAccount. +**KinClient** - Used to query the blockchain and perform actions that don't require authentication (e.g Get account balance) **KinAccount** - Used to perform authenticated actions on the blockchain (e.g Send payment) To initialize the Kin Client you will need to provide an environment (Test and Production environments are pre-configured) - +The KinClient object can be used with a context manager, or closed manually, to close the connection to the blockchain ```python from kin import KinClient, TEST_ENVIRONMENT +async with KinClient(TEST_ENVIRONMENT) as client: + ... + +OR + client = KinClient(TEST_ENVIRONMENT) +try: + ... +finally: + client.close() + ``` Custom environment can also be used: @@ -62,61 +67,48 @@ Most methods provided by the KinClient to query the blockchain about a specific ### Getting Account Balance ```python -# Get KIN/XLM balance -balance = client.get_account_balance('address') +# Get KIN balance +balance = await client.get_account_balance('address') ``` ### Getting Account Data ```python -account_data = client.get_account_data('address') +account_data = await client.get_account_data('address') ``` ### Checking If an account exists on the blockchain ```python -client.does_account_exists('address') +await client.does_account_exists('address') ``` ### Getting the minimum acceptable fee from the blockchain +Transactions usually require a fee to be processed. +To know what is the minimum fee that the blockchain will accept, use: ```python -# Transactions usually require a fee to be proccessed. -# To know what is the minimum fee that the blockchain will accept, use: -minimum_fee = client.get_minimum_fee() +minimum_fee = await client.get_minimum_fee() ``` ### Getting Transaction Data +Get information about a specific transaction +The 'simple' flag is enabled by default, and dictates what object should be returned +For simple=False: A 'kin.RawTransaction' object will return, +containing some fields that may be confusing and of no use to the user. + +For simple=True: A 'kin.SimpleTransaction' object will return, +containing only the data that the user will need. +However, if the transaction if too complex to be simplified, a 'CantSimplifyError' will be raised ```python -# Get information about a specific transaction -# The 'simple' flag is enabled by defualt, and dectates what object should be returned -# For simple=False: A 'kin.RawTransaction' object will return, -# containig some fields that may be confusing and of no use to the user. - -# For simple=True: A 'kin.SimpleTransaction' object will return, -# containing only the data that the user will need. -# However, if the transaction if too complex to be simplified, a 'CantSimplifyError' will be raised -tx_data = sdk.get_transaction_data(tx_hash, simple=True/False) - -# A transaction will not be simplifed if: -# 1. It contains a memo that is not a text memo -# 2. It contains multiple operations -# 3. It contains a payment that is not of KIN -# 4. Its operation type is not one of 'Payment'/'Create account'. - -# Given the use case of our blockchain, and the tools that we currently provied to interact with it, these conditions should not usually occur. +tx_data = await sdk.get_transaction_data(tx_hash, simple=True/False) ``` -### Verify Kin Payment -This method provides an easy way to verify that a transaction is what you expect it to be -```python -client.verify_kin_payment('tx_hash','sender','destination',amount,memo(optional),check_memo=True/False) -#Lets say that addr1 payed 15 KIN to add2, with the memo 'Enjoy!' +A transaction will not be simplified if: +1. It contains a memo that is not a text memo +2. It contains multiple operations +3. It contains a payment that is not of KIN +4. Its operation type is not one of 'Payment'/'Create account'. -client.verify_kin_payment('tx_hash','addr1','addr2',15,'Enjoy!',True) >> True -client.verify_kin_payment('tx_hash','addr1','addr2',15,'Hello',True) >> False -client.verify_kin_payment('tx_hash','addr1','addr2',15) >> True -client.verify_kin_payment('tx_hash','addr1','addr2',10) >> False -client.verify_kin_payment('tx_hash','addr1','addr3',10) >> False -``` +Given the use case of our blockchain, and the tools that we currently provide to interact with it, these conditions should not usually occur. ### Checking configuration The handy `get_config` method will return some parameters the client was configured with, along with Horizon status: @@ -125,27 +117,21 @@ status = client.get_config() ``` ```json - { - "sdk_version": "2.2.0", - "environment": "TEST", - "horizon": { - "uri": "https://horizon-playground.kininfrastructure.com", - "online": true, - "error": null - }, - "transport": { - "pool_size": 10, - "num_retries": 5, - "request_timeout": 11, - "retry_statuses": [ - 503, - 413, - 429, - 504 - ], - "backoff_factor": 0.5 - } + { + "sdk_version": "2.4.0", + "environment": "TEST", + "horizon": { + "uri": "https://horizon-testnet.kininfrastructure.com", + "online": true, + "error": null + }, + "transport": { + "pool_size": 100, + "num_retries": 3, + "request_timeout": 11, + "backoff_factor": 0.5 } +} ``` - `sdk_version` - the version of this SDK. - `environment` - the environment the SDK was configured with (TEST/PROD/CUSTOM). @@ -157,42 +143,40 @@ status = client.get_config() - `pool_size` - number of pooled connections to Horizon. - `num_retries` - number of retries on failed request. - `request_timeout` - single request timeout. - - `retry_statuses` - a list of statuses to retry on. - `backoff_factor` - a backoff factor to apply between retry attempts. ### Friendbot +If a friendbot endpoint is provided when creating the environment (it is provided with the TEST_ENVIRONMENT), +you will be able to use the friendbot method to call a service that will create an account for you ```python -# If a friendbot endpoint is provided when creating the environment (it is provided with the TEST_ENVIRONMENT), -# you will be able to use the friendbot method to call a service that will create an account for you - -client.friendbot('address') +await client.friendbot('address') ``` ## Account Usage ### Getting Wallet Details +Get the public address of my wallet account. The address is derived from the seed the account was created with. ```python -# Get the public address of my wallet account. The address is derived from the seed the account was created with. address = account.get_public_address() ``` ### Creating a New Account +Create a new account +the KIN amount can be specified in numbers or as a string ```python -# Create a new account -# the KIN amount can be specified in numbers or as a string -tx_hash = account.create_account('address', starting_balance=1000, fee=100) - -# a text memo can also be provided: -tx_hash = account.create_account('address', starting_balance=1000, fee=100, memo_text='Account creation example') +tx_hash = await account.create_account('address', starting_balance=1000, fee=100) +``` +A text memo can also be provided: +```python +tx_hash = await account.create_account('address', starting_balance=1000, fee=100, memo_text='Account creation') ``` ### Sending KIN +The KIN amount can be specified in numbers or as a string ```python -# send KIN -# the KIN amount can be specified in numbers or as a string -tx_hash = account.send_kin('destination', 1000, fee=100, memo_text='order123') +tx_hash = await account.send_kin('destination', 1000, fee=100, memo_text='order123') ``` ### Build/Submit transactions @@ -205,8 +189,8 @@ builder = account.build_send_kin('destination', 1000, fee=100, memo_text='order1 Step 2: Update the transaction ```python # do whatever you want with the builder -with account.channel_manager.get_channel() as channel: - builder.set_channel(channel) +async with account.channel_manager.get_channel() as channel: + await builder.set_channel(channel) builder.sign(channel) # If you used additional channels apart from your main account, # sign with your main account @@ -214,16 +198,15 @@ with account.channel_manager.get_channel() as channel: ``` Step 3: Send the transaction ```python - tx_hash = account.submit_transaction(builder) + tx_hash = await account.submit_transaction(builder) ``` ### Whitelist a transaction +Assuming you are registered as a whitelisted digital service with the Kin Ecosystem (exact details TBD) +You will be able to whitelist transactions for your clients, making it so that their fee will not be deducted +Your clients will send an http request to you containing their tx. +You can then whitelist it, and return it back to the client to send to the blockchain ```python -# Assuming you are registered as a whitelisted digital service with the Kin Ecosystem (exact details TBD) -# You will be able to whitelist transactions for your clients, making it so that their fee will not be deducted -# Your clients will send an http request to you containing their tx. -# You can then whitelist it, and return it back to the client to send to the blockchain - whitelisted_tx = account.whitelist_transaction(client_transaction) # By defualt, any payment sent from you is already considered whitelisted, @@ -231,49 +214,43 @@ whitelisted_tx = account.whitelist_transaction(client_transaction) ``` ### Get account status +Get the status and config of the account +If verbose it set to true, all channels and statuses will be printed ```python -# Get the status and config of the account account.get_status(verbose=False/True) -# If verbose it set to true, all channels and statuses will be printed ``` ```json { "client": { - "sdk_version": "2.2.0", - "environment": "LOCAL", + "sdk_version": "2.4.0", + "environment": "TEST", "horizon": { - "uri": "http://localhost:8000", + "uri": "https://horizon-testnet.kininfrastructure.com", "online": true, "error": null }, "transport": { - "pool_size": 10, - "num_retries": 5, + "pool_size": 100, + "num_retries": 3, "request_timeout": 11, - "retry_statuses": [ - 503, - 413, - 429, - 504 - ], "backoff_factor": 0.5 } }, "account": { "app_id": "anon", - "public_address": "GCLBBAIDP34M4JACPQJUYNSPZCQK7IRHV7ETKV6U53JPYYUIIVDVJJFQ", - "balance": 9999989999199.979, + "public_address": "GBQLWHAH5BRB3PTJEXIKGKI3YYM2DJI32ZOZBR4O5WE7FE2GNSUTF6RP", + "balance": 10000, "channels": { "total_channels": 5, "free_channels": 4, "non_free_channels": 1, "channels": { - "SBS3O5BGCPDIYWTTOV7TGLXFRPFSD6ACBEAEHJUMMPF5DUDF732MX6LL": "free", - "SC65CIJCAWJEJX5IVHDJK6FO6DM5BVPIUX5F7EULIC3C4PF7KTAUHHE2": "free", - "SABWFQ2HOYPQGCWN7INIV2RNZZLAZDOX67R3VHMGQAFF6FA3JIA2E7BB": "free", - "SBBQJTYF6K2TDUJ2LBUSXICUEEX75RXAQZRP6LLVF3JDXK5D4SVYX3X4": "taken", - "SCD36QIV3SFEGZDHRZZXO7MICNMOHSRAOV6L2MQKSW4TO4OTCR4IF2FD": "free" + "SBRHUVGBCXDM2HDSTQ5Y5QLMBCTOTK6GIQ4PDZIMCD3SG3A7MU22ASRV": "free", + "SA6XIHKGWVGUNOWUPCEA2SWBII5JEHK7Q54I2ESZ42NKUX5NYNXPTA4P": "free", + "SB57K5N2JUVXBF3S56OND4WXLZAXMBB7WFV5E5ZQTHOGQQTGCY4ZBWGL": "free", + "SCFXWAXZHM3OJA5XJNW4MIDPRYZHTECXJEOYY5O6JJB523M32OJXD756": "taken", + "SA6YK4SR2KS2RXV7SN6HFVXNO44AA7IQTZ7QKWAWS6TPJ2NCND2JMLY3": "free" } } } @@ -284,10 +261,10 @@ account.get_status(verbose=False/True) These methods are relevant to transactions ### Decode_transaction -```python -# When the client sends you a transaction for whitelisting, it will be encoded. -# If you wish to decode the transaction and verify its details before whitelisting it: +When the client sends you a transaction for whitelisting, it will be encoded. +If you wish to decode the transaction and verify its details before whitelisting it: +```python from kin import decode_transaction decoded_tx = decode_transaction(encoded_tx) @@ -301,7 +278,9 @@ These set of methods allow you to create new keypairs. from kin import Keypair my_keypair = Keypair() -# Or, you can create a keypair from an existing seed +``` +Or, you can create a keypair from an existing seed +```python my_keypair = Keypair('seed') ``` @@ -316,56 +295,30 @@ seed = Keypair.generate_seed() ``` ### Generate a deterministic seed +Given the same seed and salt, the same seed will always be generated ```python -# Given the same seed and salt, the same seed will always be generated seed = Keypair.generate_hd_seed('seed','salt') ``` -### Generate a mnemonic seed: -**Not implemented yet** - ## Monitoring Kin Payments -These methods can be used to monitor the kin payment that an account or accounts is sending/receiving -**Currently, due to a bug on the blockchain frontend, the monitor may also return 1 tx that happened before the monitoring request** - - -The monitor will run in a background thread (accessible via ```monitor.thread```) , -and will call the callback function everytime it finds a kin payment for the given address. +These methods can be used to monitor the kin payment that an account or accounts is sending/receiving ### Monitor a single account Monitoring a single account will continuously get data about this account from the blockchain and filter it. - +An additional "timeout" parameter can be passed to raise a "TimeoutError" if too much time passes between each tx. ```python -def callback_fn(address, tx_data, monitor) - print ('Found tx: {} for address: {}'.format(address,tx_data.id)) - -monitor = client.monitor_account_payments('address', callback_fn) +async for tx in client.monitor_account_payments('address'): + ... ``` ### Monitor multiple accounts -Monitoring multiple accounts will continuously get data about **all** accounts on the blockchain, and will filter it. - -```python -def callback_fn(address, tx_data, monitor) - print ('Found tx: {} for address: {}'.format(address,tx_data.id)) - -monitor = client.monitor_accounts_payments(['address1','address2'], callback_fn) -``` - -You can freely add or remove accounts to this monitor - +Monitoring multiple accounts will continuously get data about **all** accounts on the blockchain, and will filter it to only yield txs for the relevant accounts. +Since this monitor receives a set of addresses, you can freely add/remove address at from it at any point ```python -monitor.add_address('address3') -monitor.remove_address('address1') +addresses = set(['address1','address2']) +async for address, tx in client.monitor_accounts_payments(addresses): + ... ``` -### Stopping a monitor -When you are done monitoring, make sure to stop the monitor, to terminate the thread and the connection to the blockchain. - -```python -monitor.stop() -``` - - ## Channels One of the most sensitive points in Stellar is [transaction sequence](https://www.stellar.org/developers/guides/concepts/transactions.html#sequence-number). @@ -379,30 +332,25 @@ Depending on the nature of your application, here are our recommendations: In this case, the SDK can be instantiated with only the wallet key, the channel accounts are not necessary. 2. You have a single application server that should handle a stream of concurrent transactions. In this case, -you need to make sure that only a single instance of a KinAccount initialized with multiple channel accounts. -This is an important point, because if you use a standard `gunicorn/Flask` setup for example, gunicorn will spawn -several *worker processes*, each containing your Flask application, each containing your KinAccount instance, so multiple -KinAccount instances will exist, having the same channel accounts. The solution is to use gunicorn *thread workers* instead of -*process workers*, for example run gunicorn with `--threads` switch instead of `--workers` switch, so that only -one Flask application is created, containing a single KinAccount instance. +you need to make sure that only a single instance of a KinAccount initialized with multiple channel accounts. 3. You have a number of load-balanced application servers. Here, each application server should a) have the setup outlined above, and b) have its own channel accounts. This way, you ensure you will not have any collisions in your transaction sequences. ### Creating Channels -``` -# The kin sdk allows you to create HD (highly desterministic) channels based on your seed and a passphrase to be used as a salt. -# As long as you use the same seed and passphrase, you will always get the same seeds. +The kin sdk allows you to create HD (highly deterministic) channels based on your seed and a passphrase to be used as a salt. +As long as you use the same seed and passphrase, you will always get the same seeds. +``` import kin.utils channels = utils.create_channels(master_seed, environment, amount, starting_balance, salt) -"channels" will be a list of seeds the sdk created for you, that can be used when initializing the KinAccount object. - -# If you just wish to get the list of the channels generated from your seed + passphrase combination without creating them - +# "channels" will be a list of seeds the sdk created for you, that can be used when initializing the KinAccount object. +``` +If you just wish to get the list of the channels generated from your seed + passphrase combination without creating them +```python channels = utils.get_hd_channels(master_seed, salt, amount) ``` diff --git a/images/docker-compose.yml b/images/docker-compose.yml index cc9a0c6..f8c32fd 100644 --- a/images/docker-compose.yml +++ b/images/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: stellar-core-1: - image: kinecosystem/stellar-core:kinecosystem-v2.0.0-stellar-v9.2.0 + image: kinecosystem/stellar-core:latest ports: - 11626:11626 links: @@ -26,7 +26,7 @@ services: POSTGRES_DB: core stellar-core-2: - image: kinecosystem/stellar-core:kinecosystem-v2.0.0-stellar-v9.2.0 + image: kinecosystem/stellar-core:latest ports: - 11627:11626 links: @@ -49,7 +49,7 @@ services: POSTGRES_DB: core horizon: - image: kinecosystem/horizon:v2.0.0-stellar-v0.12.3 + image: kinecosystem/horizon:latest # using nginx-proxy ports: - 8000:8000 @@ -63,7 +63,7 @@ services: # available configuration visible at: # https://github.com/stellar/horizon/blob/v0.11.1/src/github.com/stellar/horizon/cmd/horizon/main.go#L33 # - NETWORK_PASSPHRASE: private testnet + NETWORK_PASSPHRASE: "Integration Test Network ; zulucrypto" DATABASE_URL: postgres://stellar:12345678@horizon-db/horizon?sslmode=disable HORIZON_DB_MAX_OPEN_CONNECTIONS: "24" STELLAR_CORE_DATABASE_URL: postgres://stellar:12345678@stellar-core-1-db/core?sslmode=disable @@ -96,7 +96,7 @@ services: POSTGRES_DB: horizon horizon-nginx-proxy: - image: kinecosystem/horizon-nginx-proxy:85c6b72 + image: kinecosystem/horizon-nginx-proxy:latest ports: - 8008:80 links: diff --git a/kin/__init__.py b/kin/__init__.py index 505091f..80a9702 100644 --- a/kin/__init__.py +++ b/kin/__init__.py @@ -5,4 +5,7 @@ from .transactions import OperationTypes, decode_transaction from .blockchain.keypair import Keypair from .blockchain.environment import Environment -from .blockchain.builder import Builder + +# Override kin_base user agent with the kin-sdk user agent +from kin_base import horizon +horizon.USER_AGENT = config.SDK_USER_AGENT diff --git a/kin/account.py b/kin/account.py index 351b249..0057e57 100644 --- a/kin/account.py +++ b/kin/account.py @@ -3,19 +3,20 @@ import re import json -from kin_base.transaction_envelope import TransactionEnvelope +from kin_base import Builder from kin_base.network import NETWORKS +from kin_base.transaction_envelope import TransactionEnvelope from .blockchain.keypair import Keypair -from .blockchain.horizon import Horizon -from .blockchain.builder import Builder from .blockchain.channel_manager import ChannelManager, ChannelStatuses from . import errors as KinErrors -from .transactions import build_memo -from .blockchain.errors import TransactionResultCode, HorizonErrorType, HorizonError -from .config import SDK_USER_AGENT, APP_ID_REGEX, KIN_DECIMAL_PRECISION +from .transactions import build_memo, RawTransaction, SimplifiedTransaction +from .blockchain.errors import TransactionResultCode, HorizonErrorType +from .config import APP_ID_REGEX, KIN_DECIMAL_PRECISION from .blockchain.utils import is_valid_address, is_valid_secret_key +from .blockchain.horizon_models import AccountData +from typing import List, Optional, Union, AsyncGenerator import logging logger = logging.getLogger(__name__) @@ -37,112 +38,104 @@ def __init__(self, seed, client, channel_seeds, app_id): # Set keypair self.keypair = Keypair(seed) - # check that sdk wallet account exists - if not self._client.does_account_exists(self.keypair.public_address): - raise KinErrors.AccountNotFoundError(self.keypair.public_address) if channel_seeds is not None: # Use given channels self.channel_seeds = channel_seeds + for channel_seed in self.channel_seeds: + if not is_valid_secret_key(channel_seed): + raise KinErrors.StellarSecretInvalidError else: # Use the base account as the only channel self.channel_seeds = [seed] - for channel_seed in self.channel_seeds: - if not is_valid_secret_key(channel_seed): - raise KinErrors.StellarSecretInvalidError - - # set connection pool size for channels + monitoring connection + extra - pool_size = max(1, len(self.channel_seeds)) + 2 - - # Set an horizon instance with the new pool_size - self.horizon = Horizon(self._client.environment.horizon_uri, - pool_size=pool_size, user_agent=SDK_USER_AGENT) self.channel_manager = ChannelManager(self.channel_seeds) def get_public_address(self): """Return this KinAccount's public address""" return self.keypair.public_address - def get_balance(self): + async def get_balance(self) -> float: """ Get the KIN balance of this KinAccount + :return: the kin balance - :rtype: float :raises: KinErrors.AccountNotFoundError if the account does not exist. """ - return self._client.get_account_balance(self.keypair.public_address) + return await self._client.get_account_balance(self.keypair.public_address) - def get_data(self): + async def get_data(self) -> AccountData: """ Gets this KinAccount's data :return: account data - :rtype: kin.blockchain.horizon_models.AccountData :raises: KinErrors.AccountNotFoundError if the account does not exist. """ - return self._client.get_account_data(self.keypair.public_address) + return await self._client.get_account_data(self.keypair.public_address) - def get_status(self, verbose=False): + async def get_status(self, verbose: Optional[bool] = False) -> dict: """ Get the config and status of this KinAccount object - :param bool verbose: Should the channels status be verbose + + :param verbose: Should the channels status be verbose :return: The config and status of this KinAccount object :rtype dict """ account_status = { 'app_id': self.app_id, 'public_address': self.get_public_address(), - 'balance': self.get_balance(), + 'balance': await self.get_balance(), 'channels': self.channel_manager.get_status(verbose) } total_status = { - 'client': self._client.get_config(), + 'client': await self._client.get_config(), 'account': account_status } return total_status - def get_transaction_history(self, amount=10, descending=True, cursor=None, simple=True): + async def get_transaction_history(self, amount: Optional[int] = 10, descending: Optional[bool] = True, + cursor: Optional[int] = None, + simple: Optional[bool] = True) -> List[Union[SimplifiedTransaction, RawTransaction]]: """ Get the transaction history for this kin account - :param int amount: The maximum number of transactions to get - :param bool descending: The order of the transactions, True will start from the latest one - :param int cursor: The horizon paging token - :param bool simple: Should the returned txs be simplified, if True, complicated txs will be ignored + + :param amount: The maximum number of transactions to get + :param descending: The order of the transactions, True will start from the latest one + :param cursor: The horizon paging token + :param simple: Should the returned txs be simplified, if True, complicated txs will be ignored :return: A list of transactions - :rtype: list """ - return self._client.get_account_tx_history(self.get_public_address(), - amount=amount, - descending=descending, - cursor=cursor, - simple=simple) + return await self._client.get_account_tx_history(self.get_public_address(), + amount=amount, + descending=descending, + cursor=cursor, + simple=simple) - def get_transaction_builder(self, fee): + def get_transaction_builder(self, fee: int) -> Builder: """ Get a transaction builder using this account - :param int fee: The fee that will be used for the transaction - :return: kin.Builder + + :param fee: The fee that will be used for the transaction """ - return Builder(self._client.environment.name, self.horizon, fee, self.keypair.secret_seed) + return Builder(horizon=self._client.horizon, + network_name=self._client.environment.name, + fee=fee, + secret=self.keypair.secret_seed) - def create_account(self, address, starting_balance, fee, memo_text=None): + async def create_account(self, address: str, starting_balance: Union[float, str], fee: int, + memo_text: Optional[str] = None) -> str: """Create an account identified by the provided address. - :param str address: the address of the account to create. - - :param float|str starting_balance: the starting KIN balance of the account. - - :param str memo_text: (optional) a text to put into transaction memo, up to MEMO_CAP chars. - - :param int fee: fee to be deducted for the tx + :param address: the address of the account to create. + :param starting_balance: the starting KIN balance of the account. + :param memo_text: (optional) a text to put into transaction memo, up to MEMO_CAP chars. + :param fee: fee to be deducted for the tx :return: the hash of the transaction - :rtype: str :raises: KinErrors.StellarAddressInvalidError: if the provided address has a wrong format. :raises: KinErrors.AccountExistsError if the account already exists. @@ -152,27 +145,24 @@ def create_account(self, address, starting_balance, fee, memo_text=None): """ builder = self.build_create_account(address, starting_balance, fee, memo_text) - with self.channel_manager.get_channel() as channel: - builder.set_channel(channel) + async with self.channel_manager.get_channel() as channel: + await builder.set_channel(channel) builder.sign(channel) # Also sign with the root account if a different channel was used if builder.address != self.keypair.public_address: builder.sign(self.keypair.secret_seed) - return self.submit_transaction(builder) + return await self.submit_transaction(builder) - def send_kin(self, address, amount, fee, memo_text=None): + async def send_kin(self, address: str, amount: Union[float, str], fee: int, + memo_text: Optional[str] = None) -> str: """Send KIN to the account identified by the provided address. - :param str address: the account to send KIN to. - - :param float|str amount: the amount of KIN to send. - - :param str memo_text: (optional) a text to put into transaction memo. - - :param int fee: fee to be deducted + :param address: the account to send KIN to. + :param amount: the amount of KIN to send. + :param memo_text: (optional) a text to put into transaction memo. + :param fee: fee to be deducted :return: the hash of the transaction - :rtype: str :raises: KinErrors.StellarAddressInvalidError: if the provided address has a wrong format. :raises: ValueError: if the amount is not positive. @@ -183,27 +173,24 @@ def send_kin(self, address, amount, fee, memo_text=None): :raises: KinErrors.NotValidParamError: if the fee is not valid """ builder = self.build_send_kin(address, amount, fee, memo_text) - with self.channel_manager.get_channel() as channel: - builder.set_channel(channel) + async with self.channel_manager.get_channel() as channel: + await builder.set_channel(channel) builder.sign(channel) # Also sign with the root account if a different channel was used if builder.address != self.keypair.public_address: builder.sign(self.keypair.secret_seed) - return self.submit_transaction(builder) + return await self.submit_transaction(builder) - def build_create_account(self, address, starting_balance, fee, memo_text=None): + def build_create_account(self, address: str, starting_balance: Union[float, str], fee: int, + memo_text: Optional[str] = None) -> Builder: """Build a tx that will create an account identified by the provided address. - - :param str address: the address of the account to create. - - :param float|str starting_balance: the starting XLM balance of the account. - - :param str memo_text: (optional) a text to put into transaction memo, up to MEMO_CAP chars. - - :param int fee: fee to be deducted for the tx + + :param address: the address of the account to create. + :param starting_balance: the starting XLM balance of the account. + :param memo_text: (optional) a text to put into transaction memo, up to MEMO_CAP chars. + :param fee: fee to be deducted for the tx :return: a transaction builder object - :rtype: kin.Builder :raises: KinErrors.StellarAddressInvalidError: if the supplied address has a wrong format. """ @@ -219,19 +206,16 @@ def build_create_account(self, address, starting_balance, fee, memo_text=None): builder.append_create_account_op(address, str(starting_balance), source=self.keypair.public_address) return builder - def build_send_kin(self, address, amount, fee, memo_text=None): + def build_send_kin(self, address: str, amount: Union[float, str], fee: int, + memo_text: Optional[str] = None) -> Builder: """Build a tx to send KIN to the account identified by the provided address. - :param str address: the account to send asset to. - - :param float|str amount: the KIN amount to send. - - :param str memo_text: (optional) a text to put into transaction memo. - - :param int fee: fee to be deducted for the tx + :param address: the account to send asset to. + :param amount: the KIN amount to send. + :param memo_text: (optional) a text to put into transaction memo. + :param fee: fee to be deducted for the tx :return: a transaction builder - :rtype: kin.Builder :raises: KinErrors.StellarAddressInvalidError: if the provided address has a wrong format. :raises: ValueError: if the amount is not positive. @@ -248,49 +232,49 @@ def build_send_kin(self, address, amount, fee, memo_text=None): builder.append_payment_op(address, str(amount), source=self.keypair.public_address) return builder - def submit_transaction(self, tx_builder): + async def submit_transaction(self, tx_builder: Builder) -> str: """ Submit a transaction to the blockchain. + :param kin.Builder tx_builder: The transaction builder + :return: The hash of the transaction. :rtype: str """ try: - return tx_builder.submit()['hash'] + return (await tx_builder.submit())['hash'] # If the channel is out of KIN, top it up and try again - except HorizonError as e: + except KinErrors.HorizonError as e: logger.warning('send transaction error with channel {}: {}'.format(tx_builder.address, str(e))) if e.type == HorizonErrorType.TRANSACTION_FAILED \ - and e.extras.result_codes.transaction == TransactionResultCode.INSUFFICIENT_BALANCE: + and e.extras['result_codes']['transaction'] == TransactionResultCode.INSUFFICIENT_BALANCE: - self.channel_manager.channel_pool.queue[tx_builder.address] = ChannelStatuses.UNDERFUNDED - self._top_up(tx_builder.address) - self.channel_manager.channel_pool.queue[tx_builder.address] = ChannelStatuses.TAKEN + self.channel_manager.channel_pool._queue[tx_builder.address] = ChannelStatuses.UNDERFUNDED + await self._top_up(tx_builder.address) + self.channel_manager.channel_pool._queue[tx_builder.address] = ChannelStatuses.TAKEN # Insufficient balance is a "fast-fail", the sequence number doesn't increment # so there is no need to build the transaction again - self.submit_transaction(tx_builder) + await self.submit_transaction(tx_builder) else: raise KinErrors.translate_error(e) - def monitor_payments(self, callback_fn): + def monitor_payments(self, timeout: Optional[float] = None) -> AsyncGenerator[SimplifiedTransaction, None]: """Monitor KIN payment transactions related to this account - NOTE: the function starts a background thread. - :param callback_fn: the function to call on each received payment as `callback_fn(address, tx_data, monitor)`. - :type: callable[str,kin.transactions.SimplifiedTransaction,kin.monitors.SingleMonitor] + :param timeout: How long to wait for each event - :return: a monitor instance - :rtype: kin.monitors.SingleMonitor + :raises: ValueError: if the address is in the wrong format + :raises: asyncio.TimeoutError: If too much time has passed between events (only if "timeout" is set) """ - return self._client.monitor_account_payments(self.keypair.public_address, callback_fn) + return self._client.monitor_account_payments(self.keypair.public_address, timeout) - def whitelist_transaction(self, payload): + def whitelist_transaction(self, payload: Union[str, dict]) -> str: """ Sign on a transaction to whitelist it - :param str payload: the json received from the client + + :param payload: the json received from the client :return: a signed transaction encoded as base64 - :rtype str """ # load the object from the json @@ -324,20 +308,17 @@ def whitelist_transaction(self, payload): # Internal methods - def _top_up(self, address): + async def _top_up(self, address: str) -> None: """ Top up a channel with the base account. - :param str address: The address to top up + + :param address: The address to top up """ - # In theory, if the sdk runs in threads, and 2 or more channels - # are out of funds and needed to be topped up at the exact same time - # there is a chance for a bad_sequence error, - # however it is virtually impossible that this situation will occur. # TODO: let user config the amount of kin to top up - min_fee = self._client.get_minimum_fee() + min_fee = await self._client.get_minimum_fee() builder = self.get_transaction_builder(min_fee) builder.append_payment_op(address, str((min_fee / KIN_DECIMAL_PRECISION) * 1000)) # Enough for 1K txs - builder.update_sequence() + await builder.update_sequence() builder.sign() - builder.submit() + await builder.submit() diff --git a/kin/blockchain/builder.py b/kin/blockchain/builder.py deleted file mode 100644 index 1d223ab..0000000 --- a/kin/blockchain/builder.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Contains the builder class to build transactions""" - -from kin_base.builder import Builder as BaseBuilder -from kin_base.keypair import Keypair -from kin_base.memo import NoneMemo - - -class Builder(BaseBuilder): - """ - This class overrides :class:`kin_base.builder` to provide additional functionality. - # TODO: maybe merge this with kin-base builder - """ - - # TODO: make seed optional (need to change kin_base) - def __init__(self, network_name, horizon, fee, secret): - """ - Create a new transaction builder - :param str network_name: The name of the network - :param kin.Horizon horizon: The horizon instance to use - :param int fee: Fee for the transaction - :param str secret: The seed to be used - """ - - # call base class constructor to init base class variables - # sequence is one since it get overridden later - super(Builder, self).__init__(secret=secret, sequence=1, fee=fee) - - # custom overrides - - self.network = network_name - self.horizon = horizon - - def clear(self): - """"Clears the builder so it can be reused.""" - self.ops = [] - self.time_bounds = None - self.memo = NoneMemo() - self.tx = None - self.te = None - - def update_sequence(self): - """ - Update the builder with the *current* sequence of the account - # TODO: kin-base builder increments this value by 1 when building a tx. - # Remove this functionality from py-stellar-base and change this method set the current sequence+1 - """ - - # TODO: kin-base checks for 'not sequence' to find if there is no sequence, therefore - # Sequence of 0 fails, write it as a str for now and fix in kin-base later - self.sequence = str(self.get_sequence()) - - def next(self): - """ - Alternative implementation that does not create a new builder but clears the current one and increments - the account sequence number. - """ - self.clear() - self.sequence = str(int(self.sequence) + 1) - - def set_channel(self, channel_seed): - """ - Set a channel to be used for this transaction - :param str channel_seed: Seed to use as the channel - """ - self.keypair = Keypair.from_seed(channel_seed) - self.address = self.keypair.address().decode() - self.update_sequence() diff --git a/kin/blockchain/channel_manager.py b/kin/blockchain/channel_manager.py index 35e2a2b..a77abe0 100644 --- a/kin/blockchain/channel_manager.py +++ b/kin/blockchain/channel_manager.py @@ -2,95 +2,84 @@ import sys import random -from contextlib import contextmanager +from asyncio.queues import Queue as queue from enum import Enum -from .errors import ChannelsBusyError, ChannelsFullError +from typing import List, Optional -if sys.version[0] == '2': - import Queue as queue +# Python 3.6 didnt support asynccontextmanager, so the kin-sdk installs a backport for it +if sys.version_info.minor == 6: + from async_generator import asynccontextmanager else: - import queue - -CHANNEL_GET_TIMEOUT = 11 # how much time to wait until a channel is available, in seconds -CHANNEL_PUT_TIMEOUT = 0.5 # how much time to wait for a channel to return to the queue + from contextlib import asynccontextmanager class ChannelManager: """Provide useful methods to interact with the underlying ChannelPool""" - def __init__(self, channel_seeds): + def __init__(self, channel_seeds: List[str]): """ Crete a channel manager instance - :param list[str] channel_seeds: The seeds of the channels to use + + :param channel_seeds: The seeds of the channels to use """ self.channel_pool = ChannelPool(channel_seeds) - @contextmanager - def get_channel(self, timeout=CHANNEL_GET_TIMEOUT): + @asynccontextmanager + async def get_channel(self) -> str: """ Get an available channel - :param float timeout: (Optional) How long to wait before raising an exception + :return a free channel seed - :rtype str - :raises KinErrors.ChannelBusyError """ - try: - channel = self.channel_pool.get(timeout=timeout) - except queue.Empty: - raise ChannelsBusyError() + channel = await self.channel_pool.get() try: yield channel finally: - if self.channel_pool.queue[channel] != ChannelStatuses.UNDERFUNDED: - self.put_channel(channel) + if self.channel_pool._queue[channel] != ChannelStatuses.UNDERFUNDED: + await self.put_channel(channel) - def put_channel(self, channel, timeout=CHANNEL_PUT_TIMEOUT): + async def put_channel(self, channel) -> None: """ Set a channel status back to FREE - :param str channel: the channel to set back to FREE - :param float timeout: (Optional) How long to wait before raising an exception - :raises KinErrors.ChannelsFullError + :param str channel: the channel to set back to FREE """ - try: - self.channel_pool.put(channel, timeout=timeout) - except queue.Full: - raise ChannelsFullError() + await self.channel_pool.put(channel) - def get_status(self, verbose=False): + def get_status(self, verbose: Optional[bool] = False) -> dict: """ Return the current status of the channel manager - :param bool verbose: Include all channel seeds and their statuses in the response + + :param verbose: Include all channel seeds and their statuses in the response :return: The status of the channel manager - :rtype dict """ free_channels = len(self.channel_pool.get_free_channels()) status = { - 'total_channels': len(self.channel_pool.queue), + 'total_channels': len(self.channel_pool._queue), 'free_channels': free_channels, - 'non_free_channels': len(self.channel_pool.queue) - free_channels + 'non_free_channels': len(self.channel_pool._queue) - free_channels } if verbose: - status['channels'] = self.channel_pool.queue + status['channels'] = self.channel_pool._queue return status class ChannelStatuses(str, Enum): """Contains possible statuses for channels""" + # subclass str to be able to serialize to json FREE = 'free' TAKEN = 'taken' UNDERFUNDED = 'underfunded' -# TODO: remove object when we kill python2 -class ChannelPool(queue.Queue, object): +class ChannelPool(queue): """ - A thread-safe queue that sets a member's status instead of pulling it in/out of the queue. + An async queue that sets a member's status instead of pulling it in/out of the queue. This queue gets members randomly when 'get' is used, as opposed to always get the last member. """ def __init__(self, channels_seeds): @@ -101,44 +90,44 @@ def __init__(self, channels_seeds): # Init base queue super(ChannelPool, self).__init__(len(channels_seeds)) # Change queue from a 'deque' object to a dict full of free channels - self.queue = {channel: ChannelStatuses.FREE for channel in channels_seeds} + self._queue = {channel: ChannelStatuses.FREE for channel in channels_seeds} - def _get(self): + def _get(self) -> str: """ Randomly get an available free channel from the dict + :return: a channel seed - :rtype str """ # Get a list of all free channels free_channels = self.get_free_channels() # Select a random free channel selected_channel = random.choice(free_channels) # Change channel state to taken - self.queue[selected_channel] = ChannelStatuses.TAKEN + self._queue[selected_channel] = ChannelStatuses.TAKEN return selected_channel - def _put(self, channel): + def _put(self, channel: str) -> None: """ Change a channel status back to FREE + :param str channel: the channel seed """ # Change channel state to free - self.queue[channel] = ChannelStatuses.FREE + self._queue[channel] = ChannelStatuses.FREE - def _qsize(self): + def qsize(self) -> int: """ - Used to determine if the queue is empty + Counts free channels in the queue + :return: amount of free channels in the queue - :rtype int """ - # Base queue checks if the queue is not empty by checking the length of the queue (_qsize() != 0) - # We need to check it by checking how many channels are free return len(self.get_free_channels()) - def get_free_channels(self): - """ - Get a list of channels with "FREE" status - :rtype list[str] - """ - return [channel for channel, status in self.queue.items() if status == ChannelStatuses.FREE] + def empty(self) -> bool: + """Used to check if the queue is empty""" + return len(self.get_free_channels()) == 0 + + def get_free_channels(self) -> List[str]: + """Get a list of channels with "FREE" status""" + return [channel for channel, status in self._queue.items() if status == ChannelStatuses.FREE] diff --git a/kin/blockchain/environment.py b/kin/blockchain/environment.py index a1ca0c2..bde22c5 100644 --- a/kin/blockchain/environment.py +++ b/kin/blockchain/environment.py @@ -3,23 +3,21 @@ from hashlib import sha256 from kin_base.network import NETWORKS -from kin_base.asset import Asset -from kin_base.exceptions import StellarAddressInvalidError -from .utils import is_valid_address +from typing import Optional class Environment: - """Environments holds the parameters that will be used to connect to horizon""" - def __init__(self, name, horizon_endpoint_uri, network_passphrase, friendbot_url=None): + def __init__(self, name: str, horizon_endpoint_uri: str, network_passphrase: str, + friendbot_url: Optional[str] = None): """ + Environments holds the parameters that will be used to connect to horizon - :param str name: Name of the environment. - :param str horizon_uri: a Horizon endpoint. - :param str network_passphrase: The passphrase/network_id of the environment. - :param str friendbot_url: a url to a friendbot service + :param name: Name of the environment. + :param horizon_uri: a Horizon endpoint. + :param network_passphrase: The passphrase/network_id of the environment. + :param friendbot_url: a url to a friendbot service :return: An instance of the Environment class. - :rtype: kin.Environment """ # Add the network to the kin_base network list. NETWORKS[name.upper()] = network_passphrase diff --git a/kin/blockchain/errors.py b/kin/blockchain/errors.py index ce1e985..c2018e6 100644 --- a/kin/blockchain/errors.py +++ b/kin/blockchain/errors.py @@ -1,16 +1,5 @@ """Contains errors types related to horizon""" -from .horizon_models import HTTPProblemDetails - - -class ChannelsBusyError(Exception): - pass - - -class ChannelsFullError(Exception): - pass - - HORIZON_NS_PREFIX = 'https://stellar.org/horizon-errors/' """ Horizon error example: @@ -39,15 +28,6 @@ class ChannelsFullError(Exception): """ - -class HorizonError(HTTPProblemDetails, Exception): - def __init__(self, err_dict): - super(HTTPProblemDetails, self).__init__(err_dict, strict=False) - super(Exception, self).__init__(self.title) - if len(self.type) > len(HORIZON_NS_PREFIX): - self.type = self.type[len(HORIZON_NS_PREFIX):] - - # noinspection PyClassHasNoInit class HorizonErrorType: BAD_REQUEST = 'bad_request' # cannot understand the request due to invalid parameters diff --git a/kin/blockchain/horizon.py b/kin/blockchain/horizon.py deleted file mode 100644 index 392acb2..0000000 --- a/kin/blockchain/horizon.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Contains the Horizon class to interact with horizon""" - -import requests -from requests.adapters import HTTPAdapter, DEFAULT_POOLSIZE -from requests.exceptions import RequestException -import sys -from time import sleep -from urllib3.util import Retry - -from kin_base.horizon import HORIZON_LIVE, HORIZON_TEST - -from .errors import HorizonError - -import logging - -logger = logging.getLogger(__name__) - -try: - from sseclient import SSEClient -except ImportError: - SSEClient = None - -if sys.version[0] == '2': - # noinspection PyUnresolvedReferences - from urllib import urlencode -else: - # noinspection PyUnresolvedReferences - from urllib.parse import urlencode - -DEFAULT_REQUEST_TIMEOUT = 11 # two ledgers + 1 sec, let's retry faster and not wait 60 secs. -DEFAULT_NUM_RETRIES = 5 -DEFAULT_BACKOFF_FACTOR = 0.5 -USER_AGENT = 'py-stellar-base' - - -class Horizon(object): - """ - This class redefines :class:`kin_base.horizon.Horizon` to provide additional functionality: - - persistent connection to Horizon and connection pool - - configurable request retry functionality - - Horizon error checking and deserialization - """ - - def __init__(self, horizon_uri=None, pool_size=DEFAULT_POOLSIZE, num_retries=DEFAULT_NUM_RETRIES, - request_timeout=DEFAULT_REQUEST_TIMEOUT, backoff_factor=DEFAULT_BACKOFF_FACTOR, user_agent=USER_AGENT): - if horizon_uri is None: - self.horizon_uri = HORIZON_TEST - else: - self.horizon_uri = horizon_uri - - self.pool_size = pool_size - self.num_retries = num_retries - self.request_timeout = request_timeout - self.backoff_factor = backoff_factor - - # adding 504 to the list of statuses to retry - self.status_forcelist = list(Retry.RETRY_AFTER_STATUS_CODES) - self.status_forcelist.append(504) - - # configure standard session - - # configure retry handler - retry = Retry(total=self.num_retries, backoff_factor=self.backoff_factor, redirect=0, - status_forcelist=self.status_forcelist) - # init transport adapter - adapter = HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size, max_retries=retry) - - # init session - session = requests.Session() - - # set default headers - session.headers.update({'User-Agent': user_agent}) - - session.mount('http://', adapter) - session.mount('https://', adapter) - self._session = session - - # configure SSE session (differs from our standard session) - - sse_retry = Retry(total=1000000, redirect=0, status_forcelist=self.status_forcelist) - sse_adapter = HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size, max_retries=sse_retry) - sse_session = requests.Session() - sse_session.headers.update({'User-Agent': user_agent}) - sse_session.mount('http://', sse_adapter) - sse_session.mount('https://', sse_adapter) - self._sse_session = sse_session - - def submit(self, te): - """Submit the transaction using a pooled connection, and retry on failure.""" - params = {'tx': te} - url = self.horizon_uri + '/transactions/' - - # POST is not included in Retry's method_whitelist for a good reason. - # our custom retry mechanism follows - reply = None - retry_count = self.num_retries - while True: - try: - reply = self._session.post(url, data=params, timeout=self.request_timeout) - return check_horizon_reply(reply.json()) - except (RequestException, ValueError) as e: - if reply: - msg = 'horizon submit exception: {}, reply: [{}] {}'.format(str(e), reply.status_code, reply.text) - else: - msg = 'horizon submit exception: {}'.format(str(e)) - logging.warning(msg) - - if reply and reply.status_code not in self.status_forcelist: - raise Exception('invalid horizon reply: [{}] {}'.format(reply.status_code, reply.text)) - # retry - if retry_count <= 0: - raise - retry_count -= 1 - logging.warning('submit retry attempt {}'.format(retry_count)) - sleep(self.backoff_factor) - - def query(self, rel_url, params=None, sse=False): - abs_url = self.horizon_uri + rel_url - reply = self._query(abs_url, params, sse) - return check_horizon_reply(reply) if not sse else reply - - def account(self, address): - url = '/accounts/' + address - return self.query(url) - - def account_effects(self, address, params=None, sse=False): - url = '/accounts/' + address + '/effects/' - return self.query(url, params, sse) - - def account_offers(self, address, params=None): - url = '/accounts/' + address + '/offers/' - return self.query(url, params) - - def account_operations(self, address, params=None, sse=False): - url = '/accounts/' + address + '/operations/' - return self.query(url, params, sse) - - def account_transactions(self, address, params=None, sse=False): - url = '/accounts/' + address + '/transactions/' - return self.query(url, params, sse) - - def account_payments(self, address, params=None, sse=False): - url = '/accounts/' + address + '/payments/' - return self.query(url, params, sse) - - def transactions(self, params=None, sse=False): - url = '/transactions/' - return self.query(url, params, sse) - - def transaction(self, tx_hash): - url = '/transactions/' + tx_hash - return self.query(url) - - def transaction_operations(self, tx_hash, params=None): - url = '/transactions/' + tx_hash + '/operations/' - return self.query(url, params) - - def transaction_effects(self, tx_hash, params=None): - url = '/transactions/' + tx_hash + '/effects/' - return self.query(url, params) - - def transaction_payments(self, tx_hash, params=None): - url = '/transactions/' + tx_hash + '/payments/' - return self.query(url, params) - - def order_book(self, params=None): - url = '/order_book/' - return self.query(url, params) - - def trades(self, params=None): - url = '/trades/' - return self.query(url, params) - - def ledgers(self, params=None, sse=False): - url = '/ledgers/' - return self.query(url, params, sse) - - def ledger(self, ledger_id): - url = '/ledgers/' + str(ledger_id) - return self.query(url) - - def ledger_transactions(self, ledger_id, params=None): - url = '/ledgers/' + str(ledger_id) + '/transactions/' - return self.query(url, params) - - def ledger_effects(self, ledger_id, params=None): - url = '/ledgers/' + str(ledger_id) + '/effects/' - return self.query(url, params) - - def ledger_operations(self, ledger_id, params=None): - url = '/ledgers/' + str(ledger_id) + '/operations/' - return self.query(url, params) - - def ledger_payments(self, ledger_id, params=None): - url = '/ledgers/' + str(ledger_id) + '/payments/' - return self.query(url, params) - - def effects(self, params=None, sse=False): - url = '/effects/' - return self.query(url, params, sse) - - def operations(self, params=None, sse=False): - url = '/operations/' - return self.query(url, params, sse) - - def operation(self, op_id, params=None): - url = '/operations/' + str(op_id) - return self.query(url, params) - - def operation_effects(self, op_id, params=None): - url = '/operations/' + str(op_id) + '/effects/' - return self.query(url, params) - - def payments(self, params=None, sse=False): - url = '/payments/' - return self.query(url, params, sse) - - def assets(self, params=None): - url = '/assets/' - return self.query(url, params) - - def _query(self, url, params=None, sse=False): - if not sse: - reply = self._session.get(url, params=params, timeout=self.request_timeout) - try: - return reply.json() - except ValueError: - raise Exception('invalid horizon reply: [{}] {}'.format(reply.status_code, reply.text)) - - # SSE connection - if SSEClient is None: - raise ValueError('SSE not supported, missing sseclient module') - - return SSEClient(url, session=self._sse_session, params=params) - - @staticmethod - def testnet(): - return Horizon(horizon_uri=HORIZON_TEST) - - @staticmethod - def livenet(): - return Horizon(horizon_uri=HORIZON_LIVE) - - -def check_horizon_reply(reply): - if 'status' not in reply: - return reply - raise HorizonError(reply) diff --git a/kin/blockchain/keypair.py b/kin/blockchain/keypair.py index fce9cf5..f507e1b 100644 --- a/kin/blockchain/keypair.py +++ b/kin/blockchain/keypair.py @@ -8,14 +8,17 @@ from .utils import is_valid_secret_key +from typing import Optional + class Keypair: - """A simpler version of kin_base.Keypair that holds the public address and secret seed.""" + """A simpler version of kin_base.Keypair that caches the public address and secret seed.""" - def __init__(self, seed=None): + def __init__(self, seed: Optional[str] = None): """ # Create an instance of Keypair. - :param str seed: (Optional) The secret seed of an account + + :param seed: (Optional) The secret seed of an account """ self.secret_seed = seed or self.generate_seed() if not is_valid_secret_key(self.secret_seed): @@ -27,43 +30,43 @@ def __init__(self, seed=None): self._hint = base_keypair.signature_hint() self._signing_key = base_keypair.signing_key - def sign(self, data): + def sign(self, data: bytes) -> DecoratedSignature: """ Sign any data using the keypair's private key - :param bytes data: any data to sign + + :param data: any data to sign :return: a decorated signature - :rtype kin_base.stellarxdr.StellarXDR_type.DecoratedSignature """ signature = self._signing_key.sign(data) return DecoratedSignature(self._hint, signature) @staticmethod - def address_from_seed(seed): + def address_from_seed(seed: str) -> str: """ Get a public address from a secret seed. - :param str seed: The secret seed of an account. + + :param seed: The secret seed of an account. :return: A public address. - :rtype str """ return BaseKeypair.from_seed(seed).address().decode() @staticmethod - def generate_seed(): + def generate_seed() -> str: """ Generate a random secret seed. + :return: A secret seed. - :rtype str """ return BaseKeypair.random().seed().decode() @staticmethod - def generate_hd_seed(base_seed, salt): + def generate_hd_seed(base_seed: str, salt: str) -> str: """ Generate a highly deterministic seed from a base seed + salt - :param str base_seed: The base seed to generate a seed from - :param str salt: A unique string that will be used to generate the seed + + :param base_seed: The base seed to generate a seed from + :param salt: A unique string that will be used to generate the seed :return: a new seed. - :rtype str """ # Create a new raw seed from this hash raw_seed = sha256((base_seed + salt).encode()).digest() diff --git a/kin/client.py b/kin/client.py index cd312dc..dd2545a 100644 --- a/kin/client.py +++ b/kin/client.py @@ -1,31 +1,34 @@ """Contains the KinClient class to interact with the blockchain""" -import requests +from kin_base import Horizon -from .config import SDK_USER_AGENT, ANON_APP_ID, MAX_RECORDS_PER_REQUEST +from .config import ANON_APP_ID, MAX_RECORDS_PER_REQUEST from . import errors as KinErrors -from .blockchain.horizon import Horizon -from .monitors import SingleMonitor, MultiMonitor -from .transactions import OperationTypes, SimplifiedTransaction, RawTransaction, build_memo +from .monitors import single_monitor, multi_monitor +from .transactions import SimplifiedTransaction, RawTransaction from .account import KinAccount from .blockchain.horizon_models import AccountData from .blockchain.utils import is_valid_address, is_valid_transaction_hash from .version import __version__ +from .blockchain.environment import Environment + +from typing import List, Optional, Union, AsyncGenerator import logging logger = logging.getLogger(__name__) -class KinClient(object): +class KinClient: """ The :class:`kin.KinClient` class is the primary interface to the KIN Python SDK based on Kin Blockchain. It maintains a connection context with a Horizon node and hides all the specifics of dealing with Kin REST API. """ - def __init__(self, environment): + def __init__(self, environment: Environment): """Create a new instance of the KinClient to query the Kin blockchain. - :param kin.Environment environment: an environment for the client to point to. + + :param environment: an environment for the client to point to. :return: An instance of the KinClient. :rtype: KinErrors.KinClient @@ -34,26 +37,37 @@ def __init__(self, environment): self.environment = environment self.network = environment.name - self.horizon = Horizon(horizon_uri=environment.horizon_uri, user_agent=SDK_USER_AGENT) - logger.info('Kin SDK inited on network {}, horizon endpoint {}'.format(self.network, self.horizon.horizon_uri)) + self.horizon = Horizon(environment.horizon_uri) + logger.info('Kin Client initialized on network {}, horizon endpoint {}'. + format(self.network, self.horizon.horizon_uri)) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() - def kin_account(self, seed, channel_secret_keys=None, app_id=ANON_APP_ID): + async def close(self) -> None: + """Close the connection to the horizon server""" + await self.horizon.close() + + def kin_account(self, seed: str, channel_secret_keys: Optional[List[str]] = None, + app_id: Optional[str] = ANON_APP_ID) -> KinAccount: """ Create a new instance of a KinAccount to perform authenticated operations on the blockchain. - :param str seed: The secret seed of the account that will be used - :param list[str] channel_secret_keys: A list of seeds to be used as channels - :param str app_id: the unique id of your app - :return: An instance of KinAccount - :rtype: kin.KinAccount - :raises: KinErrors.AccountNotFoundError if SDK wallet or channel account is not yet created. + :param seed: The secret seed of the account that will be used + :param channel_secret_keys: A list of seeds to be used as channels + :param app_id: the unique id of your app + :return: An instance of KinAccount """ # Create a new kin account, using self as the KinClient to be used - return KinAccount(seed, self,channel_secret_keys, app_id) + return KinAccount(seed, self, channel_secret_keys, app_id) - def get_config(self): + async def get_config(self) -> dict: """Get system configuration data and online status. + :return: a dictionary containing the data :rtype: dict """ @@ -61,44 +75,43 @@ def get_config(self): 'sdk_version': __version__, 'environment': self.environment.name, 'horizon': { - 'uri': self.horizon.horizon_uri, + 'uri': str(self.horizon.horizon_uri), 'online': False, 'error': None, }, 'transport': { - 'pool_size': self.horizon.pool_size, + 'pool_size': self.horizon._session.connector.limit, 'num_retries': self.horizon.num_retries, - 'request_timeout': self.horizon.request_timeout, - 'retry_statuses': self.horizon.status_forcelist, + 'request_timeout': self.horizon._session._timeout.total, 'backoff_factor': self.horizon.backoff_factor, } } # now check Horizon connection try: - self.horizon.query('') + await self.horizon.metrics() status['horizon']['online'] = True except Exception as e: - status['horizon']['error'] = str(e) + status['horizon']['error'] = repr(e) return status - def get_minimum_fee(self): + async def get_minimum_fee(self) -> int: """ Get the current minimum fee acceptable for a tx + :return: The minimum fee - :type: int """ params = {'order': 'desc', 'limit': 1} - return self.horizon.ledgers(params=params)['_embedded']['records'][0]['base_fee_in_stroops'] + return (await self.horizon.ledgers(order='desc', limit=1))['_embedded']['records'][0]['base_fee_in_stroops'] - def get_account_balance(self, address): + async def get_account_balance(self, address: str) -> float: """ Get the KIN balance of a given account - :param str address: the public address of the account to query + + :param address: the public address of the account to query :return: the balance of the account - :rtype: float :raises: StellarAddressInvalidError: if the provided address has the wrong format. :raises: KinErrors.AccountNotFoundError if the account does not exist. @@ -106,18 +119,18 @@ def get_account_balance(self, address): if not is_valid_address(address): raise KinErrors.StellarAddressInvalidError('invalid address: {}'.format(address)) - account_data = self.get_account_data(address) + account_data = await self.get_account_data(address) for balance in account_data.balances: # accounts will always have native asset if balance.asset_type == 'native': return balance.balance - def does_account_exists(self, address): + async def does_account_exists(self, address: str) -> bool: """ Find out if a given account exists on the blockchain - :param str address: The kin account to query about + + :param address: The kin account to query about :return: does the account exists on the blockchain - :rtype boolean :raises: KinErrors.StellarAddressInvalidError if the address is not valid. """ @@ -126,18 +139,17 @@ def does_account_exists(self, address): raise KinErrors.StellarAddressInvalidError('invalid address: {}'.format(address)) try: - self.get_account_balance(address) + await self.get_account_balance(address) return True except KinErrors.AccountNotFoundError: return False - def get_account_data(self, address): + async def get_account_data(self, address: str) -> AccountData: """Get account data. - :param str address: the public address of the account to query. + :param address: the public address of the account to query. :return: account data - :rtype: kin.blockchain.horizon_models.AccountData :raises: StellarAddressInvalidError: if the provided address has a wrong format. :raises: :class:`KinErrors.AccountNotFoundError`: if the account does not exist. @@ -147,21 +159,20 @@ def get_account_data(self, address): raise KinErrors.StellarAddressInvalidError('invalid address: {}'.format(address)) try: - acc = self.horizon.account(address) + acc = await self.horizon.account(address) return AccountData(acc, strict=False) except Exception as e: err = KinErrors.translate_error(e) raise KinErrors.AccountNotFoundError(address) if \ isinstance(err, KinErrors.ResourceNotFoundError) else err - def get_transaction_data(self, tx_hash, simple=True): + async def get_transaction_data(self, tx_hash: str, simple: Optional[bool] = True) -> Union[SimplifiedTransaction, RawTransaction]: """Gets transaction data. - :param str tx_hash: transaction hash. - :param boolean simple: (optional) returns a simplified transaction object + :param tx_hash: transaction hash. + :param simple: (optional) Should the method return a simplified or raw transaction :return: transaction data - :rtype: kin.transactions.RawTransaction | kin.transactions.SimplifiedTransaction :raises: ValueError: if the provided hash is invalid. :raises: :class:`KinErrors.ResourceNotFoundError`: if the transaction does not exist. @@ -172,7 +183,7 @@ def get_transaction_data(self, tx_hash, simple=True): raise ValueError('invalid transaction hash: {}'.format(tx_hash)) try: - raw_tx = RawTransaction(self.horizon.transaction(tx_hash)) + raw_tx = RawTransaction(await self.horizon.transaction(tx_hash)) except Exception as e: raise KinErrors.translate_error(e) @@ -180,16 +191,18 @@ def get_transaction_data(self, tx_hash, simple=True): return SimplifiedTransaction(raw_tx) return raw_tx - def get_account_tx_history(self, address, amount=10, descending=True, cursor=None, simple=True): + async def get_account_tx_history(self, address: str, amount: Optional[int] = 10, descending: Optional[bool] = True, + cursor: Optional[int] = None, + simple: Optional[bool] = True) -> List[Union[SimplifiedTransaction, RawTransaction]]: """ Get the transaction history for a given account. - :param str address: The public address of the account to query - :param int amount: The maximum number of transactions to get - :param bool descending: The order of the transactions, True will start from the latest one - :param int cursor: The horizon paging token - :param bool simple: Should the returned txs be simplified, if True, complicated txs will be ignored + + :param address: The public address of the account to query + :param amount: The maximum number of transactions to get + :param descending: The order of the transactions, True will start from the latest one + :param cursor: The horizon paging token + :param simple: Should the returned txs be simplified, if True, complicated txs will be ignored :return: A list of transactions - :rtype: list[kin.transactions.RawTransaction | kin.transactions.SimplifiedTransaction] """ if not is_valid_address(address): @@ -201,16 +214,10 @@ def get_account_tx_history(self, address, amount=10, descending=True, cursor=Non tx_list = [] requested_amount = amount if amount < MAX_RECORDS_PER_REQUEST else MAX_RECORDS_PER_REQUEST - params = { - 'limit': requested_amount, - 'order': 'desc' if descending else 'asc' - } - - # cursor is optional - if cursor is not None: - params['cursor'] = cursor - horizon_response = self.horizon.account_transactions(address, params) + horizon_response = await self.horizon.account_transactions(address, + cursor=cursor, limit=requested_amount, + order='desc' if descending else 'asc') for transaction in horizon_response['_embedded']['records']: raw_tx = RawTransaction(transaction) @@ -226,47 +233,19 @@ def get_account_tx_history(self, address, amount=10, descending=True, cursor=Non remaining_txs = amount - len(tx_list) # if we got all the txs that we wanted, or there are no more txs + # TODO: paging does not work DP-370 if remaining_txs <= 0 or len(horizon_response['_embedded']['records']) < amount: return tx_list # If there are anymore transactions, recursively get the next transaction page - return tx_list.extend(self.get_account_tx_history(address, remaining_txs, descending, last_cursor, simple)) - - def verify_kin_payment(self, tx_hash, source, destination, amount, memo=None, check_memo=False, app_id=ANON_APP_ID): - """ - Verify that a give tx matches the desired parameters - :param str tx_hash: The hash of the transaction to query - :param str source: The expected source account - :param str destination: The expected destination account - :param float amount: The expected amount - :param str memo: (optional) The expected memo - :param boolean check_memo: (optional) Should the memo match - :param str app_id: the id of the app that sent the tx - :return: True/False - :rtype: boolean - """ - - try: - tx = self.get_transaction_data(tx_hash) - operation = tx.operation - if operation.type != OperationTypes.PAYMENT: - return False - if source != tx.source or destination != operation.destination or amount != operation.amount: - return False - if check_memo and build_memo(app_id, memo) != tx.memo: - return False - - return True - - except KinErrors.CantSimplifyError: - return False + return tx_list.extend(await self.get_account_tx_history(address, remaining_txs, descending, last_cursor, simple)) - def friendbot(self, address): + async def friendbot(self, address: str): """ Use the friendbot service to create and fund an account - :param str address: The address to create and fund + + :param address: The address to create and fund :return: the hash of the friendbot transaction - :rtype str :raises ValueError: if no friendbot service was provided :raises ValueError: if the address is invalid @@ -279,49 +258,32 @@ def friendbot(self, address): if not is_valid_address(address): raise KinErrors.StellarAddressInvalidError('invalid address: {}'.format(address)) - if self.does_account_exists(address): + if await self.does_account_exists(address): raise KinErrors.AccountExistsError(address) - response = requests.get(self.environment.friendbot_url, params={'addr': address}) - if response.ok: - return response.json()['hash'] + response = await self.horizon._session.get(self.environment.friendbot_url, params={'addr': address}) + if response.status == 200: + return (await response.json(encoding='utf-8'))['hash'] else: - raise KinErrors.FriendbotError(response.status_code, response.text) + raise KinErrors.FriendbotError(response.status, await response.text(encoding='utf-8')) - def monitor_account_payments(self, address, callback_fn): + def monitor_account_payments(self, address: str, timeout: Optional[float] = None) -> AsyncGenerator[SimplifiedTransaction, None]: """Monitor KIN payment transactions related to the account identified by provided address. - NOTE: the function starts a background thread. :param str address: the address of the account to query. + :param timeout: How long to wait for each event - :param callback_fn: the function to call on each received payment as `callback_fn(address, tx_data, monitor)`. - :type: callable[str,kin.transactions.SimplifiedTransaction, kin.SingleMonitor] - - :return: a monitor instance - :rtype: kin.monitors.SingleMonitor - - :raises: ValueError: when no address is given. :raises: ValueError: if the address is in the wrong format - :raises: KinErrors.AccountNotActivatedError if the account given is not activated + :raises: asyncio.TimeoutError: If too much time has passed between events (only if "timeout" is set) """ + return single_monitor(self, address, timeout=timeout) - return SingleMonitor(self, address, callback_fn) - - def monitor_accounts_payments(self, addresses, callback_fn): + def monitor_accounts_payments(self, addresses: set, timeout: Optional[float] = None) -> AsyncGenerator[SimplifiedTransaction, None]: """Monitor KIN payment transactions related to multiple accounts - NOTE: the function starts a background thread. - :param str addresses: the addresses of the accounts to query. + :param addresses: the addresses of the accounts to query. + :param timeout: How long to wait for each event - :param callback_fn: the function to call on each received payment as `callback_fn(address, tx_data, monitor)`. - :type: callable[str,kin.transactions.SimplifiedTransaction ,kin.monitors.MultiMonitor] - - :return: a monitor instance - :rtype: kin.monitors.MultiMonitor - - :raises: ValueError: when no address is given. - :raises: ValueError: if the addresses are in the wrong format - :raises: KinErrors.AccountNotActivatedError if the accounts given are not activated + :raises: asyncio.TimeoutError: If too much time has passed between events (only if "timeout" is set) """ - - return MultiMonitor(self, addresses, callback_fn) + return multi_monitor(self, addresses, timeout=timeout) diff --git a/kin/config.py b/kin/config.py index 910e16a..6ab68a3 100644 --- a/kin/config.py +++ b/kin/config.py @@ -25,4 +25,4 @@ MAX_RECORDS_PER_REQUEST = 200 -SDK_USER_AGENT = 'kin-core-python/{}'.format(__version__) +SDK_USER_AGENT = 'kin-sdk-python/{}'.format(__version__) diff --git a/kin/errors.py b/kin/errors.py index 51e4884..bfbda06 100644 --- a/kin/errors.py +++ b/kin/errors.py @@ -1,10 +1,12 @@ """Contains errors related to the Kin SDK""" -from requests.exceptions import RequestException +from aiohttp.client_exceptions import ClientError -from .blockchain.errors import * +from kin_base.exceptions import HorizonError from kin_base.exceptions import NotValidParamError, StellarAddressInvalidError, StellarSecretInvalidError +from .blockchain.errors import * + # All exceptions should subclass from SdkError in this module. class SdkError(Exception): @@ -127,13 +129,6 @@ def __init__(self, error_code=None, extra=None): super(CantSimplifyError, self).__init__('Tx simplification error', error_code, extra) -class StoppedMonitorError(SdkError): - """A stopped monitor cannot be modified""" - - def __init__(self, error_code=None, extra=None): - super(StoppedMonitorError, self).__init__('Stopped monitor cannot be modified', error_code, extra) - - class WrongNetworkError(SdkError): """The account is not using the network specified in the tx""" @@ -144,10 +139,8 @@ def __init__(self, error_code=None, extra=None): def translate_error(err): """A high-level error translator.""" - if isinstance(err, RequestException): + if isinstance(err, ClientError): return NetworkError({'internal_error': str(err)}) - if isinstance(err, ChannelsBusyError): - return ThrottleError if isinstance(err, HorizonError): return translate_horizon_error(err) return InternalError(None, {'internal_error': str(err)}) @@ -157,7 +150,7 @@ def translate_horizon_error(horizon_error): """Horizon error translator.""" # query errors if horizon_error.type == HorizonErrorType.BAD_REQUEST: - return RequestError(horizon_error.type, {'invalid_field': horizon_error.extras.invalid_field}) + return RequestError(horizon_error.type, {'invalid_field': horizon_error.extras.get('invalid_field')}) if horizon_error.type == HorizonErrorType.NOT_FOUND: return ResourceNotFoundError(horizon_error.type) if horizon_error.type in [HorizonErrorType.FORBIDDEN, @@ -188,7 +181,7 @@ def translate_horizon_error(horizon_error): def translate_transaction_error(tx_error): """Transaction error translator.""" - tx_result_code = tx_error.extras.result_codes.transaction + tx_result_code = tx_error.extras['result_codes']['transaction'] if tx_result_code in [TransactionResultCode.TOO_EARLY, TransactionResultCode.TOO_LATE, TransactionResultCode.MISSING_OPERATION, @@ -202,7 +195,7 @@ def translate_transaction_error(tx_error): if tx_result_code == TransactionResultCode.INSUFFICIENT_BALANCE: return LowBalanceError(tx_result_code) if tx_result_code == TransactionResultCode.FAILED: - return translate_operation_error(tx_error.extras.result_codes.operations) + return translate_operation_error(tx_error.extras['result_codes']['operations']) return InternalError(tx_result_code, {'internal_error': 'unknown transaction error'}) @@ -213,22 +206,21 @@ def translate_operation_error(op_result_codes): if code != OperationResultCode.SUCCESS: op_result_code = code break - if op_result_code == OperationResultCode.BAD_AUTH \ - or op_result_code == CreateAccountResultCode.MALFORMED \ - or op_result_code == PaymentResultCode.NO_ISSUER \ - or op_result_code == PaymentResultCode.LINE_FULL \ - or op_result_code == ChangeTrustResultCode.INVALID_LIMIT: + if op_result_code in [OperationResultCode.BAD_AUTH, + CreateAccountResultCode.MALFORMED, + PaymentResultCode.NO_ISSUER, + PaymentResultCode.LINE_FULL, + ChangeTrustResultCode.INVALID_LIMIT]: return RequestError(op_result_code) - if op_result_code == OperationResultCode.NO_ACCOUNT or op_result_code == PaymentResultCode.NO_DESTINATION: + if op_result_code in [OperationResultCode.NO_ACCOUNT, PaymentResultCode.NO_DESTINATION]: return AccountNotFoundError(error_code=op_result_code) if op_result_code == CreateAccountResultCode.ACCOUNT_EXISTS: return AccountExistsError(error_code=op_result_code) - if op_result_code == CreateAccountResultCode.LOW_RESERVE \ - or op_result_code == PaymentResultCode.UNDERFUNDED: + if op_result_code in [CreateAccountResultCode.LOW_RESERVE, PaymentResultCode.UNDERFUNDED]: return LowBalanceError(op_result_code) - if op_result_code == PaymentResultCode.SRC_NO_TRUST \ - or op_result_code == PaymentResultCode.NO_TRUST \ - or op_result_code == PaymentResultCode.SRC_NOT_AUTHORIZED \ - or op_result_code == PaymentResultCode.NOT_AUTHORIZED: + if op_result_code in [PaymentResultCode.SRC_NO_TRUST, + PaymentResultCode.NO_TRUST, + PaymentResultCode.SRC_NOT_AUTHORIZED, + PaymentResultCode.NOT_AUTHORIZED]: return AccountNotActivatedError(error_code=op_result_code) return InternalError(op_result_code, {'internal_error': 'unknown operation error'}) diff --git a/kin/monitors.py b/kin/monitors.py index 1a676f2..5f714e9 100644 --- a/kin/monitors.py +++ b/kin/monitors.py @@ -1,227 +1,68 @@ """Contains classes for monitoring the blockchain""" -from threading import Thread, Event - from .blockchain.utils import is_valid_address -from .transactions import OperationTypes, SimplifiedTransaction ,RawTransaction -from .errors import AccountNotFoundError, CantSimplifyError, StoppedMonitorError, StellarAddressInvalidError +from .transactions import OperationTypes, SimplifiedTransaction, RawTransaction +from .errors import CantSimplifyError, StellarAddressInvalidError + +from typing import Optional, AsyncGenerator import logging logger = logging.getLogger(__name__) -class SingleMonitor: - """Single Monitor to monitor Kin payment on a single account""" - - def __init__(self, kin_client, address, callback_fn): - """ - Monitors a single account for kin payments - :param kin.KinClient kin_client: a kin client directed to the correct network - :param str address: address to watch - :param callback_fn: the function to call on each received payment as `callback_fn(address, tx_data, monitor)`. - :type: callable[str,kin.transactions.SimplifiedTransaction ,kin.monitors.MultiMonitor] - """ - self.kin_client = kin_client - self.callback_fn = callback_fn - - if not address: - raise ValueError('no address to monitor') - - if not is_valid_address(address): - raise StellarAddressInvalidError('invalid address: {}'.format(address)) - - if not self.kin_client.does_account_exists(address): - raise AccountNotFoundError(address) - - self.address = address - - # Currently, due to nonstandard SSE implementation in Horizon, - # using cursor=now will block until the first tx happens. - # Instead, we determine the cursor ourselves. - # Fix will be for horizon to send any message just to start a connection. - # This will cause a tx - params = {} - reply = self.kin_client.horizon.account_transactions(address, params={'order': 'desc', 'limit': 2}) - if len(reply['_embedded']['records']) == 2: - cursor = reply['_embedded']['records'][1]['paging_token'] - params = {'cursor': cursor} - - # make synchronous SSE request (will raise errors in the current thread) - self.sse_client = self.kin_client.horizon.account_transactions(address, sse=True, params=params) - - self.stop_event = Event() - # start monitoring thread - self.thread = Thread(target=self.event_processor, args=(self.stop_event,)) - self.thread.daemon = True - self.thread.start() - - def event_processor(self, stop_event): - """ - Method to filter through SSE events and find kin payments for an account - :param threading.Event stop_event: an event that can be used to stop this method - """ - import json +async def single_monitor(kin_client: 'KinClient', address: str, + timeout: Optional[float] = None) -> AsyncGenerator[SimplifiedTransaction, None]: + """ + Monitors a single account for kin payments + + :param kin_client: a kin client directed to the correct network + :param address: address to watch + :param timeout: How long to wait for a new event + + :raises: asyncio.TimeoutError: If too much time has passed between events (only if "timeout" is set) + """ + if not is_valid_address(address): + raise StellarAddressInvalidError('invalid address: {}'.format(address)) + + sse_client = await kin_client.horizon.account_transactions(address, sse=True, sse_timeout=timeout) + + async for tx in sse_client: try: - for event in self.sse_client: - if stop_event.is_set(): - return - if event.event != 'message': - continue - try: - tx = json.loads(event.data) - - try: - tx_data = SimplifiedTransaction(RawTransaction(tx)) - except CantSimplifyError: - continue - - if tx_data.operation.type != OperationTypes.PAYMENT: - continue - - self.callback_fn(self.address, tx_data, self) - - except Exception as ex: - logger.exception(ex) - continue - except TypeError: - # If we got a type error, that means retry was none, so we should end the thread - return - - def stop(self): - """ - Stop monitoring the account. - - The thread will terminate in up to X seconds, where X is the timeout set by the blockchain. - """ - - # Set the stop event, this will terminate the thread once we get the next event from the blockchain. - self.stop_event.set() - - # Change the retry value, - # this will cause an exception when trying to reconnect after timeout by the blockchain, - # which will terminate the thread. - self.sse_client.retry = None - - -class MultiMonitor: - """Multi Monitor to monitor Kin payment on a multiple accounts""" - - def __init__(self, kin_client, addresses, callback_fn): - """ - Monitors multiple accounts for kin payments - :param kin.KinClient kin_client: a kin client directed to the correct network - :param str addresses: addresses to watch - :param callback_fn: the function to call on each received payment as `callback_fn(address, tx_data, monitor)`. - :type: callable[str,kin.transactions.SimplifiedTransaction ,kin.monitors.MultiMonitor] - """ - self.kin_client = kin_client - self.callback_fn = callback_fn - - if not addresses: - raise ValueError('no address to monitor') - - for address in addresses: - if not is_valid_address(address): - raise StellarAddressInvalidError('invalid address: {}'.format(address)) - if not self.kin_client.does_account_exists(address): - raise AccountNotFoundError(address) - - self.addresses = addresses - - # Currently, due to nonstandard SSE implementation in Horizon, - # using cursor=now will block until the first tx happens. - # Instead, we determine the cursor ourselves. - # Fix will be for horizon to send any message just to start a connection - params = {} - reply = self.kin_client.horizon.transactions(params={'order': 'desc', 'limit': 1}) - if len(reply['_embedded']['records']) == 1: - cursor = reply['_embedded']['records'][0]['paging_token'] - params = {'cursor': cursor} - - # make synchronous SSE request (will raise errors in the current thread) - self.sse_client = self.kin_client.horizon.transactions(sse=True, params=params) - - self.stop_event = Event() - # start monitoring thread - self.thread = Thread(target=self.event_processor, args=(self.stop_event,)) - self.thread.daemon = True - self.thread.start() - - def event_processor(self, stop_event): - """ - Method to filter through SSE events and find kin payments for an account - :param threading.Event stop_event: an event that can be used to stop this method - """ - import json + tx_data = SimplifiedTransaction(RawTransaction(tx)) + except CantSimplifyError: + logger.debug("SSE transaction couldn't be simplified: ", tx) + continue + + if tx_data.operation.type != OperationTypes.PAYMENT: + logger.debug("Non-payment SSE transaction skipped: ", tx_data) + continue + + yield tx_data + + +async def multi_monitor(kin_client: 'KinClient', addresses: set) -> AsyncGenerator[SimplifiedTransaction, None]: + """ + Monitors a single account for kin payments + + :param kin_client: a kin client directed to the correct network + :param addresses: set of addresses to watch + """ + + sse_client = await kin_client.horizon.transactions(sse=True) + + async for tx in sse_client: try: - for event in self.sse_client: - if stop_event.is_set(): - return - if event.event != 'message': - continue - try: - tx = json.loads(event.data) - - try: - tx_data = SimplifiedTransaction(RawTransaction(tx)) - except CantSimplifyError: - continue - - if tx_data.operation.type != OperationTypes.PAYMENT: - continue - - if tx_data.source in self.addresses: - self.callback_fn(tx_data.source, tx_data, self) - if tx_data.operation.destination in self.addresses: - self.callback_fn(tx_data.operation.destination, tx_data, self) - - except Exception as ex: - logger.exception(ex) - continue - except TypeError: - # If we got a type error, that means retry was none, so we should end the thread - return - - def stop(self): - """ - Stop monitoring the account. - - The thread will terminate in up to X seconds, where X is the timeout set by the blockchain. - """ - - # Set the stop event, this will terminate the thread once we get the next event from the blockchain. - self.stop_event.set() - - # Change the retry value, - # this will cause an exception when trying to reconnect after timeout by the blockchain, - # which will terminate the thread. - self.sse_client.retry = None - - def add_address(self, address): - """ - Add address to the watched addresses list - :param address: address to add - """ - if address in self.addresses: - return - - if self.stop_event.is_set(): - raise StoppedMonitorError() - - if not is_valid_address(address): - raise StellarAddressInvalidError('invalid address: {}'.format(address)) - - if not self.kin_client.does_account_exists(address): - raise AccountNotFoundError(address) - - self.addresses.append(address) - - def remove_address(self, address): - """ - Remove an address for the list of addresses to watch - :param address: the address to remove - """ - if self.stop_event.is_set(): - raise StoppedMonitorError() - - self.addresses.remove(address) + tx_data = SimplifiedTransaction(RawTransaction(tx)) + except CantSimplifyError: + logger.debug("SSE transaction couldn't be simplified: ", tx) + continue + + if tx_data.operation.type != OperationTypes.PAYMENT: + logger.debug("Non-payment SSE transaction skipped: ", tx_data) + continue + + # Will yield twice if both of these are correct. (someone sent to himself) - which it fine + if tx_data.source in addresses: + yield tx_data.source, tx_data + if tx_data.operation.destination in addresses: + yield tx_data.operation.destination, tx_data \ No newline at end of file diff --git a/kin/transactions.py b/kin/transactions.py index 37bc392..c99bcaa 100644 --- a/kin/transactions.py +++ b/kin/transactions.py @@ -1,5 +1,4 @@ """Contains classes and methods related to transactions and operations""" -import sys from hashlib import sha256 from binascii import hexlify import base64 @@ -10,11 +9,11 @@ from kin_base.transaction_envelope import TransactionEnvelope as BaseEnvelop from kin_base.memo import TextMemo, NoneMemo from kin_base.operation import Payment, CreateAccount -from .blockchain.channel_manager import CHANNEL_PUT_TIMEOUT from .errors import CantSimplifyError from .config import MEMO_TEMPLATE +from typing import Union, Optional # This is needed in order to calculate transaction hash. # It is the xdr representation of kin_base.XDR.const.ENVELOP_TYPE_TX (2) @@ -22,10 +21,26 @@ NATIVE_ASSET_TYPE = 'native' +class RawTransaction: + def __init__(self, horizon_tx_response: dict): + """ + Class to hold raw info about a transaction + + :param horizon_tx_response: the json response from an horizon query + """ + # Network_id is left as '' since we override the hash anyway + self.tx = decode_transaction(horizon_tx_response['envelope_xdr'], network_id='', simple=False) + self.timestamp = horizon_tx_response['created_at'] + self.hash = horizon_tx_response['hash'] + + class SimplifiedTransaction: - """Class to hold simplified info about a transaction""" + def __init__(self, raw_tx: RawTransaction): + """ + Class to hold simplified info about a transaction - def __init__(self, raw_tx): + :param raw_tx: The raw transaction object to simplify + """ self.id = raw_tx.hash self.timestamp = raw_tx.timestamp @@ -44,9 +59,12 @@ def __init__(self, raw_tx): class SimplifiedOperation: - """Class to hold simplified info about a operation""" + def __init__(self, op_data: Union[CreateAccount, Payment]): + """ + Class to hold simplified info about a operation - def __init__(self, op_data): + :param op_data: Operation to simplify + """ if isinstance(op_data, Payment): # Raise error if its not a KIN payment if op_data.asset.type != NATIVE_ASSET_TYPE: @@ -65,18 +83,6 @@ def __init__(self, op_data): raise CantSimplifyError('Cant simplify operation with {} operation'.format(op_data.type)) -class RawTransaction: - """Class to hold raw info about a transaction""" - def __init__(self, horizon_tx_response): - """ - :param dict horizon_tx_response: the json response from an horizon query - """ - # Network_id is left as '' since we override the hash anyway - self.tx = decode_transaction(horizon_tx_response['envelope_xdr'], network_id='', simple=False) - self.timestamp = horizon_tx_response['created_at'] - self.hash = horizon_tx_response['hash'] - - class OperationTypes(Enum): """Possible operation types for a simple operation""" @@ -84,13 +90,13 @@ class OperationTypes(Enum): CREATE_ACCOUNT = 2 -def build_memo(app_id, memo): +def build_memo(app_id: str, memo: Union[str, None]) -> str: """ Build a memo for a tx that fits the pre-defined template - :param str app_id: The app_id to include in the memo - :param str memo: The memo to include + + :param app_id: The app_id to include in the memo + :param memo: The memo to include :return: the finished memo - :rtype: str """ finished_memo = MEMO_TEMPLATE.format(app_id) if memo is not None: @@ -99,14 +105,15 @@ def build_memo(app_id, memo): return finished_memo -def decode_transaction(b64_tx, network_id, simple=True): +def decode_transaction(b64_tx: str, network_id: str, simple: Optional[bool] = True) -> Union[SimplifiedTransaction, RawTransaction]: """ Decode a base64 transaction envelop - :param str b64_tx: a transaction envelop encoded in base64 - :param boolean simple: should the tx be simplified - :param str network_id: the network_id for the transaction + + :param b64_tx: a transaction envelop encoded in base64 + :param simple: should the tx be simplified + :param network_id: the network_id for the transaction :return: The transaction - :rtype kin.transactions.SimplifiedTransaction | kin_base.Transaction + :raises: KinErrors.CantSimplifyError: if the tx cannot be simplified """ unpacker = Xdr.StellarXDRUnpacker(base64.b64decode(b64_tx)) @@ -124,7 +131,7 @@ def decode_transaction(b64_tx, network_id, simple=True): return envelop.tx -def calculate_tx_hash(tx, network_passphrase_hash): +def calculate_tx_hash(tx: BaseTransaction, network_passphrase_hash: bytes) -> str: """ Calculate a tx hash. @@ -132,9 +139,10 @@ def calculate_tx_hash(tx, network_passphrase_hash): 1. A sha256 hash of the network_id + 2. The xdr representation of ENVELOP_TYPE_TX + 3. The xdr representation of the transaction + :param tx: The builder's transaction object :param network_passphrase_hash: The network passphrase hash - :return: + :return: The hex encoded transaction hash """ # Pack the transaction to xdr packer = Xdr.StellarXDRPacker() diff --git a/kin/utils.py b/kin/utils.py index 6c3248b..61a3201 100644 --- a/kin/utils.py +++ b/kin/utils.py @@ -2,66 +2,72 @@ from hashlib import sha256 +from kin_base import Builder + from .client import KinClient -from .blockchain.builder import Builder from .blockchain.keypair import Keypair from .errors import AccountNotFoundError +from .blockchain.environment import Environment + +from typing import List -def create_channels(master_seed, environment, amount, starting_balance, salt): +async def create_channels(master_seed: str, environment: Environment, amount: int, + starting_balance: float, salt: str) -> List[str]: """ Create HD seeds based on a master seed and salt - :param str master_seed: The master seed that creates the seeds - :param Kin.Environment environment: The blockchain environment to create the seeds on - :param int amount: Number of seeds to create (Up to 100) - :param float starting_balance: Starting balance to create channels with - :param str salt: A string to be used to create the HD seeds + + :param master_seed: The master seed that creates the seeds + :param environment: The blockchain environment to create the seeds on + :param amount: Number of seeds to create (Up to 100) + :param starting_balance: Starting balance to create channels with + :param salt: A string to be used to create the HD seeds :return: The list of seeds generated - :rtype list[str] """ - client = KinClient(environment) - base_key = Keypair(master_seed) - if not client.does_account_exists(base_key.public_address): - raise AccountNotFoundError(base_key.public_address) + async with KinClient(environment) as client: + base_key = Keypair(master_seed) + if not await client.does_account_exists(base_key.public_address): + raise AccountNotFoundError(base_key.public_address) - fee = client.get_minimum_fee() + fee = await client.get_minimum_fee() - channels = get_hd_channels(master_seed, salt, amount) + channels = get_hd_channels(master_seed, salt, amount) - # Create a builder for the transaction - builder = Builder(environment.name, client.horizon, fee, master_seed) + # Create a builder for the transaction + builder = Builder(client.horizon, environment.name, fee, master_seed) - # Find out if this salt+seed combination was ever used to create channels. - # If so, the user might only be interested in adding channels, - # so we need to find what seed to start from + # Find out if this salt+seed combination was ever used to create channels. + # If so, the user might only be interested in adding channels, + # so we need to find what seed to start from - # First check if the last channel exists, if it does, we don't need to create any channel. - if client.does_account_exists(Keypair.address_from_seed(channels[-1])): - return channels + # First check if the last channel exists, if it does, we don't need to create any channel. + if await client.does_account_exists(Keypair.address_from_seed(channels[-1])): + return channels - for index, seed in enumerate(channels): - if client.does_account_exists(Keypair.address_from_seed(seed)): - continue + for index, seed in enumerate(channels): + if await client.does_account_exists(Keypair.address_from_seed(seed)): + continue - # Start creating from the current seed forward - for channel_seed in channels[index:]: - builder.append_create_account_op(Keypair.address_from_seed(channel_seed), str(starting_balance)) + # Start creating from the current seed forward + for channel_seed in channels[index:]: + builder.append_create_account_op(Keypair.address_from_seed(channel_seed), str(starting_balance)) - builder.update_sequence() - builder.sign() - builder.submit() - break + await builder.update_sequence() + builder.sign() + await builder.submit() + break return channels -def get_hd_channels(master_seed, salt, amount): +def get_hd_channels(master_seed: str, salt: str, amount: int) -> List[str]: """ Get a list of channels generated based on a seed and salt - :param str master_seed: the base seed that created the channels - :param str salt: A string to be used to generate the seeds - :param int amount: Number of seeds to generate (Up to 100) + + :param master_seed: the base seed that created the channels + :param salt: A string to be used to generate the seeds + :param amount: Number of seeds to generate (Up to 100) :return: The list of seeds generated :rtype list[str] """ diff --git a/kin/version.py b/kin/version.py index 3a5935a..3d67cd6 100644 --- a/kin/version.py +++ b/kin/version.py @@ -1 +1 @@ -__version__ = "2.3.1" +__version__ = "2.4.0" diff --git a/requirements-dev.txt b/requirements-dev.txt index 09ed9a7..2c59dd8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,5 @@ -attrs==17.4.0 codecov==2.0.15 -coverage==4.5.1 -funcsigs==1.0.2 -pluggy==0.6.0 -py==1.5.2 -pytest-cov==2.5.1 -pytest==3.4.0 +coverage==4.5.3 +pytest-cov==2.6.1 +pytest==4.3.1 +pytest-asyncio==0.10.0 diff --git a/requirements.txt b/requirements.txt index 1b2881e..febcbcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -kin-base==1.0.7 -schematics==2.0.1 +kin-base==1.3.0 +schematics==2.1.* +async-generator; python_version < '3.7' diff --git a/setup.py b/setup.py index 305bf9c..0bcb5a4 100644 --- a/setup.py +++ b/setup.py @@ -5,9 +5,9 @@ exec(open("kin/version.py").read()) with open('requirements.txt') as f: - requires = [line.split(' ')[0] for line in f] + requires = f.readlines() with open('requirements-dev.txt') as f: - tests_requires = [line.split(' ')[0] for line in f] + tests_requires = f.readlines() setup( name='kin-sdk', @@ -31,5 +31,5 @@ ], install_requires=requires, tests_require=tests_requires, - python_requires='>=3.4', + python_requires='>=3.6', ) diff --git a/test/conftest.py b/test/conftest.py index eb8653d..65112ad 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import pytest +import asyncio from kin import Environment, KinClient import logging @@ -27,21 +28,30 @@ def __init__(self, **entries): self.__dict__.update(entries) environment=docker_environment) -@pytest.fixture(scope='session') -def test_client(setup): +@pytest.yield_fixture(scope='session') +async def test_client(setup): # Create a base KinClient print('Created a base KinClient') - return KinClient(setup.environment) + client = KinClient(setup.environment) + yield client + await client.__aexit__(None, None, None) @pytest.fixture(scope='session') -def test_account(setup, test_client): +async def test_account(setup, test_client): # Create and fund the sdk account from the root account sdk_address = 'GAIDUTTQ5UIZDW7VZ2S3ZAFLY6LCRT5ZVHF5X3HDJVDQ4OJWYGJVJDZB' sdk_seed = 'SBKI7MEF62NHHH3AOXBHII46K2FD3LVH63FYHUDLTBUYT3II6RAFLZ7B' root_account = test_client.kin_account(setup.issuer_seed) - root_account.create_account(sdk_address, 10000 + 1000000, fee=100) + await root_account.create_account(sdk_address, 10000 + 1000000, fee=100) print('Created the base kin account') return test_client.kin_account(sdk_seed) + + +@pytest.yield_fixture(scope='session') +def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() diff --git a/test/test_account.py b/test/test_account.py index 1de7ef6..90f1ae0 100644 --- a/test/test_account.py +++ b/test/test_account.py @@ -8,9 +8,6 @@ def test_create_basic(test_client, test_account): - with pytest.raises(KinErrors.AccountNotFoundError): - account = test_client.kin_account('SD6IDZHCMX3Z4QPDIC33PECKLLY572DAA5S3DZDALEVVACJKSZPVPJC6') - with pytest.raises(KinErrors.StellarSecretInvalidError): account = test_client.kin_account('bad format') @@ -18,7 +15,7 @@ def test_create_basic(test_client, test_account): assert account assert account.keypair.secret_seed == SDK_SEED assert account.keypair.public_address == SDK_PUBLIC - assert account.horizon + assert account._client is test_client assert account.channel_manager @@ -26,22 +23,24 @@ def test_get_address(test_client, test_account): assert test_account.get_public_address() == SDK_PUBLIC -def test_create_account(setup, test_client, test_account): +@pytest.mark.asyncio +async def test_create_account(setup, test_client, test_account): with pytest.raises(KinErrors.AccountExistsError): - test_account.create_account(setup.issuer_address, 0, fee=100) + await test_account.create_account(setup.issuer_address, 0, fee=100) - test_account.create_account('GDN7KB72OO7G6VBD3CXNRFXVELLW6F36PS42N7ASZHODV7Q5GYPETQ74', 0, fee=100) - assert test_client.does_account_exists('GDN7KB72OO7G6VBD3CXNRFXVELLW6F36PS42N7ASZHODV7Q5GYPETQ74') + await test_account.create_account('GDN7KB72OO7G6VBD3CXNRFXVELLW6F36PS42N7ASZHODV7Q5GYPETQ74', 0, fee=100) + assert await test_client.does_account_exists('GDN7KB72OO7G6VBD3CXNRFXVELLW6F36PS42N7ASZHODV7Q5GYPETQ74') -def test_send_kin(test_client, test_account): +@pytest.mark.asyncio +async def test_send_kin(test_client, test_account): recipient = 'GBZWWLRJRWL4DLYOJMCHXJUOJJY5NLNJHQDRQHVQH43KFCPC3LEOWPYM' - test_client.friendbot(recipient) + await test_client.friendbot(recipient) - test_account.send_kin(recipient, 10, fee=100) - balance = test_client.get_account_balance(recipient) + await test_account.send_kin(recipient, 10, fee=100) + balance = await test_client.get_account_balance(recipient) with pytest.raises(KinErrors.NotValidParamError): - test_account.send_kin(recipient, 1.1234567898765, fee=100) + await test_account.send_kin(recipient, 1.1234567898765, fee=100) assert balance == 10 @@ -56,9 +55,9 @@ def test_build_create_account(test_account): builder = test_account.build_create_account(recipient, starting_balance=10, fee=100) - assert builder + def test_build_send_kin(test_account): recipient = 'GBZWWLRJRWL4DLYOJMCHXJUOJJY5NLNJHQDRQHVQH43KFCPC3LEOWPYM' with pytest.raises(KinErrors.StellarAddressInvalidError): @@ -73,44 +72,47 @@ def test_build_send_kin(test_account): assert builder -def test_auto_top_up(test_client, test_account): +@pytest.mark.asyncio +async def test_auto_top_up(test_client, test_account): channel = 'SBYU2EBGTTGIFR4O4K4SQXTD4ISMVX4R5TX2TTB4SWVIA5WVRS2MHN4K' public = 'GBKZAXTDJRYBK347KDTOFWEBDR7OW3U67XV2BOF2NLBNEGRQ2WN6HFK6' - test_account.create_account(public, 0, fee=100) + await test_account.create_account(public, 0, fee=100) account = test_client.kin_account(test_account.keypair.secret_seed, channel_secret_keys=[channel]) - account.send_kin(public, 10, fee=100) + await account.send_kin(public, 10, fee=100) - channel_balance = test_client.get_account_balance(public) + channel_balance = await test_client.get_account_balance(public) # channel should have ran out of funds, so the base account should have topped it up assert channel_balance > 0 -def test_memo(test_client, test_account): +@pytest.mark.asyncio +async def test_memo(test_client, test_account): recipient1 = 'GCT3YLKNVEILHUOZYK3QPOVZWWVLF5AE5D24Y6I4VH7WGZYBFU2HSXYX' recipient2 = 'GDR375ZLWHZUFH2SWXFEH7WVPK5G3EQBLXPZKYEFJ5EAW4WE4WIQ5BP3' - tx1 = test_account.create_account(recipient1, 0, memo_text='Hello', fee=100) + tx1 = await test_account.create_account(recipient1, 0, memo_text='Hello', fee=100) account2 = test_client.kin_account(test_account.keypair.secret_seed, app_id='test') - tx2 = account2.create_account(recipient2, 0, memo_text='Hello', fee=100) + tx2 = await account2.create_account(recipient2, 0, memo_text='Hello', fee=100) sleep(5) - tx1_data = test_client.get_transaction_data(tx1) - tx2_data = test_client.get_transaction_data(tx2) + tx1_data = await test_client.get_transaction_data(tx1) + tx2_data = await test_client.get_transaction_data(tx2) assert tx1_data.memo == MEMO_TEMPLATE.format(ANON_APP_ID) + 'Hello' assert tx2_data.memo == MEMO_TEMPLATE.format('test') + 'Hello' with pytest.raises(KinErrors.NotValidParamError): - account2.create_account(recipient2, 0, memo_text='a'*25, fee=100) + await account2.create_account(recipient2, 0, memo_text='a'*25, fee=100) + def test_get_transaction_builder(test_account): builder = test_account.get_transaction_builder(fee=100) assert builder assert builder.address == test_account.get_public_address() assert builder.fee == 100 - assert builder.horizon is test_account.horizon - assert builder.network == test_account._client.environment.name + assert builder.horizon is test_account._client.horizon + assert builder.network_name == test_account._client.environment.name def test_whitelist_transaction(test_account): diff --git a/test/test_builder.py b/test/test_builder.py deleted file mode 100644 index 35519ae..0000000 --- a/test/test_builder.py +++ /dev/null @@ -1,96 +0,0 @@ -import pytest -import time - -from kin.blockchain.builder import Builder -from kin.blockchain.horizon import Horizon -from kin import KinErrors - - -def test_create_fail(): - with pytest.raises(KinErrors.StellarSecretInvalidError): - Builder(secret='bad', network_name=None, horizon=None, fee=100) - - -def test_create(): - seed = 'SASKOJJOG7MLXAWJGE6QNCWH5ZIBH5LWQCXPRGDHUKUOB4RBRWXXFZ2T' - address = 'GCAZ7QXD6UJ5NOVWYTNKLNP36DPJZMRO67LQ4X5CH2IHY3OG5QGECGYQ' - - # with secret - builder = Builder(secret=seed, network_name=None, horizon=None, fee=100) - assert builder - assert builder.keypair.seed().decode() == seed - assert builder.address == address - - -def test_create_custom(test_client): - seed = 'SASKOJJOG7MLXAWJGE6QNCWH5ZIBH5LWQCXPRGDHUKUOB4RBRWXXFZ2T' - address = 'GCAZ7QXD6UJ5NOVWYTNKLNP36DPJZMRO67LQ4X5CH2IHY3OG5QGECGYQ' - - horizon = Horizon() - builder = Builder(secret=seed, horizon=horizon, network_name='custom', fee=100) - assert builder - assert builder.horizon - assert builder.network == 'custom' - assert builder - assert builder.horizon == horizon - - # with horizon fixture - builder = Builder(secret=seed, - horizon=test_client.horizon, - network_name=test_client.environment.name, fee=100) - assert builder - - -@pytest.fixture(scope='session') -def test_builder(test_client, test_account): - builder = test_account.get_transaction_builder(100) - return builder - - -def test_sign(test_builder): - address = 'GCAZ7QXD6UJ5NOVWYTNKLNP36DPJZMRO67LQ4X5CH2IHY3OG5QGECGYQ' - - test_builder.append_create_account_op(address, '100') - assert len(test_builder.ops) == 1 - test_builder.sign() - assert test_builder.te - assert test_builder.tx - - -def test_clear(test_builder): - test_builder.clear() - assert len(test_builder.ops) == 0 - assert not test_builder.te - assert not test_builder.tx - - -def test_get_sequence(test_builder): - assert test_builder.get_sequence() - - -def test_update_sequence(test_builder): - test_builder.update_sequence() - # TODO: remove str() after kin-base is fixed - assert test_builder.sequence == str(test_builder.get_sequence()) - - -def test_set_channel(test_client, test_builder): - channel_addr = 'GBC6PXY4ZSO356NUPF2A2SDVEBQB2RG7XN6337NBW4F24APGHEVR3IIU' - channel = 'SA4BHY26Q3C3BSYKKGDM7UVMZ4YF6YBLX6AOWYEDBXPLOR7WQ5EJXN6X' - test_client.friendbot(channel_addr) - time.sleep(5) - test_builder.set_channel(channel) - assert test_builder.address == channel_addr - assert test_builder.sequence == str(test_builder.get_sequence()) - - -def test_next(test_builder): - address = 'GCAZ7QXD6UJ5NOVWYTNKLNP36DPJZMRO67LQ4X5CH2IHY3OG5QGECGYQ' - - sequence = test_builder.get_sequence() - test_builder.append_create_account_op(address, '100') - test_builder.sign() - test_builder.next() - assert not test_builder.tx - assert not test_builder.te - assert test_builder.sequence == str(int(sequence) + 1) diff --git a/test/test_channel_manager.py b/test/test_channel_manager.py index f57ea0f..ee3d125 100644 --- a/test/test_channel_manager.py +++ b/test/test_channel_manager.py @@ -1,5 +1,4 @@ import pytest -from kin import KinErrors from kin.blockchain.channel_manager import ChannelManager, ChannelStatuses, ChannelPool @@ -18,17 +17,18 @@ def test_create_channel_manager(): def test_create_channel_pool(): pool = ChannelPool(channels) - assert len(channels) == len(pool.queue) + assert len(channels) == len(pool._queue) for channel in channels: - assert pool.queue[channel] == ChannelStatuses.FREE + assert pool._queue[channel] == ChannelStatuses.FREE -def test_pool_get_and_put(): +@pytest.mark.asyncio +async def test_pool_get_and_put(): pool = ChannelPool(channels) - channel = pool.get() - assert pool.queue[channel] == ChannelStatuses.TAKEN - pool.put(channel) - assert pool.queue[channel] == ChannelStatuses.FREE + channel = await pool.get() + assert pool._queue[channel] == ChannelStatuses.TAKEN + await pool.put(channel) + assert pool._queue[channel] == ChannelStatuses.FREE def test_q_size(): @@ -36,16 +36,22 @@ def test_q_size(): assert pool.qsize() == 5 +def test_empty(): + pool = ChannelPool([]) + assert pool.empty() + + def test_get_available_channels(): pool = ChannelPool(channels) free_channels = pool.get_free_channels() assert len(free_channels) == 5 for channel in free_channels: - assert pool.queue[channel] == ChannelStatuses.FREE + assert pool._queue[channel] == ChannelStatuses.FREE -def test_get_channel(): +@pytest.mark.asyncio +async def test_get_channel(): manager = ChannelManager(channels) - with manager.get_channel() as channel: - assert manager.channel_pool.queue[channel] == ChannelStatuses.TAKEN - assert manager.channel_pool.queue[channel] == ChannelStatuses.FREE \ No newline at end of file + async with manager.get_channel() as channel: + assert manager.channel_pool._queue[channel] == ChannelStatuses.TAKEN + assert manager.channel_pool._queue[channel] == ChannelStatuses.FREE diff --git a/test/test_client.py b/test/test_client.py index 25374a8..1518f6b 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -13,28 +13,30 @@ def test_create(): assert client.network == TEST_ENVIRONMENT.name -def test_get_minimum_fee(test_client): - assert test_client.get_minimum_fee() == 100 +@pytest.mark.asyncio +async def test_get_minimum_fee(test_client): + assert await test_client.get_minimum_fee() == 100 -def test_get_config(setup, test_client): +@pytest.mark.asyncio +async def test_get_config(setup, test_client): from kin import Environment # bad Horizon endpoint env = Environment('bad', 'bad', 'bad', 'GDZA33STWFOVWLHAFXEOYS46DA2VMIQH3MCCVVGAUENMZMMZJFAHT4KO') - status = KinClient(env).get_config() + status = await KinClient(env).get_config() assert status['horizon'] assert status['horizon']['online'] is False - assert status['horizon']['error'].startswith("Invalid URL 'bad': No schema supplied") + assert 'InvalidURL' in status['horizon']['error'] # no Horizon on endpoint env = Environment('bad', 'http://localhost:666', 'bad', 'GDZA33STWFOVWLHAFXEOYS46DA2VMIQH3MCCVVGAUENMZMMZJFAHT4KO') - status = KinClient(env).get_config() + status = await KinClient(env).get_config() assert status['horizon'] assert status['horizon']['online'] is False - assert status['horizon']['error'].find('Connection refused') > 0 + assert 'ClientConnectorError' in status['horizon']['error'] # success - status = test_client.get_config() + status = await test_client.get_config() assert status['environment'] == setup.environment.name assert status['horizon'] assert status['horizon']['uri'] == setup.environment.horizon_uri @@ -44,34 +46,36 @@ def test_get_config(setup, test_client): assert status['transport']['pool_size'] assert status['transport']['num_retries'] assert status['transport']['request_timeout'] - assert status['transport']['retry_statuses'] assert status['transport']['backoff_factor'] -def test_get_balance(test_client, test_account): - balance = test_client.get_account_balance(test_account.get_public_address()) +@pytest.mark.asyncio +async def test_get_balance(test_client, test_account): + balance = await test_client.get_account_balance(test_account.get_public_address()) assert balance > 0 -def test_does_account_exists(test_client, test_account): +@pytest.mark.asyncio +async def test_does_account_exists(test_client, test_account): with pytest.raises(KinErrors.StellarAddressInvalidError): - test_client.does_account_exists('bad') + await test_client.does_account_exists('bad') address = 'GB7F23F7235ADJ7T2L4LJZT46LA3256QAXIU56ANKPX5LSAAS3XVA465' - assert not test_client.does_account_exists(address) - assert test_client.does_account_exists(test_account.get_public_address()) + assert not await test_client.does_account_exists(address) + assert await test_client.does_account_exists(test_account.get_public_address()) -def test_get_account_data(test_client, test_account): +@pytest.mark.asyncio +async def test_get_account_data(test_client, test_account): with pytest.raises(KinErrors.StellarAddressInvalidError): - test_client.get_account_data('bad') + await test_client.get_account_data('bad') address = 'GBSZO2C63WM2DHAH4XGCXDW5VGAM56FBIOGO2KFRSJYP5I4GGCPAVKHW' with pytest.raises(KinErrors.AccountNotFoundError): - test_client.get_account_data(address) + await test_client.get_account_data(address) - acc_data = test_client.get_account_data(test_account.get_public_address()) + acc_data = await test_client.get_account_data(test_account.get_public_address()) assert acc_data assert acc_data.id == test_account.get_public_address() assert acc_data.sequence @@ -95,20 +99,21 @@ def test_get_account_data(test_client, test_account): assert str(acc_data) -def test_get_transaction_data(setup, test_client): +@pytest.mark.asyncio +async def test_get_transaction_data(setup, test_client): from kin import OperationTypes from kin.transactions import RawTransaction with pytest.raises(ValueError): - test_client.get_transaction_data('bad') + await test_client.get_transaction_data('bad') with pytest.raises(KinErrors.ResourceNotFoundError): - test_client.get_transaction_data('deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef') + await test_client.get_transaction_data('deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef') address = 'GAHTWFVYV4RF2AMEZP3X2VOK4HB3YOSARU7VNVTP7J2OLDSVOP564YEN' - tx_hash = test_client.friendbot(address) + tx_hash = await test_client.friendbot(address) sleep(5) - tx_data = test_client.get_transaction_data(tx_hash) + tx_data = await test_client.get_transaction_data(tx_hash) assert tx_data assert tx_data.id == tx_hash assert tx_data.timestamp @@ -119,42 +124,31 @@ def test_get_transaction_data(setup, test_client): assert tx_data.operation.destination == address assert tx_data.operation.starting_balance == 0 - tx_data = test_client.get_transaction_data(tx_hash, simple=False) + tx_data = await test_client.get_transaction_data(tx_hash, simple=False) assert isinstance(tx_data, RawTransaction) -def test_friendbot(test_client): +@pytest.mark.asyncio +async def test_friendbot(test_client): address = 'GDIPKVWPVCL5E5MX4UWMLCGXMDWEMEYAZGCI3TPJPVDG5ZFA6VJAA7RA' - test_client.friendbot(address) - assert test_client.does_account_exists(address) + await test_client.friendbot(address) + assert await test_client.does_account_exists(address) with pytest.raises(KinErrors.StellarAddressInvalidError): - test_client.friendbot('bad') + await test_client.friendbot('bad') -def test_verify_kin_payment(test_client, test_account): - address = 'GCZXR4ILXETTNQMUNF54ILRMPEG3UTUUMYKPUXU5633VCOABZZ63H7FJ' - tx_hash = test_client.friendbot(address) - sleep(5) - - assert not test_client.verify_kin_payment(tx_hash, 'source', 'destination', 123) - - tx_hash = test_account.send_kin('GCZXR4ILXETTNQMUNF54ILRMPEG3UTUUMYKPUXU5633VCOABZZ63H7FJ', 123, 100, 'Hello') - sleep(5) - assert test_client.verify_kin_payment(tx_hash, test_account.get_public_address(), address, 123) - assert test_client.verify_kin_payment(tx_hash, test_account.get_public_address(), address, 123, 'Hello', True) - - -def test_tx_history(test_client,test_account): +@pytest.mark.asyncio +async def test_tx_history(test_client,test_account): address = 'GA4GDLBEWVT5IZZ6JKR4BF3B6JJX5S6ISFC2QCC7B6ZVZWJDMR77HYP6' - test_client.friendbot(address) + await test_client.friendbot(address) txs = [] for _ in range(6): - txs.append(test_account.send_kin(address, 1, fee=100)) + txs.append(await test_account.send_kin(address, 1, fee=100)) # let horizon ingest the txs sleep(10) - tx_history = test_client.get_account_tx_history(test_account.get_public_address(), amount=6) + tx_history = await test_client.get_account_tx_history(test_account.get_public_address(), amount=6) history_ids = [tx.id for tx in tx_history] # tx history goes from latest to oldest @@ -162,10 +156,19 @@ def test_tx_history(test_client,test_account): assert txs == history_ids + # TODO: INCORRECT TESTING, broken # test paging config.MAX_RECORDS_PER_REQUEST = 2 - tx_history = test_client.get_account_tx_history(test_account.get_public_address(), amount=6) + tx_history = await test_client.get_account_tx_history(test_account.get_public_address(), amount=6) history_ids = [tx.id for tx in tx_history] assert txs == history_ids + + +@pytest.mark.asyncio +async def test_client_context(setup): + async with KinClient(TEST_ENVIRONMENT) as client: + context_channel = client + assert not context_channel.horizon._session.closed + assert context_channel.horizon._session.closed diff --git a/test/test_errors.py b/test/test_errors.py index 3bc69a6..402b21d 100644 --- a/test/test_errors.py +++ b/test/test_errors.py @@ -1,5 +1,3 @@ -from requests.exceptions import RequestException - from kin import KinErrors from kin.blockchain.errors import * @@ -13,10 +11,6 @@ def test_sdk_error(): def test_translate_error(): - e = KinErrors.translate_error(RequestException('error')) - assert isinstance(e, KinErrors.NetworkError) - assert e.extra['internal_error'] == 'error' - e = KinErrors.translate_error(Exception('error')) assert isinstance(e, KinErrors.InternalError) assert e.extra['internal_error'] == 'error' @@ -27,55 +21,55 @@ def test_translate_horizon_error(): fixtures = [ # RequestError - [HorizonErrorType.BAD_REQUEST, KinErrors.RequestError, 'bad request', {}], - [HorizonErrorType.FORBIDDEN, KinErrors.RequestError, 'bad request', {}], - [HorizonErrorType.NOT_ACCEPTABLE, KinErrors.RequestError, 'bad request', {}], - [HorizonErrorType.UNSUPPORTED_MEDIA_TYPE, KinErrors.RequestError, 'bad request', {}], - [HorizonErrorType.NOT_IMPLEMENTED, KinErrors.RequestError, 'bad request', {}], - [HorizonErrorType.BEFORE_HISTORY, KinErrors.RequestError, 'bad request', {}], - [HorizonErrorType.STALE_HISTORY, KinErrors.RequestError, 'bad request', {}], - [HorizonErrorType.TRANSACTION_MALFORMED, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.BAD_REQUEST, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.FORBIDDEN, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.NOT_ACCEPTABLE, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.UNSUPPORTED_MEDIA_TYPE, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.NOT_IMPLEMENTED, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.BEFORE_HISTORY, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.STALE_HISTORY, KinErrors.RequestError, 'bad request', {}], + [KinErrors.HorizonErrorType.TRANSACTION_MALFORMED, KinErrors.RequestError, 'bad request', {}], # ResourceNotFoundError - [HorizonErrorType.NOT_FOUND, KinErrors.ResourceNotFoundError, 'resource not found', {}], + [KinErrors.HorizonErrorType.NOT_FOUND, KinErrors.ResourceNotFoundError, 'resource not found', {}], # ServerError - [HorizonErrorType.RATE_LIMIT_EXCEEDED, KinErrors.ServerError, 'server error', {}], - [HorizonErrorType.SERVER_OVER_CAPACITY, KinErrors.ServerError, 'server error', {}], + [KinErrors.HorizonErrorType.RATE_LIMIT_EXCEEDED, KinErrors.ServerError, 'server error', {}], + [KinErrors.HorizonErrorType.SERVER_OVER_CAPACITY, KinErrors.ServerError, 'server error', {}], # InternalError - [HorizonErrorType.INTERNAL_SERVER_ERROR, KinErrors.InternalError, 'internal error', {}], + [KinErrors.HorizonErrorType.INTERNAL_SERVER_ERROR, KinErrors.InternalError, 'internal error', {}], ['unknown', KinErrors.InternalError, 'internal error', {'internal_error': 'unknown horizon error'}], ] for fixture in fixtures: - err_dict['type'] = HORIZON_NS_PREFIX + fixture[0] - e = KinErrors.translate_horizon_error(HorizonError(err_dict)) + err_dict['type'] = KinErrors.HORIZON_NS_PREFIX + fixture[0] + e = KinErrors.translate_horizon_error(KinErrors.HorizonError(err_dict)) assert isinstance(e, fixture[1]) assert e.error_code == fixture[0] assert e.message == fixture[2] def test_translate_transaction_error(): - err_dict = dict(type=HORIZON_NS_PREFIX + HorizonErrorType.TRANSACTION_FAILED, title='title', status=400, + err_dict = dict(type=KinErrors.HORIZON_NS_PREFIX + KinErrors.HorizonErrorType.TRANSACTION_FAILED, title='title', status=400, detail='detail', instance='instance', extras={'result_codes': {'operations': [], 'transaction': 'tx_failed'}}) fixtures = [ # RequestError - [TransactionResultCode.TOO_EARLY, KinErrors.RequestError, 'bad request', {}], - [TransactionResultCode.TOO_LATE, KinErrors.RequestError, 'bad request', {}], - [TransactionResultCode.MISSING_OPERATION, KinErrors.RequestError, 'bad request', {}], - [TransactionResultCode.BAD_AUTH, KinErrors.RequestError, 'bad request', {}], - [TransactionResultCode.BAD_AUTH_EXTRA, KinErrors.RequestError, 'bad request', {}], - [TransactionResultCode.BAD_SEQUENCE, KinErrors.RequestError, 'bad request', {}], - [TransactionResultCode.INSUFFICIENT_FEE, KinErrors.RequestError, 'bad request', {}], + [KinErrors.TransactionResultCode.TOO_EARLY, KinErrors.RequestError, 'bad request', {}], + [KinErrors.TransactionResultCode.TOO_LATE, KinErrors.RequestError, 'bad request', {}], + [KinErrors.TransactionResultCode.MISSING_OPERATION, KinErrors.RequestError, 'bad request', {}], + [KinErrors.TransactionResultCode.BAD_AUTH, KinErrors.RequestError, 'bad request', {}], + [KinErrors.TransactionResultCode.BAD_AUTH_EXTRA, KinErrors.RequestError, 'bad request', {}], + [KinErrors.TransactionResultCode.BAD_SEQUENCE, KinErrors.RequestError, 'bad request', {}], + [KinErrors.TransactionResultCode.INSUFFICIENT_FEE, KinErrors.RequestError, 'bad request', {}], # AccountNotFoundError - [TransactionResultCode.NO_ACCOUNT, KinErrors.AccountNotFoundError, 'account not found', {}], + [KinErrors.TransactionResultCode.NO_ACCOUNT, KinErrors.AccountNotFoundError, 'account not found', {}], # LowBalanceError - [TransactionResultCode.INSUFFICIENT_BALANCE, KinErrors.LowBalanceError, 'low balance', {}], + [KinErrors.TransactionResultCode.INSUFFICIENT_BALANCE, KinErrors.LowBalanceError, 'low balance', {}], # InternalError ['unknown', KinErrors.InternalError, 'internal error', {'internal_error': 'unknown transaction error'}] @@ -83,7 +77,7 @@ def test_translate_transaction_error(): for fixture in fixtures: err_dict['extras']['result_codes']['transaction'] = fixture[0] - e = KinErrors.translate_horizon_error(HorizonError(err_dict)) + e = KinErrors.translate_horizon_error(KinErrors.HorizonError(err_dict)) assert isinstance(e, fixture[1]) assert e.error_code == fixture[0] assert e.message == fixture[2] @@ -92,45 +86,45 @@ def test_translate_transaction_error(): def test_translate_operation_error(): # RequestError - err_dict = dict(type=HORIZON_NS_PREFIX + HorizonErrorType.TRANSACTION_FAILED, title='title', status=400, + err_dict = dict(type=KinErrors.HORIZON_NS_PREFIX + KinErrors.HorizonErrorType.TRANSACTION_FAILED, title='title', status=400, detail='detail', instance='instance', extras={'result_codes': {'operations': [], 'transaction': 'tx_failed'}}) fixtures = [ # RequestError - [[OperationResultCode.BAD_AUTH], KinErrors.RequestError, 'bad request', {}], - [[CreateAccountResultCode.MALFORMED], KinErrors.RequestError, 'bad request', {}], - [[PaymentResultCode.NO_ISSUER], KinErrors.RequestError, 'bad request', {}], - [[PaymentResultCode.LINE_FULL], KinErrors.RequestError, 'bad request', {}], - [[ChangeTrustResultCode.INVALID_LIMIT], KinErrors.RequestError, 'bad request', {}], + [[KinErrors.OperationResultCode.BAD_AUTH], KinErrors.RequestError, 'bad request', {}], + [[KinErrors.CreateAccountResultCode.MALFORMED], KinErrors.RequestError, 'bad request', {}], + [[KinErrors.PaymentResultCode.NO_ISSUER], KinErrors.RequestError, 'bad request', {}], + [[KinErrors.PaymentResultCode.LINE_FULL], KinErrors.RequestError, 'bad request', {}], + [[KinErrors.ChangeTrustResultCode.INVALID_LIMIT], KinErrors.RequestError, 'bad request', {}], # AccountNotFoundError - [[OperationResultCode.NO_ACCOUNT], KinErrors.AccountNotFoundError, 'account not found', {}], - [[PaymentResultCode.NO_DESTINATION], KinErrors.AccountNotFoundError, 'account not found', {}], + [[KinErrors.OperationResultCode.NO_ACCOUNT], KinErrors.AccountNotFoundError, 'account not found', {}], + [[KinErrors.PaymentResultCode.NO_DESTINATION], KinErrors.AccountNotFoundError, 'account not found', {}], # AccountExistsError - [[CreateAccountResultCode.ACCOUNT_EXISTS], KinErrors.AccountExistsError, 'account already exists', {}], + [[KinErrors.CreateAccountResultCode.ACCOUNT_EXISTS], KinErrors.AccountExistsError, 'account already exists', {}], # LowBalanceError - [[CreateAccountResultCode.LOW_RESERVE], KinErrors.LowBalanceError, 'low balance', {}], - [[PaymentResultCode.UNDERFUNDED], KinErrors.LowBalanceError, 'low balance', {}], + [[KinErrors.CreateAccountResultCode.LOW_RESERVE], KinErrors.LowBalanceError, 'low balance', {}], + [[KinErrors.PaymentResultCode.UNDERFUNDED], KinErrors.LowBalanceError, 'low balance', {}], # AccountNotActivatedError - [[PaymentResultCode.SRC_NO_TRUST], KinErrors.AccountNotActivatedError, 'account not activated', {}], - [[PaymentResultCode.NO_TRUST], KinErrors.AccountNotActivatedError, 'account not activated', {}], - [[PaymentResultCode.SRC_NOT_AUTHORIZED], KinErrors.AccountNotActivatedError, 'account not activated', {}], - [[PaymentResultCode.NOT_AUTHORIZED], KinErrors.AccountNotActivatedError, 'account not activated', {}], + [[KinErrors.PaymentResultCode.SRC_NO_TRUST], KinErrors.AccountNotActivatedError, 'account not activated', {}], + [[KinErrors.PaymentResultCode.NO_TRUST], KinErrors.AccountNotActivatedError, 'account not activated', {}], + [[KinErrors.PaymentResultCode.SRC_NOT_AUTHORIZED], KinErrors.AccountNotActivatedError, 'account not activated', {}], + [[KinErrors.PaymentResultCode.NOT_AUTHORIZED], KinErrors.AccountNotActivatedError, 'account not activated', {}], # InternalError [['unknown'], KinErrors.InternalError, 'internal error', {'internal_error': 'unknown operation error'}], # MultiOp - [[OperationResultCode.SUCCESS, PaymentResultCode.UNDERFUNDED], KinErrors.LowBalanceError, 'low balance', {}] + [[KinErrors.OperationResultCode.SUCCESS, KinErrors.PaymentResultCode.UNDERFUNDED], KinErrors.LowBalanceError, 'low balance', {}] ] for fixture in fixtures: err_dict['extras']['result_codes']['operations'] = fixture[0] - e = KinErrors.translate_horizon_error(HorizonError(err_dict)) + e = KinErrors.translate_horizon_error(KinErrors.HorizonError(err_dict)) assert isinstance(e, fixture[1]) assert e.error_code == fixture[0][-1] assert e.message == fixture[2] diff --git a/test/test_horizon.py b/test/test_horizon.py deleted file mode 100644 index b11e1d8..0000000 --- a/test/test_horizon.py +++ /dev/null @@ -1,325 +0,0 @@ -import pytest -from requests.adapters import DEFAULT_POOLSIZE - -from kin_base.horizon import HORIZON_TEST, HORIZON_LIVE -from kin.blockchain.errors import * -from kin.blockchain.horizon import ( - Horizon, - check_horizon_reply, - DEFAULT_REQUEST_TIMEOUT, - DEFAULT_NUM_RETRIES, - DEFAULT_BACKOFF_FACTOR, - USER_AGENT, -) - - -def test_check_horizon_reply(): - reply = { - 'type': HORIZON_NS_PREFIX + HorizonErrorType.TRANSACTION_FAILED, - 'status': 400, - 'title': 'title', - 'extras': { - 'result_codes': { - 'operations': [PaymentResultCode.NO_TRUST], - 'transaction': TransactionResultCode.FAILED - } - } - } - with pytest.raises(HorizonError) as exc_info: - check_horizon_reply(reply) - assert exc_info.value.type == HorizonErrorType.TRANSACTION_FAILED - - reply = "{'a':'b'}" - check_horizon_reply(reply) - - -def test_defaults(): - horizon = Horizon.testnet() - assert horizon - assert horizon.horizon_uri == HORIZON_TEST - - horizon = Horizon.livenet() - assert horizon - assert horizon.horizon_uri == HORIZON_LIVE - - -def test_create_default(): - horizon = Horizon() - assert horizon - assert horizon.horizon_uri == HORIZON_TEST - assert horizon.request_timeout == DEFAULT_REQUEST_TIMEOUT - assert horizon._session - assert horizon._session.headers['User-Agent'] == USER_AGENT - assert horizon._session.adapters['http://'] - assert horizon._session.adapters['https://'] - adapter = horizon._session.adapters['http://'] - assert adapter.max_retries - assert adapter.max_retries.total == DEFAULT_NUM_RETRIES - assert adapter.max_retries.backoff_factor == DEFAULT_BACKOFF_FACTOR - assert adapter.max_retries.redirect == 0 - assert adapter._pool_connections == DEFAULT_POOLSIZE - assert adapter._pool_maxsize == DEFAULT_POOLSIZE - - -def test_create_custom(): - horizon_uri = 'horizon_uri' - pool_size = 5 - num_retries = 10 - request_timeout = 30 - backoff_factor = 5 - horizon = Horizon(horizon_uri=horizon_uri, pool_size=pool_size, num_retries=num_retries, - request_timeout=request_timeout, backoff_factor=backoff_factor) - assert horizon - assert horizon.horizon_uri == horizon_uri - assert horizon.request_timeout == request_timeout - assert horizon._session.headers['User-Agent'] == USER_AGENT - adapter = horizon._session.adapters['http://'] - assert adapter.max_retries.total == num_retries - assert adapter.max_retries.backoff_factor == backoff_factor - assert adapter.max_retries.redirect == 0 - assert adapter._pool_connections == pool_size - assert adapter._pool_maxsize == pool_size - - -def test_account(test_client): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.account('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - # root blockchain address - address = 'GA3FLH3EVYHZUHTPQZU63JPX7ECJQL2XZFCMALPCLFYMSYC4JKVLAJWM' - reply = test_client.horizon.account(address) - assert reply - assert reply['id'] - - -def test_account_effects(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.account_effects('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - # root blockchain address - address = test_account.get_public_address() - reply = test_client.horizon.account_effects(address) - assert reply - assert reply['_embedded']['records'] - - -def test_account_offers(test_client): - # does not raise on nonexistent account! - - # root blockchain address - address = 'GA3FLH3EVYHZUHTPQZU63JPX7ECJQL2XZFCMALPCLFYMSYC4JKVLAJWM' - reply = test_client.horizon.account_offers(address) - assert reply - assert reply['_embedded'] - - -def test_account_operations(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.account_operations('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - address = test_account.get_public_address() - reply = test_client.horizon.account_operations(address) - assert reply - assert reply['_embedded']['records'] - - -def test_account_transactions(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.account_transactions('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - address = test_account.get_public_address() - reply = test_client.horizon.account_transactions(address) - assert reply - assert reply['_embedded']['records'] - - -def test_account_payments(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.account_payments('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - address = test_account.get_public_address() - reply = test_client.horizon.account_payments(address) - assert reply - assert reply['_embedded']['records'] - - -def test_transactions(test_client): - reply = test_client.horizon.transactions() - assert reply - assert reply['_embedded']['records'] - - -def get_first_tx_hash(test_client, test_account): - if not hasattr(test_account, 'first_tx_hash'): - address = test_account.get_public_address() - reply = test_client.horizon.account_transactions(address) - assert reply - tx = reply['_embedded']['records'][0] - assert tx['hash'] - test_account.first_tx_hash = tx['hash'] - return test_account.first_tx_hash - - -def test_transaction(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_account.horizon.transaction('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - tx_id = get_first_tx_hash(test_client, test_account) - reply = test_account.horizon.transaction(tx_id) - assert reply - assert reply['id'] == tx_id - - assert reply['operation_count'] == 1 - - -def test_transaction_effects(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.transaction_effects('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - tx_id = get_first_tx_hash(test_client, test_account) - reply = test_client.horizon.transaction_effects(tx_id) - assert reply - assert reply['_embedded']['records'] - - -def test_transaction_operations(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.transaction_operations('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - tx_id = get_first_tx_hash(test_client, test_account) - reply = test_client.horizon.transaction_operations(tx_id) - assert reply - assert reply['_embedded']['records'] - - -def test_transaction_payments(test_client, test_account): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.transaction_payments('bad') - assert exc_info.value.type == HorizonErrorType.NOT_FOUND - - tx_id = get_first_tx_hash(test_client, test_account) - reply = test_client.horizon.transaction_payments(tx_id) - assert reply - assert reply['_embedded']['records'] - - -def test_order_book(setup, test_client): - params = { - 'selling_asset_type': 'credit_alphanum4', - 'selling_asset_code': 'KIN', - 'selling_asset_issuer': setup.issuer_address, - 'buying_asset_type': 'native', - 'buying_asset_code': 'XLM', - } - reply = test_client.horizon.order_book(params=params) - assert reply - assert reply['base']['asset_code'] == 'KIN' - - -def test_ledgers(test_client): - reply = test_client.horizon.ledgers() - assert reply - assert reply['_embedded']['records'] - - -def test_ledger(test_client): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.ledger('bad') - assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! - - reply = test_client.horizon.ledger(5) - assert reply - assert reply['sequence'] == 5 - - -def test_ledger_effects(test_client): - with pytest.raises(HorizonError, match='Bad Request') as exc_info: - test_client.horizon.ledger_effects('bad') - assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! - - reply = test_client.horizon.ledger_effects(5) - assert reply - assert reply['_embedded'] - - -def test_ledger_operations(test_client): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.ledger_operations('bad') - assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! - - reply = test_client.horizon.ledger_operations(5) - assert reply - assert reply['_embedded'] - - -def test_ledger_payments(test_client): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.ledger_payments('bad') - assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! - - reply = test_client.horizon.ledger_payments(5) - assert reply - assert reply['_embedded'] - - -def test_effects(test_client): - reply = test_client.horizon.effects() - assert reply - assert reply['_embedded']['records'] - - -def test_operations(test_client): - reply = test_client.horizon.operations() - assert reply - assert reply['_embedded']['records'] - - -def test_operation(test_client): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.operation('bad') - assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! - - reply = test_client.horizon.operations() - op_id = reply['_embedded']['records'][0]['id'] - - reply = test_client.horizon.operation(op_id) - assert reply - assert reply['id'] == op_id - - -def test_operation_effects(test_client): - with pytest.raises(HorizonError) as exc_info: - test_client.horizon.operation_effects('bad') - assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! - - reply = test_client.horizon.operations() - op_id = reply['_embedded']['records'][0]['id'] - - reply = test_client.horizon.operation_effects(op_id) - assert reply - assert reply['_embedded']['records'] - - -def test_payments(test_client): - reply = test_client.horizon.payments() - assert reply - assert reply['_embedded']['records'] - - -def test_horizon_error_hashable(): - err_dict = dict(title='title', - status=400, - detail='detail', - instance='instance', - extras={}, - type=HORIZON_NS_PREFIX + HorizonErrorType.BAD_REQUEST) - e = HorizonError(err_dict) - {e: 1} # shouldn't fail on unhashable type diff --git a/test/test_monitor.py b/test/test_monitor.py index cdf2127..a696248 100644 --- a/test/test_monitor.py +++ b/test/test_monitor.py @@ -1,85 +1,10 @@ import pytest -from time import sleep @pytest.mark.skip(reason='Blockchain sometimes returns 404, known broken') def test_single_monitor(test_client, test_account): - address = 'GCFNA3MUPL6ZELRZQD5IGBZRWMYIQV6VVG2LCAERLY2A7W5VVRXSBH75' - seed = 'SAEAU66JLC5QNKSNABHH56XXKLHVSQAK7RH34VD2LEALNDKRBLSZ66QD' - test_client.friendbot(address) + pass - txs_found = [] - def account_tx_callback(addr, tx_data, monitor): - assert addr == address - txs_found.append(tx_data.id) - - # start monitoring - monitor = test_client.monitor_account_payments(address, account_tx_callback) - assert monitor.thread.is_alive() - - # pay from sdk to the account - hash1 = test_account.send_kin(address, 1, fee=100) - hash2 = test_account.send_kin(address, 2, fee=100) - sleep(20) - - # Horizon should timeout after 10 seconds of no traffic, make sure we reconnected - hash3 = test_account.send_kin(address, 3, fee=100) - - sleep(5) - assert hash1 in txs_found - assert hash2 in txs_found - assert hash3 in txs_found - - monitor.stop() - # Make sure we stopped monitoring - hash4 = test_account.send_kin(address, 4, fee=100) - sleep(10) - assert not monitor.thread.is_alive() - assert hash4 not in txs_found - - -@pytest.mark.skip(reason='Known broken feature on the blockchain') +@pytest.mark.skip(reason='Blockchain sometimes returns 404, known broken') def test_multi_monitor(test_client, test_account): - address1 = 'GBMU6NALXWCGEVAU2KKG4KIR3WVRSRRKDQB54VED4MKZZPV653ZVUCNB' - seed1 = 'SCIVPFA3NFG5Q7W7U3EOP2P33GOETCDMYYIM72BNBD4HKY6WF5J3IE5G' - test_client.friendbot(address1) - - address2 = 'GB5LRQXPZKCXGTHR2MGD4VNCMV53GJ5WK4NAQOBKRLMGOP3UQJEJMVH2' - seed2 = 'SAVWARZ7WGUPZJEBIUSQ2ZS4I2PPZILMHWXE7W5OSM2T5BSMCZIBP3G2' - test_client.friendbot(address2) - - txs_found1 = [] - txs_found2 = [] - - def account_tx_callback(addr, tx_data, monitor): - if addr == address1: - txs_found1.append(tx_data.id) - elif addr == address2: - txs_found2.append(tx_data.id) - - # start monitoring - monitor = test_client.monitor_accounts_payments([test_account.get_public_address(), address1], - account_tx_callback) - assert monitor.thread.is_alive() - - # pay from sdk to the account - hash1 = test_account.send_kin(address1, 1, fee=100) - sleep(5) - assert hash1 in txs_found1 - - hash2 = test_account.send_kin(address2, 2, fee=100) - sleep(5) - # The second address is not being watched - assert hash2 not in txs_found2 - - monitor.add_address(address2) - hash3 = test_account.send_kin(address2, 3, fee=100) - sleep(5) - assert hash3 in txs_found2 - - # stop monitoring - monitor.stop() - hash4 = test_account.send_kin(address2, 4, fee=100) - sleep(10) - assert not monitor.thread.is_alive() - assert hash4 not in txs_found2 + pass