From 0b312ff9618148435c25b776913036bf85bc9d89 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 22 May 2023 06:10:51 -0300 Subject: [PATCH 01/88] Use leap as network + auth layer, we decentarlized now --- build_docker.sh | 4 +- certs/brain.cert | 33 - certs/testing.key | 52 - certs/whitelist/testing.cert | 33 - .../Dockerfile.runtime | 0 .../Dockerfile.runtime+cuda | 0 docker/leap-skynet-4.0.0/Dockerfile | 22 + docker/leap-skynet-4.0.0/config.ini | 52 + .../contracts/eosio.msig/eosio.msig.abi | 360 +++ .../contracts/eosio.msig/eosio.msig.wasm | Bin 0 -> 29387 bytes .../contracts/eosio.system/eosio.system.abi | 2178 +++++++++++++++++ .../contracts/eosio.system/eosio.system.wasm | Bin 0 -> 344968 bytes .../contracts/eosio.token/eosio.token.abi | 185 ++ .../contracts/eosio.token/eosio.token.wasm | Bin 0 -> 17637 bytes .../contracts/eosio.wrap/eosio.wrap.abi | 130 + .../contracts/eosio.wrap/eosio.wrap.wasm | Bin 0 -> 2366 bytes .../contracts/telos.decide/decide.abi | 1666 +++++++++++++ .../contracts/telos.decide/decide.wasm | Bin 0 -> 255437 bytes docker/leap-skynet-4.0.0/genesis/skynet.json | 25 + pytest.ini | 1 - requirements.test.txt | 4 +- requirements.txt | 6 +- scripts/generate_cert.py | 44 - skynet.ini.example | 6 - skynet/brain.py | 210 -- skynet/cli.py | 124 +- skynet/constants.py | 10 +- skynet/db/__init__.py | 4 +- skynet/db/functions.py | 106 +- skynet/db/proxy.py | 123 - skynet/dgpu.py | 164 +- skynet/frontend/__init__.py | 33 - skynet/frontend/telegram.py | 535 ++-- skynet/ipfs.py | 62 + skynet/models.py | 33 - skynet/network.py | 341 --- skynet/nodeos.py | 70 + skynet/protobuf/__init__.py | 4 - skynet/protobuf/auth.py | 69 - skynet/protobuf/skynet.proto | 24 - skynet/protobuf/skynet_pb2.py | 30 - skynet/structs.py | 148 -- skynet/utils.py | 8 + tests/conftest.py | 70 +- tests/contracts/telos.gpu/telos.gpu.abi | 220 ++ tests/contracts/telos.gpu/telos.gpu.wasm | Bin 0 -> 21761 bytes tests/test_deploy.py | 82 + tests/test_dgpu.py | 389 --- tests/test_skynet.py | 86 - tests/test_telegram.py | 28 - 50 files changed, 5631 insertions(+), 2143 deletions(-) delete mode 100644 certs/brain.cert delete mode 100644 certs/testing.key delete mode 100644 certs/whitelist/testing.cert rename Dockerfile.runtime => docker/Dockerfile.runtime (100%) rename Dockerfile.runtime+cuda => docker/Dockerfile.runtime+cuda (100%) create mode 100644 docker/leap-skynet-4.0.0/Dockerfile create mode 100644 docker/leap-skynet-4.0.0/config.ini create mode 100644 docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.abi create mode 100755 docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.wasm create mode 100644 docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.abi create mode 100755 docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.wasm create mode 100644 docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.abi create mode 100755 docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.wasm create mode 100644 docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.abi create mode 100755 docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.wasm create mode 100644 docker/leap-skynet-4.0.0/contracts/telos.decide/decide.abi create mode 100755 docker/leap-skynet-4.0.0/contracts/telos.decide/decide.wasm create mode 100644 docker/leap-skynet-4.0.0/genesis/skynet.json delete mode 100644 scripts/generate_cert.py delete mode 100644 skynet/brain.py delete mode 100644 skynet/db/proxy.py create mode 100644 skynet/ipfs.py delete mode 100644 skynet/models.py delete mode 100644 skynet/network.py create mode 100644 skynet/nodeos.py delete mode 100644 skynet/protobuf/__init__.py delete mode 100644 skynet/protobuf/auth.py delete mode 100644 skynet/protobuf/skynet.proto delete mode 100644 skynet/protobuf/skynet_pb2.py delete mode 100644 skynet/structs.py create mode 100644 tests/contracts/telos.gpu/telos.gpu.abi create mode 100755 tests/contracts/telos.gpu/telos.gpu.wasm create mode 100644 tests/test_deploy.py delete mode 100644 tests/test_dgpu.py delete mode 100644 tests/test_skynet.py delete mode 100644 tests/test_telegram.py diff --git a/build_docker.sh b/build_docker.sh index 5d67269..74e8dca 100755 --- a/build_docker.sh +++ b/build_docker.sh @@ -1,7 +1,7 @@ docker build \ -t skynet:runtime-cuda \ - -f Dockerfile.runtime+cuda . + -f docker/Dockerfile.runtime+cuda . docker build \ -t skynet:runtime \ - -f Dockerfile.runtime . + -f docker/Dockerfile.runtime . diff --git a/certs/brain.cert b/certs/brain.cert deleted file mode 100644 index d5d7e49..0000000 --- a/certs/brain.cert +++ /dev/null @@ -1,33 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFxDCCA6wCAQAwDQYJKoZIhvcNAQENBQAwgacxCzAJBgNVBAYTAlVZMRMwEQYD -VQQIDApNb250ZXZpZGVvMRMwEQYDVQQHDApNb250ZXZpZGVvMRowGAYDVQQKDBFz -a3luZXQtZm91bmRhdGlvbjENMAsGA1UECwwEbm9uZTEcMBoGA1UEAwwTR3VpbGxl -cm1vIFJvZHJpZ3VlejElMCMGCSqGSIb3DQEJARYWZ3VpbGxlcm1vckBmaW5nLmVk -dS51eTAeFw0yMjEyMTExNDM3NDVaFw0zMjEyMDgxNDM3NDVaMIGnMQswCQYDVQQG -EwJVWTETMBEGA1UECAwKTW9udGV2aWRlbzETMBEGA1UEBwwKTW9udGV2aWRlbzEa -MBgGA1UECgwRc2t5bmV0LWZvdW5kYXRpb24xDTALBgNVBAsMBG5vbmUxHDAaBgNV -BAMME0d1aWxsZXJtbyBSb2RyaWd1ZXoxJTAjBgkqhkiG9w0BCQEWFmd1aWxsZXJt -b3JAZmluZy5lZHUudXkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCu -HdqGPtsqtYqfIilVdq0MmqfEn9g4T+uglfWjRF2gWV3uQCuXDv1O61XfIIyaDQXl -VRqT36txtM8rvn213746SwK0jx9+ln5jD3EDbL4WZv1qvp4/jqA+UPKXFXnD3he+ -pRpcDMu4IpYKuoPl667IW/auFSSy3TIWhIZb8ghqxzb2e2i6/OhzIWKHeFIKvbEA -EB6Z63wy3O0ACY7RVhHu0wzyzqUW1t1VNsbZvO9Xmmqm2EWZBJp0TFph3Z9kOR/g -0Ik7kxMLrGIfhV5/1gPQlNr3ADebGJnaMdGCBUi+pqeZcVnGY45fjOJREaD3aTRG -ohZM0Td40K7paDVjUvQ9rPgKoDMsCWpu8IPdc4LB0hONIO2KycFb49cd8zNWsetj -kHXxL9IVgORxfGmVyOtNGotS5RX6R+qwsll3qUmX4XjwvQMAMvATcSkY26CWdCDM -vGFp+0REbVyDfJ9pwU7ZkAxiWeAoiesGfEWyRLsl0fFkaHgHG+oPCH9IO63TVnCq -E6NGRQpHfJ5oV4ZihUfWjSFxOJqdFM3xfzk/2YGzQUgKVBsbuQTWPKxE0aSwt1Cf -Ug4+C0RSDMmrquRmhRn/BWsSRl+2m17rt1axTA4pEVGcHHyKSowEFQ68spD1Lm2K -iU/LCPBh4REzexwjP+onwHALXoxIEOLiy2lEdYgWnwIDAQABMA0GCSqGSIb3DQEB -DQUAA4ICAQBtTZb6PJJQXtF90MD4Hcgj+phKkbtHVZyM198Giw3I9f2PgjDECKb9 -I7JLzCUgpexKk1TNso2FPNoVlcE4yMO0I0EauoKcwZ1w9GXsXOGwPHvB9hrItaLs -s7Qxf+IVgKO4y5Tv+8WO4lhgShWa4fW3L7Dpk0XK4INoAAxZLbEdekf2GGqTUGzD -SrfvtE8h6JT+gR4lsAvdsRjJIKYacsqhKjtV0reA6v99NthDcpwaStrAaFmtJkD3 -6G3JVU0JyMBlR1GetN0w42BjVHJ2l7cPm405lE2ymFwcl7C8VozXXi4wmfVN+xlh -NOVSbl/QUiMUyt44XPhPCbgopxLqhqtvGzBl+ldF1AR4aaukXjvS/8VtFZ3cfx7n -n5NYxvPnq3kwlFNHgppt+u1leGrzxuesGNQENQd3shO/S9T4I92hAdk2MRTivIfv -m74u6RCtHqDviiOFzF7zcqO37wCrb1dnfS1N4I6/rCf6XtxlRGa8Cp9z4DTKjwAC -5z5irJb+LSJkFXA/zIFpBjjKBdyhjYGuXrbJWdL81kTcYRqjE99XfZaTU8L43qVd -TUaIvQGTtx8k7WGmeTRHk6SauCaXSfeXwYTpEZpictUI/uWo/KJRDL/aE8HmBeH3 -pr+cfDu7erTLH+GG5ZROrILf4929Jd7OF4a0nHUnZcycBS0CjGHVHA== ------END CERTIFICATE----- diff --git a/certs/testing.key b/certs/testing.key deleted file mode 100644 index 72402d7..0000000 --- a/certs/testing.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCyAuCwwnoENeYe -B0159sH47zedmRaxcUmC/qmVdUptzOxIHpUCSAIy+hoR5UOhnRsmjj7Y0kUWtlwj -bHAKHcuUn4sqLBb0nl6kH79DzP/4YCQM3GEIXzE6wy/zmnYrHz53Ci7DzmMcRM3n -MwXDVPPpKXzpmI/yassKxSltBKgbh65U3oOheiuFygOlAkT4fUaXX5Bf9DECZBsj -ewf9WvHzLGN2eQt/YWYxJMstgAecHLlRmLbKoYD/P+O0K1ybmhMDItcXE49kNC4s -Rvq7MUt8B0bi8SlRxv5plAbZBiyMilrxf3yCCgYaTsqtt3x+CSrAWjzYIzEzD5aZ -1+s5O2jsqPYkbTvA4NT/hDnWHkkr7YcBRwQn1iMe2tMUTTsWotIYWH87++BzDAWG -3ZBkqNZ4mUdA3usk2ZPO0BwWNxlb0AqOlAJUYSoCsm3nBPT08rVvumQ44hup6XPW -L5KIDyL5+Fl8RDgDF8cpCfrijdL+U+GoHmmJYM6zMkrGqD7BD+WJgw9plgbaWUBI -q4aimXF4PrBJAAX5IRyZK+EDDH0AREL3qoZIQVvJR+yGIKTixpyVKtj6jm1OY4Go -iXxRLaFrc4ucT9+PxRHo9zYtNIijub4eXuU5nveswptmCsNa4spTO2XCkHh6IE0Z -B4oALC4lrC279WY+3TaOpv/roGzG9QIDAQABAoICABfpXGFMs7MzwkYvrkU/KO3V -bwppHAFDOcqyMU7K7e/d4ly1rvJwKyDJ3mKfrKay7Ii7UXndP5E+IcD9ufcXQCzQ -rug/+pLAC0UkoT6W9PNaMWgrhOU+VDs+fjHM19QRuFmpMSr1jZ6ofLgdGchpSvJR -CQnKh9uFDjfTethoEw96Tv1GKTcHAChSleFpHUv7wqsRbTABJJbbokGb2duQhzD7 -uh3vQzodzT+2CjeBxoPpNS40GKm+FA6KzdLP2FAWhuNESibmu7uMFCpicR+1ZBxe -+zNU4xCsbamk9rPZqSD1HM4/1RZqs53TuP9TcbzvDPfAUgKpMjICWrUuVIHgQcb/ -H3lJbsusZccFkl+B4arncUu7oyYWsw+OLHq/khja1RrJu6/PDDfcqY0cSAAsCKJf -ChiHVyVbhZ6b9g1MdYLNPlcJrpgCVX+PisqLqY/RqQGIln6D0sBK1+MC6TjFW3zA -ca3Dhun18JBZ73mmlGj7LoOUojtnnxy5YVUdB75tdo5BqilGR1nLurJupg9Nkgeq -C7nbA+rZ93MKHptayko91nc7yLzsMRV8PDFhE2UhZWRZfJ5yAW/IaJBZpvTvSYM3 -5lTgAn1o34mnykuNC3sK5tbCAMb0YbCJtmotRwBIqlFHqbH+TK07CW2lnEkqZ8ID -YFTpAJlgKgsdhsd5ZCkpAoIBAQDQMvn4iBKvnhCeRUV/6AOHcOsgwJkV/G61Gz/G -F0mx0kPsaPugNX1VzF15R+vN1kbk3sQ9bDP6FfsX7jp2EjRqGEb9mJ8BoIbSHLJ4 -dDT7M90TMMYepCVoFMC03Hh30vxH3QokgV3E1lakXCwl1dheRz5czT0BL9VuBkpG -x8vGpVfX4VqLliOWK72wEYdfohUTynb2OkRP/e6woBRxb3hYLqpN7nVHVRiMFBgG -+AvpLNv/oSYBOXj9oRBOwVLZaPV8N1p4Pv7WXL+B7E47Z9rUYNzGFf+2iM1uDdrO -xHkAocgMM/sL81sJaj1khoYRLC8IpAxBG8NqRP6xzeGcLVLHAoIBAQDa4ZdEDvqA -gJmJ4vgivIX7/zv7/q9c/nkNsnPiXjMys6HRdwroQjT7wrxO5/jJX9EDjM98dSFg -1HFJWJulpmDMpIzzwC6DLxZWd+EEqG4Pyv50VGmGuwmqDwWAP7v/pMPwUEvlsGYZ -Tvlebr4jze9vz8MiRw3qBp0ASWpDWgySt3zm0gDWRaxqvZbdqlLvK/YTta+4ySay -dfkqMG4SGM2m7Rc6H+DKqhwADoyd3oVrFD7QWCZTUUm414TgFFk+uils8Pms6ulG -u+mZT29Jaq8UzoXLOmf+tX2K07oA98y0HfrGMAto3+c0x9ArIPrtwHuUGJiTdt3V -ShBPP9AzaBxjAoIBAQCF+3gwP2k/CQqKv+t035t9yuYVgrxBkNyxweJtmUj8nWLG -vdzIggOxdj3lMaqHIVEoMk+5c2uTkhevk8ideSOv7wWoZ1JUWrjIeF1F9QqvafXo -RqgIyfukmk5VVdhUzDs8B/xh97qfVIwXY5Wpl4+RRGnWkOGkZOMF1hhwqlzx7i+0 -prp9P9aQ6n880lr66TSFMvMRi/ewPqsfkTT2txSMMyO32TAyAoo0gy3fNjt8CDlf -rZXmjdTV65OyCulFLi1kjb6zyV54FuHLO4Yw5qnFqLwK4ddY4XrKSzI3g+qWxIYX -jFAPpcE9MthlW8jlPjjaZ6/XKoW8WsBJLkP1HJm7AoIBAAm9J+HbWMIG9s3vz2Kc -SMnhnWWk+2CD4hb97bIQxu5ml7ieN1oGOB1LmN1Z7PPo03/47/J1s7p/OVsuGh7Q -vFXerHbcAjXMDo5iXxy58cu6GIBMkTVxdQigCnqeW1sQlbdHm1jo9GID5YySGNu2 -+gRbli8cQj47dRjiK1w70XtltqT+ixL9nqJRNTk/rtj9d8GAwATUzmf6X8/Ev+EG -QYA/5Fyttm7OCtjlzNPpZr5Q9EqI4YurfkA/NqZRwXbNCbLTNgi/mwmOquIraqQ1 -nvyqA8H7I01t/dwDd687V1xcSSAwWxGbhMoQae7BVOjnO5hnT8Kf81beKMOd70Ga -TEkCggEAI8ICJvOBouBO92330s8smVhxPi9tRCnOZ0mg5MoR8EJydbOrcRIap1w7 -Ai0CTR6ziOgMaDbT52ouZ1u0l6izYAdBdeSaPOiiTLx8vEE+U7SpNR3zCesPtZB3 -uvGOY2mVwyfZH2SUc4cs+uzDnAGhPqC7/RSFPMoctXf46YpGc9auyjdesE395KLX -L043DaE9/ng9B1jCnhu5TUyiUtAluHvRGQC32og6id2KUEhmhGCl5vj2KIVoDmI2 -NpeBLCKuaBNi/rOG3zyHLjg1wCYidjE7vwjY6UyemjbW48LI8KN6Sl5rQdaDu+bG -lWI2XLI4C2zqDBVmEL2MuzL0FrWivQ== ------END PRIVATE KEY----- diff --git a/certs/whitelist/testing.cert b/certs/whitelist/testing.cert deleted file mode 100644 index 8c0aa19..0000000 --- a/certs/whitelist/testing.cert +++ /dev/null @@ -1,33 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFxDCCA6wCAQIwDQYJKoZIhvcNAQENBQAwgacxCzAJBgNVBAYTAlVZMRMwEQYD -VQQIDApNb250ZXZpZGVvMRMwEQYDVQQHDApNb250ZXZpZGVvMRowGAYDVQQKDBFz -a3luZXQtZm91bmRhdGlvbjENMAsGA1UECwwEbm9uZTEcMBoGA1UEAwwTR3VpbGxl -cm1vIFJvZHJpZ3VlejElMCMGCSqGSIb3DQEJARYWZ3VpbGxlcm1vckBmaW5nLmVk -dS51eTAeFw0yMjEyMTExNTE1MDNaFw0zMjEyMDgxNTE1MDNaMIGnMQswCQYDVQQG -EwJVWTETMBEGA1UECAwKTW9udGV2aWRlbzETMBEGA1UEBwwKTW9udGV2aWRlbzEa -MBgGA1UECgwRc2t5bmV0LWZvdW5kYXRpb24xDTALBgNVBAsMBG5vbmUxHDAaBgNV -BAMME0d1aWxsZXJtbyBSb2RyaWd1ZXoxJTAjBgkqhkiG9w0BCQEWFmd1aWxsZXJt -b3JAZmluZy5lZHUudXkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCy -AuCwwnoENeYeB0159sH47zedmRaxcUmC/qmVdUptzOxIHpUCSAIy+hoR5UOhnRsm -jj7Y0kUWtlwjbHAKHcuUn4sqLBb0nl6kH79DzP/4YCQM3GEIXzE6wy/zmnYrHz53 -Ci7DzmMcRM3nMwXDVPPpKXzpmI/yassKxSltBKgbh65U3oOheiuFygOlAkT4fUaX -X5Bf9DECZBsjewf9WvHzLGN2eQt/YWYxJMstgAecHLlRmLbKoYD/P+O0K1ybmhMD -ItcXE49kNC4sRvq7MUt8B0bi8SlRxv5plAbZBiyMilrxf3yCCgYaTsqtt3x+CSrA -WjzYIzEzD5aZ1+s5O2jsqPYkbTvA4NT/hDnWHkkr7YcBRwQn1iMe2tMUTTsWotIY -WH87++BzDAWG3ZBkqNZ4mUdA3usk2ZPO0BwWNxlb0AqOlAJUYSoCsm3nBPT08rVv -umQ44hup6XPWL5KIDyL5+Fl8RDgDF8cpCfrijdL+U+GoHmmJYM6zMkrGqD7BD+WJ -gw9plgbaWUBIq4aimXF4PrBJAAX5IRyZK+EDDH0AREL3qoZIQVvJR+yGIKTixpyV -Ktj6jm1OY4GoiXxRLaFrc4ucT9+PxRHo9zYtNIijub4eXuU5nveswptmCsNa4spT -O2XCkHh6IE0ZB4oALC4lrC279WY+3TaOpv/roGzG9QIDAQABMA0GCSqGSIb3DQEB -DQUAA4ICAQBic+3ipdfvmCThWkDjVs97tkbUUNjGXH95okwI0Jbft0iRivVM16Xb -hqGquQK4OvYoSTHTmsMH19/dMj0W/Bd4IUYKl64rG8YJUbjDbO1y7a+wF2TaONyn -z0k3zRCky+IwxqYf9Ppw7s2/cXlt3fOEg0kBr4EooXd+bFCx/+JQIxU3vfL8cDQK -dp55vkh+ROt8eR7ai1FiAC8J1prswyT092ktco2fP0MI4uQ3iQfl07NyI68UV1E5 -aIsOPU3SKMtxz5FLm8JEUVhZRJZJWQ/o/iB/2cdn4PDBGkrBhgU6ysMPNX51RlCM -aHRsMyoO2mFfIlm0jW0C5lZ6nKHuA1sXPFz1YxzpvnRgRlHUlfoKf1wpCeF+5Qz+ -qylArHPSu69CA38wLCzJ3wWTaGVL1nuH1UPR2Pg71HGBYqLCD2XGa8iLShO1DKl7 -1bAeHOvzryngYq35rky1L3cIquinAwCP4QKocJK3DJAD5lPqhpzO1f2/1BmWV9Ri -ZRrRkM/9AxePxGZEmnoQbwKsQs/bY+jGU2fRzqijxRPoX9ogX5Te/Ko0mQh1slbX -4bL9NIipHPgpNeZRmRUnu4z00UJNGrI/qGaont3eMH1V65WGz9VMYnmCxkmsg45e -skrauB/Ly9DRRZBddDwAQF8RIbpqPsfQTuEjF0sGdYH3LaClGbA/cA== ------END CERTIFICATE----- diff --git a/Dockerfile.runtime b/docker/Dockerfile.runtime similarity index 100% rename from Dockerfile.runtime rename to docker/Dockerfile.runtime diff --git a/Dockerfile.runtime+cuda b/docker/Dockerfile.runtime+cuda similarity index 100% rename from Dockerfile.runtime+cuda rename to docker/Dockerfile.runtime+cuda diff --git a/docker/leap-skynet-4.0.0/Dockerfile b/docker/leap-skynet-4.0.0/Dockerfile new file mode 100644 index 0000000..d529cba --- /dev/null +++ b/docker/leap-skynet-4.0.0/Dockerfile @@ -0,0 +1,22 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y wget + +# install eosio tools +RUN wget https://github.com/AntelopeIO/leap/releases/download/v4.0.0/leap_4.0.0-ubuntu22.04_amd64.deb + +RUN apt-get install -y ./leap_4.0.0-ubuntu22.04_amd64.deb + +RUN mkdir -p /root/nodeos +WORKDIR /root/nodeos +COPY config.ini config.ini +COPY contracts contracts +COPY genesis genesis + +EXPOSE 42000 +EXPOSE 29876 +EXPOSE 39999 + +CMD sleep 9999999999 diff --git a/docker/leap-skynet-4.0.0/config.ini b/docker/leap-skynet-4.0.0/config.ini new file mode 100644 index 0000000..836e2d8 --- /dev/null +++ b/docker/leap-skynet-4.0.0/config.ini @@ -0,0 +1,52 @@ +agent-name = Telos Skynet Testnet + +wasm-runtime = eos-vm-jit +eos-vm-oc-compile-threads = 4 +eos-vm-oc-enable = true + +chain-state-db-size-mb = 65536 +enable-account-queries = true + +http-server-address = 0.0.0.0:42000 +access-control-allow-origin = * +contracts-console = true +http-validate-host = false +p2p-listen-endpoint = 0.0.0.0:29876 +p2p-server-address = 0.0.0.0:29876 +verbose-http-errors = true + +state-history-endpoint = 0.0.0.0:39999 +trace-history = true +chain-state-history = true +trace-history-debug-mode = true +state-history-dir = state-history + +sync-fetch-span = 1600 +max-clients = 250 + +signature-provider = EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1=KEY:5JnvSc6pewpHHuUHwvbJopsew6AKwiGnexwDRc2Pj2tbdw6iML9 + +disable-subjective-billing = true +max-transaction-time = 500 +read-only-read-window-time-us = 600000 + +abi-serializer-max-time-ms = 2000000 + +p2p-max-nodes-per-host = 1 + +connection-cleanup-period = 30 +allowed-connection = any +http-max-response-time-ms = 100000 +max-body-size = 10000000 + +enable-stale-production = true + + +plugin = eosio::http_plugin +plugin = eosio::chain_plugin +plugin = eosio::chain_api_plugin +plugin = eosio::net_api_plugin +plugin = eosio::net_plugin +plugin = eosio::producer_plugin +plugin = eosio::producer_api_plugin +plugin = eosio::state_history_plugin diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.abi b/docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.abi new file mode 100644 index 0000000..f1eef86 --- /dev/null +++ b/docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.abi @@ -0,0 +1,360 @@ +{ + "____comment": "This file was generated with eosio-abigen. DO NOT EDIT Thu Apr 14 07:49:43 2022", + "version": "eosio::abi/1.1", + "structs": [ + { + "name": "action", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "name", + "type": "name" + }, + { + "name": "authorization", + "type": "permission_level[]" + }, + { + "name": "data", + "type": "bytes" + } + ] + }, + { + "name": "approval", + "base": "", + "fields": [ + { + "name": "level", + "type": "permission_level" + }, + { + "name": "time", + "type": "time_point" + } + ] + }, + { + "name": "approvals_info", + "base": "", + "fields": [ + { + "name": "version", + "type": "uint8" + }, + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "requested_approvals", + "type": "approval[]" + }, + { + "name": "provided_approvals", + "type": "approval[]" + } + ] + }, + { + "name": "approve", + "base": "", + "fields": [ + { + "name": "proposer", + "type": "name" + }, + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "level", + "type": "permission_level" + }, + { + "name": "proposal_hash", + "type": "checksum256$" + } + ] + }, + { + "name": "cancel", + "base": "", + "fields": [ + { + "name": "proposer", + "type": "name" + }, + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "canceler", + "type": "name" + } + ] + }, + { + "name": "exec", + "base": "", + "fields": [ + { + "name": "proposer", + "type": "name" + }, + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "executer", + "type": "name" + } + ] + }, + { + "name": "extension", + "base": "", + "fields": [ + { + "name": "type", + "type": "uint16" + }, + { + "name": "data", + "type": "bytes" + } + ] + }, + { + "name": "invalidate", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + } + ] + }, + { + "name": "invalidation", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "last_invalidation_time", + "type": "time_point" + } + ] + }, + { + "name": "old_approvals_info", + "base": "", + "fields": [ + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "requested_approvals", + "type": "permission_level[]" + }, + { + "name": "provided_approvals", + "type": "permission_level[]" + } + ] + }, + { + "name": "permission_level", + "base": "", + "fields": [ + { + "name": "actor", + "type": "name" + }, + { + "name": "permission", + "type": "name" + } + ] + }, + { + "name": "proposal", + "base": "", + "fields": [ + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "packed_transaction", + "type": "bytes" + } + ] + }, + { + "name": "propose", + "base": "", + "fields": [ + { + "name": "proposer", + "type": "name" + }, + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "requested", + "type": "permission_level[]" + }, + { + "name": "trx", + "type": "transaction" + } + ] + }, + { + "name": "transaction", + "base": "transaction_header", + "fields": [ + { + "name": "context_free_actions", + "type": "action[]" + }, + { + "name": "actions", + "type": "action[]" + }, + { + "name": "transaction_extensions", + "type": "extension[]" + } + ] + }, + { + "name": "transaction_header", + "base": "", + "fields": [ + { + "name": "expiration", + "type": "time_point_sec" + }, + { + "name": "ref_block_num", + "type": "uint16" + }, + { + "name": "ref_block_prefix", + "type": "uint32" + }, + { + "name": "max_net_usage_words", + "type": "varuint32" + }, + { + "name": "max_cpu_usage_ms", + "type": "uint8" + }, + { + "name": "delay_sec", + "type": "varuint32" + } + ] + }, + { + "name": "unapprove", + "base": "", + "fields": [ + { + "name": "proposer", + "type": "name" + }, + { + "name": "proposal_name", + "type": "name" + }, + { + "name": "level", + "type": "permission_level" + } + ] + } + ], + "types": [], + "actions": [ + { + "name": "approve", + "type": "approve", + "ricardian_contract": "" + }, + { + "name": "cancel", + "type": "cancel", + "ricardian_contract": "" + }, + { + "name": "exec", + "type": "exec", + "ricardian_contract": "" + }, + { + "name": "invalidate", + "type": "invalidate", + "ricardian_contract": "" + }, + { + "name": "propose", + "type": "propose", + "ricardian_contract": "" + }, + { + "name": "unapprove", + "type": "unapprove", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "approvals", + "type": "old_approvals_info", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "approvals2", + "type": "approvals_info", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "invals", + "type": "invalidation", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "proposal", + "type": "proposal", + "index_type": "i64", + "key_names": [], + "key_types": [] + } + ], + "ricardian_clauses": [], + "variants": [], + "abi_extensions": [] +} \ No newline at end of file diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.wasm b/docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.wasm new file mode 100755 index 0000000000000000000000000000000000000000..dda2a3434f8e8eb1c61a1ae00dcf88f76ec5bb84 GIT binary patch literal 29387 zcmeI5Ym8mjb>GkHzUItm4lP=mP>gl%v6>DO*`{7YmR6!UjIBqcM5H7qitA=LA}_^x zaAr6&l&HYfj1@&8(4-IoBXt9zmQe*#)G#m#q@UcD0aQRhYCvdhS8ZLFABrL_@#|>uJPKQAdgcrk~KGxWH`st00;8?Wr zbZ{)>CeaIb;Y|=6Yi`)TaN}4@MK?B{<~9hQrV8a^Uho`JHhG$V+(%FAVZ%48uXI8& zTI0T{QdUP*1HS2>71E7r+9jqAZ+Q|pl%>&_s`l5qrdeG6U=IP$)<10@EBldl@cjoNeYR~!%QoTw~JT`N3Zt29#+|=#C zsNQ5%yQ{t9y}2iPtFG;_!Me8wV=CIB@zcvs^jzy>G($=uiJ5_K zf%Py#50Cd;mu}d+SX*Ca+#2KuRJAh$mQF3M&)pWpdd}CV@Yv$=@rC23X6Ke>R%Tac zPxsb)t83gIUjPupX0CSMwwOU6q|=~BizCO(+Ns%F@0bd%Rd;Fs-7W6TSpUr(x7_O9 zW&Yjj+)D5CiCd=jyVuRZmJ%L(6MA3Q?F>~)p(Z$AomuET73>xaAE;U%`0&B_7Y{c& z(K!D)*YRI){rJT76XTtDVj}5uI>~tWQ=RMf+@OD*&iJ(*72CTvqC%$=MY~4E_m1z4 zu1y-@O4w>O;xGuC*ToxQxp^^2H-hrp=fmpKx)6>EP0OoKML{|;1gp9&5)XFh#m@yHXw!;P6wFFiRQ9MyGpK6pqUX8G{QZu{`?`YpH2 zF3vr^H1ovl;>>#RDZQSZAAbF-A9U>vzyCG8r@7(xk9J1GpbXRIM}FbMcO1NP&D3u%rl~Fk zd^@T`he_VBo{J`(x$%SxjIbLq0IJ*z+)*CpahB|{&t3JIM*^vtC%Ob>dp>U&jGv@* zm_0n6rmtPo2sgr~6aJ4jJ{**nXf?cvuZM#J@s~q)eT8coOhqsH=P!g=)TKAQ`(68X zD0tqN`8?M=I(+fjpWl4BymNjk+6?J=0_+&zti@FC5^Ne#zFxAF3WKog$`kE<3N1M zA#NpS@cZJRp#G!WTc<_qKzxi^O(+LH9NygAJpX(D_8Z^1u%Yj-gjus_mw^ZxmTw00 zc{}?VQGK_2yt}+0tflZ!V}X%1KUr=*g`0CXGe=f5&;IdL~W1=wln(m#YG(kmt>PP!I;~+c*<-1 zFyMsv`Hj$HIxN2vz^U+MArXMkWj@c7Q&>RR7KIHCb)_eh;wD&|04T#-y9*qqEUZX^ zrV@3+@-3(mO5Q!n-vH(NgyQWrLZ~nB+C@XCP0G=E^&8}k>|qeAKVi8aVa%O=6MN^5 zCK_wS-Wp`h-jJF~(0M8vwOhl#a02f`hG;A9+mw5a8V2oDbkW@!MP7%D74n631k%$0 z6+iHQJk{t-s=lVXPmPH+y<8bWaEq{C18K zstG}B03iG{O|N-Myb+offD9CX4&f*^BP=IH0g&%u3d^rRh@TT7wpJJu)h44!(Lf}0 zB-vX8jwJbfPRpopsNp_k)u-J9pG2*?88t%LEyF^#Q2;@CYQB)s0_4DymfEBz*Yw`P zWGsOn9m(T?Ui~L}HEz%+$0L}hd(b2{W@}oE*pO^C9XpAW1kYr2uux|r%2afx)pqaE z0`+@TL0x{uIMa`&mKsFf4JXlXOu<8#YB)k1tuo5C8%`j;?!zohqBGEN1~c+<1)$NQ zedt~Qb~O-UFXwLx(r-j@gawT^)M3(%B$S68dfW5Z9rGEQpdNEiKlhb?Yk#v0GdxqPY@sueTSKcww1AFBnK@ZLSvI_D+rl?HToza!k0d}FFATL_NO>CUgb}~Y{ zeFIKS3OM84^7q~wGH+!|S#v^mDjuOj@w)&pNa;KMy*S2s#WOKN8gduPxON=uMVXk( z zYyuM=lzSb6A(+uL=xLPa^db-8B@}c+k}BfDeP*-eYv?6qz(kCh!S!s6ORyMAwyQ|W z8=ok{wephW#WX-telEF>YDxAR&auWlF30C_SO5r71X|u?yM!8f&N2wxO+~p`@rJYF zy1*d~XT=dv*?uTdv5W9x&L~wO#8h+>(4h|-JY>Hl-zAhoV;0&jGWE>^amFjYj0#k# z9}r?hEHEN{`;M0-`;I-7S&0S21W#slTY1~tca8KxXGr8gQ_lXt12MV`bM8=qkVe@o z{vaMkmjJ+8qwmmZKr}Nej`r(?ZfoaAlDG zMI1M)Fi9wK+z-DAFJ^#X0=bAh!_FdsWEfZ3zH10}57@lO?iZJjn!GeAry;kCmSVzY z-decOZ8K5{Dlgi?aJ%oSd&)Z@ATG8Tu@ZuggaB5q@X`?&QWd^8g9-@M1&%(njPi~` z%n>?li==VC!(oz+NU7HJg!VNZBY}z~kUdqY;qHp&Lecsa&j?bBErTg<;Ft}DKp?LT z9_330NDYu>A3!|3KslKRBrN;l?9)ytSR`YRpy^P`$^uy2eD0VdU;taawJA$Gm7K2;n(Wu=JYDUtx<{3%nn+HjY=COhfKAU!S&v0N= zuFQKD4y}nCSB6nlP7?iwCWQjO<=EUt)smYB+~%Un&=Tz-2I6YzG$5rBfCHqKKr(*< z@5Dgj>!7NN%mtLHGFK=vH%osoXeN-9H%ClLEd6#f8bc;gTKUcr2_6})+%UttXC>j0 zXUScL+(CLLsBMHZ=MO0gys5SvsdmK;AuW->=%}3$DeA={B{~^}7lE0lx@!wFZAL8&fMsi1 ze+=BDNJah_q-(H}dgw*;1;E`Fn71!5*$9=}6`d8LcE|IMvx;#(j$IOH)^f1o%>rX9QaK;C~Z|IVlapyW1?bfd$_LXkp= z3*GUu+f9JlYrt`7z;UMmskfIQnFN45jXH&^%84Z~Y`HRNL`4Obh?tCWTDKAPTfcnE zjQny93Xus|H@x?~kM@w_ZA}x2w!uVl5jbIm}IR?Jlccr8X_Ao}5);{Vo|wIo0u593nCT z7|JH84%p9ERic3S)3WT`$Fh%>t|Xw5=8=z7-J#zUo{(sd#GrBv<##K5MNy8+;woo} z8_q}N-Hngc86^~J!P5$L-$^IFeT6C3p^MFAQRLY~3*8W;M^2?9r1<2xT+ZOswz1`0 zd^3!4OsN497@JJl_gYcInX*d(GZna;C|�&ZLtQqBj+8Sgz2FInKZVG+9F_xqG>Q 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 literal 0 HcmV?d00001 diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.abi b/docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.abi new file mode 100644 index 0000000..07f4e62 --- /dev/null +++ b/docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.abi @@ -0,0 +1,2178 @@ +{ + "____comment": "This file was generated with eosio-abigen. DO NOT EDIT ", + "version": "eosio::abi/1.1", + "types": [], + "structs": [ + { + "name": "abi_hash", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "hash", + "type": "checksum256" + } + ] + }, + { + "name": "activate", + "base": "", + "fields": [ + { + "name": "feature_digest", + "type": "checksum256" + } + ] + }, + { + "name": "authority", + "base": "", + "fields": [ + { + "name": "threshold", + "type": "uint32" + }, + { + "name": "keys", + "type": "key_weight[]" + }, + { + "name": "accounts", + "type": "permission_level_weight[]" + }, + { + "name": "waits", + "type": "wait_weight[]" + } + ] + }, + { + "name": "bid_refund", + "base": "", + "fields": [ + { + "name": "bidder", + "type": "name" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "bidname", + "base": "", + "fields": [ + { + "name": "bidder", + "type": "name" + }, + { + "name": "newname", + "type": "name" + }, + { + "name": "bid", + "type": "asset" + } + ] + }, + { + "name": "bidrefund", + "base": "", + "fields": [ + { + "name": "bidder", + "type": "name" + }, + { + "name": "newname", + "type": "name" + } + ] + }, + { + "name": "block_header", + "base": "", + "fields": [ + { + "name": "timestamp", + "type": "uint32" + }, + { + "name": "producer", + "type": "name" + }, + { + "name": "confirmed", + "type": "uint16" + }, + { + "name": "previous", + "type": "checksum256" + }, + { + "name": "transaction_mroot", + "type": "checksum256" + }, + { + "name": "action_mroot", + "type": "checksum256" + }, + { + "name": "schedule_version", + "type": "uint32" + }, + { + "name": "new_producers", + "type": "producer_schedule?" + } + ] + }, + { + "name": "blockchain_parameters", + "base": "", + "fields": [ + { + "name": "max_block_net_usage", + "type": "uint64" + }, + { + "name": "target_block_net_usage_pct", + "type": "uint32" + }, + { + "name": "max_transaction_net_usage", + "type": "uint32" + }, + { + "name": "base_per_transaction_net_usage", + "type": "uint32" + }, + { + "name": "net_usage_leeway", + "type": "uint32" + }, + { + "name": "context_free_discount_net_usage_num", + "type": "uint32" + }, + { + "name": "context_free_discount_net_usage_den", + "type": "uint32" + }, + { + "name": "max_block_cpu_usage", + "type": "uint32" + }, + { + "name": "target_block_cpu_usage_pct", + "type": "uint32" + }, + { + "name": "max_transaction_cpu_usage", + "type": "uint32" + }, + { + "name": "min_transaction_cpu_usage", + "type": "uint32" + }, + { + "name": "max_transaction_lifetime", + "type": "uint32" + }, + { + "name": "deferred_trx_expiration_window", + "type": "uint32" + }, + { + "name": "max_transaction_delay", + "type": "uint32" + }, + { + "name": "max_inline_action_size", + "type": "uint32" + }, + { + "name": "max_inline_action_depth", + "type": "uint16" + }, + { + "name": "max_authority_depth", + "type": "uint16" + } + ] + }, + { + "name": "buyram", + "base": "", + "fields": [ + { + "name": "payer", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "quant", + "type": "asset" + } + ] + }, + { + "name": "buyrambytes", + "base": "", + "fields": [ + { + "name": "payer", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "bytes", + "type": "uint32" + } + ] + }, + { + "name": "buyrex", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "canceldelay", + "base": "", + "fields": [ + { + "name": "canceling_auth", + "type": "permission_level" + }, + { + "name": "trx_id", + "type": "checksum256" + } + ] + }, + { + "name": "claimrewards", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "closerex", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "cnclrexorder", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "connector", + "base": "", + "fields": [ + { + "name": "balance", + "type": "asset" + }, + { + "name": "weight", + "type": "float64" + } + ] + }, + { + "name": "consolidate", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "defcpuloan", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "loan_num", + "type": "uint64" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "defnetloan", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "loan_num", + "type": "uint64" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "delegatebw", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "stake_net_quantity", + "type": "asset" + }, + { + "name": "stake_cpu_quantity", + "type": "asset" + }, + { + "name": "transfer", + "type": "bool" + } + ] + }, + { + "name": "delegated_bandwidth", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "net_weight", + "type": "asset" + }, + { + "name": "cpu_weight", + "type": "asset" + } + ] + }, + { + "name": "deleteauth", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "permission", + "type": "name" + } + ] + }, + { + "name": "deposit", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "distviarex", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "eosio_global_state", + "base": "blockchain_parameters", + "fields": [ + { + "name": "max_ram_size", + "type": "uint64" + }, + { + "name": "total_ram_bytes_reserved", + "type": "uint64" + }, + { + "name": "total_ram_stake", + "type": "int64" + }, + { + "name": "last_producer_schedule_update", + "type": "block_timestamp_type" + }, + { + "name": "last_proposed_schedule_update", + "type": "block_timestamp_type" + }, + { + "name": "last_pervote_bucket_fill", + "type": "time_point" + }, + { + "name": "pervote_bucket", + "type": "int64" + }, + { + "name": "perblock_bucket", + "type": "int64" + }, + { + "name": "total_unpaid_blocks", + "type": "uint32" + }, + { + "name": "total_activated_stake", + "type": "int64" + }, + { + "name": "thresh_activated_stake_time", + "type": "time_point" + }, + { + "name": "last_producer_schedule_size", + "type": "uint16" + }, + { + "name": "total_producer_vote_weight", + "type": "float64" + }, + { + "name": "last_name_close", + "type": "block_timestamp_type" + }, + { + "name": "block_num", + "type": "uint32" + }, + { + "name": "last_claimrewards", + "type": "uint32" + }, + { + "name": "next_payment", + "type": "uint32" + }, + { + "name": "new_ram_per_block", + "type": "uint16" + }, + { + "name": "last_ram_increase", + "type": "block_timestamp_type" + }, + { + "name": "last_block_num", + "type": "block_timestamp_type" + }, + { + "name": "total_producer_votepay_share", + "type": "float64" + }, + { + "name": "revision", + "type": "uint8" + } + ] + }, + { + "name": "exchange_state", + "base": "", + "fields": [ + { + "name": "supply", + "type": "asset" + }, + { + "name": "base", + "type": "connector" + }, + { + "name": "quote", + "type": "connector" + } + ] + }, + { + "name": "fundcpuloan", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "loan_num", + "type": "uint64" + }, + { + "name": "payment", + "type": "asset" + } + ] + }, + { + "name": "fundnetloan", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "loan_num", + "type": "uint64" + }, + { + "name": "payment", + "type": "asset" + } + ] + }, + { + "name": "init", + "base": "", + "fields": [ + { + "name": "version", + "type": "varuint32" + }, + { + "name": "core", + "type": "symbol" + } + ] + }, + { + "name": "key_weight", + "base": "", + "fields": [ + { + "name": "key", + "type": "public_key" + }, + { + "name": "weight", + "type": "uint16" + } + ] + }, + { + "name": "linkauth", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "code", + "type": "name" + }, + { + "name": "type", + "type": "name" + }, + { + "name": "requirement", + "type": "name" + } + ] + }, + { + "name": "mvfrsavings", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "rex", + "type": "asset" + } + ] + }, + { + "name": "mvtosavings", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "rex", + "type": "asset" + } + ] + }, + { + "name": "name_bid", + "base": "", + "fields": [ + { + "name": "newname", + "type": "name" + }, + { + "name": "high_bidder", + "type": "name" + }, + { + "name": "high_bid", + "type": "int64" + }, + { + "name": "last_bid_time", + "type": "time_point" + } + ] + }, + { + "name": "newaccount", + "base": "", + "fields": [ + { + "name": "creator", + "type": "name" + }, + { + "name": "name", + "type": "name" + }, + { + "name": "owner", + "type": "authority" + }, + { + "name": "active", + "type": "authority" + } + ] + }, + { + "name": "onblock", + "base": "", + "fields": [ + { + "name": "header", + "type": "block_header" + } + ] + }, + { + "name": "onerror", + "base": "", + "fields": [ + { + "name": "sender_id", + "type": "uint128" + }, + { + "name": "sent_trx", + "type": "bytes" + } + ] + }, + { + "name": "pair_time_point_sec_int64", + "base": "", + "fields": [ + { + "name": "first", + "type": "time_point_sec" + }, + { + "name": "second", + "type": "int64" + } + ] + }, + { + "name": "payment_info", + "base": "", + "fields": [ + { + "name": "bp", + "type": "name" + }, + { + "name": "pay", + "type": "asset" + } + ] + }, + { + "name": "payrates", + "base": "", + "fields": [ + { + "name": "bpay_rate", + "type": "uint64" + }, + { + "name": "worker_amount", + "type": "uint64" + } + ] + }, + { + "name": "permission_level", + "base": "", + "fields": [ + { + "name": "actor", + "type": "name" + }, + { + "name": "permission", + "type": "name" + } + ] + }, + { + "name": "permission_level_weight", + "base": "", + "fields": [ + { + "name": "permission", + "type": "permission_level" + }, + { + "name": "weight", + "type": "uint16" + } + ] + }, + { + "name": "producer_info", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "total_votes", + "type": "float64" + }, + { + "name": "producer_key", + "type": "public_key" + }, + { + "name": "is_active", + "type": "bool" + }, + { + "name": "unreg_reason", + "type": "string" + }, + { + "name": "url", + "type": "string" + }, + { + "name": "unpaid_blocks", + "type": "uint32" + }, + { + "name": "lifetime_produced_blocks", + "type": "uint32" + }, + { + "name": "missed_blocks_per_rotation", + "type": "uint32" + }, + { + "name": "lifetime_missed_blocks", + "type": "uint32" + }, + { + "name": "last_claim_time", + "type": "time_point" + }, + { + "name": "location", + "type": "uint16" + }, + { + "name": "kick_reason_id", + "type": "uint32" + }, + { + "name": "kick_reason", + "type": "string" + }, + { + "name": "times_kicked", + "type": "uint32" + }, + { + "name": "kick_penalty_hours", + "type": "uint32" + }, + { + "name": "last_time_kicked", + "type": "block_timestamp_type" + } + ] + }, + { + "name": "producer_key", + "base": "", + "fields": [ + { + "name": "producer_name", + "type": "name" + }, + { + "name": "block_signing_key", + "type": "public_key" + } + ] + }, + { + "name": "producer_metric", + "base": "", + "fields": [ + { + "name": "bp_name", + "type": "name" + }, + { + "name": "missed_blocks_per_cycle", + "type": "uint32" + } + ] + }, + { + "name": "producer_schedule", + "base": "", + "fields": [ + { + "name": "version", + "type": "uint32" + }, + { + "name": "producers", + "type": "producer_key[]" + } + ] + }, + { + "name": "refund", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "refund_request", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "request_time", + "type": "time_point_sec" + }, + { + "name": "net_amount", + "type": "asset" + }, + { + "name": "cpu_amount", + "type": "asset" + } + ] + }, + { + "name": "regproducer", + "base": "", + "fields": [ + { + "name": "producer", + "type": "name" + }, + { + "name": "producer_key", + "type": "public_key" + }, + { + "name": "url", + "type": "string" + }, + { + "name": "location", + "type": "uint16" + } + ] + }, + { + "name": "regproxy", + "base": "", + "fields": [ + { + "name": "proxy", + "type": "name" + }, + { + "name": "isproxy", + "type": "bool" + } + ] + }, + { + "name": "rentcpu", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "loan_payment", + "type": "asset" + }, + { + "name": "loan_fund", + "type": "asset" + } + ] + }, + { + "name": "rentnet", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "loan_payment", + "type": "asset" + }, + { + "name": "loan_fund", + "type": "asset" + } + ] + }, + { + "name": "rex_balance", + "base": "", + "fields": [ + { + "name": "version", + "type": "uint8" + }, + { + "name": "owner", + "type": "name" + }, + { + "name": "vote_stake", + "type": "asset" + }, + { + "name": "rex_balance", + "type": "asset" + }, + { + "name": "matured_rex", + "type": "int64" + }, + { + "name": "rex_maturities", + "type": "pair_time_point_sec_int64[]" + } + ] + }, + { + "name": "rex_fund", + "base": "", + "fields": [ + { + "name": "version", + "type": "uint8" + }, + { + "name": "owner", + "type": "name" + }, + { + "name": "balance", + "type": "asset" + } + ] + }, + { + "name": "rex_loan", + "base": "", + "fields": [ + { + "name": "version", + "type": "uint8" + }, + { + "name": "from", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "payment", + "type": "asset" + }, + { + "name": "balance", + "type": "asset" + }, + { + "name": "total_staked", + "type": "asset" + }, + { + "name": "loan_num", + "type": "uint64" + }, + { + "name": "expiration", + "type": "time_point" + } + ] + }, + { + "name": "rex_order", + "base": "", + "fields": [ + { + "name": "version", + "type": "uint8" + }, + { + "name": "owner", + "type": "name" + }, + { + "name": "rex_requested", + "type": "asset" + }, + { + "name": "proceeds", + "type": "asset" + }, + { + "name": "stake_change", + "type": "asset" + }, + { + "name": "order_time", + "type": "time_point" + }, + { + "name": "is_open", + "type": "bool" + } + ] + }, + { + "name": "rex_pool", + "base": "", + "fields": [ + { + "name": "version", + "type": "uint8" + }, + { + "name": "total_lent", + "type": "asset" + }, + { + "name": "total_unlent", + "type": "asset" + }, + { + "name": "total_rent", + "type": "asset" + }, + { + "name": "total_lendable", + "type": "asset" + }, + { + "name": "total_rex", + "type": "asset" + }, + { + "name": "namebid_proceeds", + "type": "asset" + }, + { + "name": "loan_num", + "type": "uint64" + } + ] + }, + { + "name": "rexexec", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "max", + "type": "uint16" + } + ] + }, + { + "name": "rmvproducer", + "base": "", + "fields": [ + { + "name": "producer", + "type": "name" + } + ] + }, + { + "name": "rotation_state", + "base": "", + "fields": [ + { + "name": "bp_currently_out", + "type": "name" + }, + { + "name": "sbp_currently_in", + "type": "name" + }, + { + "name": "bp_out_index", + "type": "uint32" + }, + { + "name": "sbp_in_index", + "type": "uint32" + }, + { + "name": "next_rotation_time", + "type": "block_timestamp_type" + }, + { + "name": "last_rotation_time", + "type": "block_timestamp_type" + } + ] + }, + { + "name": "schedule_metrics_state", + "base": "", + "fields": [ + { + "name": "last_onblock_caller", + "type": "name" + }, + { + "name": "block_counter_correction", + "type": "int32" + }, + { + "name": "producers_metric", + "type": "producer_metric[]" + } + ] + }, + { + "name": "sellram", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "bytes", + "type": "int64" + } + ] + }, + { + "name": "sellrex", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "rex", + "type": "asset" + } + ] + }, + { + "name": "setabi", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "abi", + "type": "bytes" + } + ] + }, + { + "name": "setacctcpu", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "cpu_weight", + "type": "int64?" + } + ] + }, + { + "name": "setacctnet", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "net_weight", + "type": "int64?" + } + ] + }, + { + "name": "setacctram", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "ram_bytes", + "type": "int64?" + } + ] + }, + { + "name": "setalimits", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "ram_bytes", + "type": "int64" + }, + { + "name": "net_weight", + "type": "int64" + }, + { + "name": "cpu_weight", + "type": "int64" + } + ] + }, + { + "name": "setcode", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "vmtype", + "type": "uint8" + }, + { + "name": "vmversion", + "type": "uint8" + }, + { + "name": "code", + "type": "bytes" + } + ] + }, + { + "name": "setparams", + "base": "", + "fields": [ + { + "name": "params", + "type": "blockchain_parameters" + } + ] + }, + { + "name": "setpayrates", + "base": "", + "fields": [ + { + "name": "inflation", + "type": "uint64" + }, + { + "name": "worker", + "type": "uint64" + } + ] + }, + { + "name": "setpriv", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "is_priv", + "type": "uint8" + } + ] + }, + { + "name": "setram", + "base": "", + "fields": [ + { + "name": "max_ram_size", + "type": "uint64" + } + ] + }, + { + "name": "setramrate", + "base": "", + "fields": [ + { + "name": "bytes_per_block", + "type": "uint16" + } + ] + }, + { + "name": "setrex", + "base": "", + "fields": [ + { + "name": "balance", + "type": "asset" + } + ] + }, + { + "name": "undelegatebw", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "unstake_net_quantity", + "type": "asset" + }, + { + "name": "unstake_cpu_quantity", + "type": "asset" + } + ] + }, + { + "name": "unlinkauth", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "code", + "type": "name" + }, + { + "name": "type", + "type": "name" + } + ] + }, + { + "name": "unregprod", + "base": "", + "fields": [ + { + "name": "producer", + "type": "name" + } + ] + }, + { + "name": "unregreason", + "base": "", + "fields": [ + { + "name": "producer", + "type": "name" + }, + { + "name": "reason", + "type": "string" + } + ] + }, + { + "name": "unstaketorex", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "receiver", + "type": "name" + }, + { + "name": "from_net", + "type": "asset" + }, + { + "name": "from_cpu", + "type": "asset" + } + ] + }, + { + "name": "updateauth", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "permission", + "type": "name" + }, + { + "name": "parent", + "type": "name" + }, + { + "name": "auth", + "type": "authority" + } + ] + }, + { + "name": "updaterex", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + } + ] + }, + { + "name": "updtrevision", + "base": "", + "fields": [ + { + "name": "revision", + "type": "uint8" + } + ] + }, + { + "name": "user_resources", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "net_weight", + "type": "asset" + }, + { + "name": "cpu_weight", + "type": "asset" + }, + { + "name": "ram_bytes", + "type": "int64" + } + ] + }, + { + "name": "votebpout", + "base": "", + "fields": [ + { + "name": "bp", + "type": "name" + }, + { + "name": "penalty_hours", + "type": "uint32" + } + ] + }, + { + "name": "voteproducer", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "proxy", + "type": "name" + }, + { + "name": "producers", + "type": "name[]" + } + ] + }, + { + "name": "voter_info", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "proxy", + "type": "name" + }, + { + "name": "producers", + "type": "name[]" + }, + { + "name": "staked", + "type": "int64" + }, + { + "name": "last_stake", + "type": "int64" + }, + { + "name": "last_vote_weight", + "type": "float64" + }, + { + "name": "proxied_vote_weight", + "type": "float64" + }, + { + "name": "is_proxy", + "type": "bool" + }, + { + "name": "flags1", + "type": "uint32" + }, + { + "name": "reserved2", + "type": "uint32" + }, + { + "name": "reserved3", + "type": "asset" + } + ] + }, + { + "name": "wait_weight", + "base": "", + "fields": [ + { + "name": "wait_sec", + "type": "uint32" + }, + { + "name": "weight", + "type": "uint16" + } + ] + }, + { + "name": "withdraw", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "amount", + "type": "asset" + } + ] + } + ], + "actions": [ + { + "name": "activate", + "type": "activate", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Activate Protocol Feature\nsummary: 'Activate protocol feature {{nowrap feature_digest}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} activates the protocol feature with a digest of {{feature_digest}}." + }, + { + "name": "bidname", + "type": "bidname", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Bid On a Premium Account Name\nsummary: '{{nowrap bidder}} bids on the premium account name {{nowrap newname}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\n{{bidder}} bids {{bid}} on an auction to own the premium account name {{newname}}.\n\n{{bidder}} transfers {{bid}} to the system to cover the cost of the bid, which will be returned to {{bidder}} only if {{bidder}} is later outbid in the auction for {{newname}} by another account.\n\nIf the auction for {{newname}} closes with {{bidder}} remaining as the highest bidder, {{bidder}} will be authorized to create the account with name {{newname}}.\n\n## Bid refund behavior\n\nIf {{bidder}}’s bid on {{newname}} is later outbid by another account, {{bidder}} will be able to claim back the transferred amount of {{bid}}. The system will attempt to automatically do this on behalf of {{bidder}}, but the automatic refund may occasionally fail which will then require {{bidder}} to manually claim the refund with the bidrefund action.\n\n## Auction close criteria\n\nThe system should automatically close the auction for {{newname}} if it satisfies the condition that over a period of two minutes the following two properties continuously hold:\n\n- no one has bid on {{newname}} within the last 24 hours;\n- and, the value of the latest bid on {{newname}} is greater than the value of the bids on each of the other open auctions.\n\nBe aware that the condition to close the auction described above are sufficient but not necessary. The auction for {{newname}} cannot close unless both of the properties are simultaneously satisfied, but it may be closed without requiring the properties to hold for a period of 2 minutes." + }, + { + "name": "bidrefund", + "type": "bidrefund", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Claim Refund on Name Bid\nsummary: 'Claim refund on {{nowrap newname}} bid'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\n{{bidder}} claims refund on {{newname}} bid after being outbid by someone else." + }, + { + "name": "buyram", + "type": "buyram", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Buy RAM\nsummary: '{{nowrap payer}} buys RAM on behalf of {{nowrap receiver}} by paying {{nowrap quant}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\n{{payer}} buys RAM on behalf of {{receiver}} by paying {{quant}}. This transaction will incur a 0.5% fee out of {{quant}} and the amount of RAM delivered will depend on market rates." + }, + { + "name": "buyrambytes", + "type": "buyrambytes", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Buy RAM\nsummary: '{{nowrap payer}} buys {{nowrap bytes}} bytes of RAM on behalf of {{nowrap receiver}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\n{{payer}} buys approximately {{bytes}} bytes of RAM on behalf of {{receiver}} by paying market rates for RAM. This transaction will incur a 0.5% fee and the cost will depend on market rates." + }, + { + "name": "buyrex", + "type": "buyrex", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Buy REX Tokens\nsummary: '{{nowrap from}} buys REX tokens in exchange for {{nowrap amount}} and his vote stake increases by {{nowrap amount}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{amount}} is taken out of {{from}}’s REX fund and used to purchase REX tokens at the current market exchange rate. In order for the action to succeed, {{from}} must have voted for a proxy or at least 21 block producers. {{amount}} is added to {{from}}’s vote stake.\n\nA sell order of the purchased amount can only be initiated after waiting for the maturity period of 4 to 5 days to pass. Even then, depending on the market conditions, the initiated sell order may not be executed immediately." + }, + { + "name": "canceldelay", + "type": "canceldelay", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Cancel Delayed Transaction\nsummary: '{{nowrap canceling_auth.actor}} cancels a delayed transaction'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\n{{canceling_auth.actor}} cancels the delayed transaction with id {{trx_id}}." + }, + { + "name": "claimrewards", + "type": "claimrewards", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Claim Block Producer Rewards\nsummary: '{{nowrap owner}} claims block and vote rewards'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{owner}} claims block and vote rewards from the system." + }, + { + "name": "closerex", + "type": "closerex", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Cleanup Unused REX Data\nsummary: 'Delete REX related DB entries and free associated RAM'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\nDelete REX related DB entries and free associated RAM for {{owner}}.\n\nTo fully delete all REX related DB entries, {{owner}} must ensure that their REX balance and REX fund amounts are both zero and they have no outstanding loans." + }, + { + "name": "cnclrexorder", + "type": "cnclrexorder", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Cancel Scheduled REX Sell Order\nsummary: '{{nowrap owner}} cancels a scheduled sell order if not yet filled'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{owner}} cancels their open sell order." + }, + { + "name": "consolidate", + "type": "consolidate", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Consolidate REX Maturity Buckets Into One\nsummary: 'Consolidate REX maturity buckets into one'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\nConsolidate REX maturity buckets into one bucket that {{owner}} will not be able to sell until 4 to 5 days later." + }, + { + "name": "defcpuloan", + "type": "defcpuloan", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Withdraw from the Fund of a Specific CPU Loan\nsummary: '{{nowrap from}} transfers {{nowrap amount}} from the fund of CPU loan number {{nowrap loan_num}} back to REX fund'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from}} transfers {{amount}} from the fund of CPU loan number {{loan_num}} back to REX fund." + }, + { + "name": "defnetloan", + "type": "defnetloan", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Withdraw from the Fund of a Specific NET Loan\nsummary: '{{nowrap from}} transfers {{nowrap amount}} from the fund of NET loan number {{nowrap loan_num}} back to REX fund'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from}} transfers {{amount}} from the fund of NET loan number {{loan_num}} back to REX fund." + }, + { + "name": "delegatebw", + "type": "delegatebw", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Stake Tokens for NET and/or CPU\nsummary: 'Stake tokens for NET and/or CPU and optionally transfer ownership'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\n{{#if transfer}} {{from}} stakes on behalf of {{receiver}} {{stake_net_quantity}} for NET bandwidth and {{stake_cpu_quantity}} for CPU bandwidth.\n\nStaked tokens will also be transferred to {{receiver}}. The sum of these two quantities will be deducted from {{from}}’s liquid balance and add to the vote weight of {{receiver}}.\n{{else}}\n{{from}} stakes to self and delegates to {{receiver}} {{stake_net_quantity}} for NET bandwidth and {{stake_cpu_quantity}} for CPU bandwidth.\n\nThe sum of these two quantities add to the vote weight of {{from}}.\n{{/if}}" + }, + { + "name": "deleteauth", + "type": "deleteauth", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Delete Account Permission\nsummary: 'Delete the {{nowrap permission}} permission of {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nDelete the {{permission}} permission of {{account}}." + }, + { + "name": "deposit", + "type": "deposit", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Deposit Into REX Fund\nsummary: 'Add to {{nowrap owner}}’s REX fund by transferring {{nowrap amount}} from {{nowrap owner}}’s liquid balance'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\nTransfer {{amount}} from {{owner}}’s liquid balance to {{owner}}’s REX fund. All proceeds and expenses related to REX are added to or taken out of this fund." + }, + { + "name": "distviarex", + "type": "distviarex", + "ricardian_contract": "" + }, + { + "name": "fundcpuloan", + "type": "fundcpuloan", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Deposit into the Fund of a Specific CPU Loan\nsummary: '{{nowrap from}} funds a CPU loan'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from}} transfers {{payment}} from REX fund to the fund of CPU loan number {{loan_num}} in order to be used in loan renewal at expiry. {{from}} can withdraw the total balance of the loan fund at any time." + }, + { + "name": "fundnetloan", + "type": "fundnetloan", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Deposit into the Fund of a Specific NET Loan\nsummary: '{{nowrap from}} funds a NET loan'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from}} transfers {{payment}} from REX fund to the fund of NET loan number {{loan_num}} in order to be used in loan renewal at expiry. {{from}} can withdraw the total balance of the loan fund at any time." + }, + { + "name": "init", + "type": "init", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Initialize System Contract\nsummary: 'Initialize system contract'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\nInitialize system contract. The core token symbol will be set to {{core}}." + }, + { + "name": "linkauth", + "type": "linkauth", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Link Action to Permission\nsummary: '{{nowrap account}} sets the minimum required permission for the {{#if type}}{{nowrap type}} action of the{{/if}} {{nowrap code}} contract to {{nowrap requirement}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\n{{account}} sets the minimum required permission for the {{#if type}}{{type}} action of the{{/if}} {{code}} contract to {{requirement}}.\n\n{{#if type}}{{else}}Any links explicitly associated to specific actions of {{code}} will take precedence.{{/if}}" + }, + { + "name": "mvfrsavings", + "type": "mvfrsavings", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unlock REX Tokens\nsummary: '{{nowrap owner}} unlocks REX Tokens'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{owner}} unlocks {{rex}} by moving it out of the REX savings bucket. The unlocked REX tokens cannot be sold until 4 to 5 days later." + }, + { + "name": "mvtosavings", + "type": "mvtosavings", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Lock REX Tokens\nsummary: '{{nowrap owner}} locks REX Tokens'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{owner}} locks {{rex}} by moving it into the REX savings bucket. The locked REX tokens cannot be sold directly and will have to be unlocked explicitly before selling." + }, + { + "name": "newaccount", + "type": "newaccount", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Create New Account\nsummary: '{{nowrap creator}} creates a new account with the name {{nowrap name}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\n{{creator}} creates a new account with the name {{name}} and the following permissions:\n\nowner permission with authority:\n{{to_json owner}}\n\nactive permission with authority:\n{{to_json active}}" + }, + { + "name": "onblock", + "type": "onblock", + "ricardian_contract": "" + }, + { + "name": "onerror", + "type": "onerror", + "ricardian_contract": "" + }, + { + "name": "refund", + "type": "refund", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Claim Unstaked Tokens\nsummary: 'Return previously unstaked tokens to {{nowrap owner}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nReturn previously unstaked tokens to {{owner}} after the unstaking period has elapsed." + }, + { + "name": "regproducer", + "type": "regproducer", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Register as a Block Producer Candidate\nsummary: 'Register {{nowrap producer}} account as a block producer candidate'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nRegister {{producer}} account as a block producer candidate.\n\n{{$clauses.BlockProducerAgreement}}" + }, + { + "name": "regproxy", + "type": "regproxy", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Register/unregister as a Proxy\nsummary: 'Register/unregister {{nowrap proxy}} as a proxy account'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\n{{#if isproxy}}\n{{proxy}} registers as a proxy that can vote on behalf of accounts that appoint it as their proxy.\n{{else}}\n{{proxy}} unregisters as a proxy that can vote on behalf of accounts that appoint it as their proxy.\n{{/if}}" + }, + { + "name": "rentcpu", + "type": "rentcpu", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Rent CPU Bandwidth for 30 Days\nsummary: '{{nowrap from}} pays {{nowrap loan_payment}} to rent CPU bandwidth for {{nowrap receiver}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from}} pays {{loan_payment}} to rent CPU bandwidth on behalf of {{receiver}} for a period of 30 days.\n\n{{loan_payment}} is taken out of {{from}}’s REX fund. The market price determines the number of tokens to be staked to {{receiver}}’s CPU resources. In addition, {{from}} provides {{loan_fund}}, which is also taken out of {{from}}’s REX fund, to be used for automatic renewal of the loan.\n\nAt expiration, if the loan has less funds than {{loan_payment}}, it is closed and lent tokens that have been staked are taken out of {{receiver}}’s CPU bandwidth. Otherwise, it is renewed at the market price at the time of renewal, that is, the number of staked tokens is recalculated and {{receiver}}’s CPU bandwidth is updated accordingly. {{from}} can fund or defund a loan at any time before expiration. When the loan is closed, {{from}} is refunded any tokens remaining in the loan fund." + }, + { + "name": "rentnet", + "type": "rentnet", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Rent NET Bandwidth for 30 Days\nsummary: '{{nowrap from}} pays {{nowrap loan_payment}} to rent NET bandwidth for {{nowrap receiver}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from}} pays {{loan_payment}} to rent NET bandwidth on behalf of {{receiver}} for a period of 30 days.\n\n{{loan_payment}} is taken out of {{from}}’s REX fund. The market price determines the number of tokens to be staked to {{receiver}}’s NET resources for 30 days. In addition, {{from}} provides {{loan_fund}}, which is also taken out of {{from}}’s REX fund, to be used for automatic renewal of the loan.\n\nAt expiration, if the loan has less funds than {{loan_payment}}, it is closed and lent tokens that have been staked are taken out of {{receiver}}’s NET bandwidth. Otherwise, it is renewed at the market price at the time of renewal, that is, the number of staked tokens is recalculated and {{receiver}}’s NET bandwidth is updated accordingly. {{from}} can fund or defund a loan at any time before expiration. When the loan is closed, {{from}} is refunded any tokens remaining in the loan fund." + }, + { + "name": "rexexec", + "type": "rexexec", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Perform REX Maintenance\nsummary: 'Process sell orders and expired loans'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\nPerforms REX maintenance by processing a maximum of {{max}} REX sell orders and expired loans. Any account can execute this action." + }, + { + "name": "rmvproducer", + "type": "rmvproducer", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Forcibly Unregister a Block Producer Candidate\nsummary: '{{nowrap producer}} is unregistered as a block producer candidate'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} unregisters {{producer}} as a block producer candidate. {{producer}} account will retain its votes and those votes can change based on voter stake changes or votes removed from {{producer}}. However new voters will not be able to vote for {{producer}} while it remains unregistered." + }, + { + "name": "sellram", + "type": "sellram", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Sell RAM From Account\nsummary: 'Sell unused RAM from {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\nSell {{bytes}} bytes of unused RAM from account {{account}} at market price. This transaction will incur a 0.5% fee on the proceeds which depend on market rates." + }, + { + "name": "sellrex", + "type": "sellrex", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Sell REX Tokens in Exchange for EOS\nsummary: '{{nowrap from}} sells {{nowrap rex}} tokens'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from}} initiates a sell order to sell {{rex}} tokens at the market exchange rate during the time at which the order is ultimately executed. If {{from}} already has an open sell order in the sell queue, {{rex}} will be added to the amount of the sell order without change the position of the sell order within the queue. Once the sell order is executed, proceeds are added to {{from}}’s REX fund, the value of sold REX tokens is deducted from {{from}}’s vote stake, and votes are updated accordingly.\n\nDepending on the market conditions, it may not be possible to fill the entire sell order immediately. In such a case, the sell order is added to the back of a sell queue. A sell order at the front of the sell queue will automatically be executed when the market conditions allow for the entire order to be filled. Regardless of the market conditions, the system is designed to execute this sell order within 30 days. {{from}} can cancel the order at any time before it is filled using the cnclrexorder action." + }, + { + "name": "setabi", + "type": "setabi", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Deploy Contract ABI\nsummary: 'Deploy contract ABI on account {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nDeploy the ABI file associated with the contract on account {{account}}." + }, + { + "name": "setacctcpu", + "type": "setacctcpu", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Explicitly Manage the CPU Quota of Account\nsummary: 'Explicitly manage the CPU bandwidth quota of account {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{#if_has_value cpu_weight}}\nExplicitly manage the CPU bandwidth quota of account {{account}} by pinning it to a weight of {{cpu_weight}}.\n\n{{account}} can stake and unstake, however, it will not change their CPU bandwidth quota as long as it remains pinned.\n{{else}}\nUnpin the CPU bandwidth quota of account {{account}}. The CPU bandwidth quota of {{account}} will be driven by the current tokens staked for CPU bandwidth by {{account}}.\n{{/if_has_value}}" + }, + { + "name": "setacctnet", + "type": "setacctnet", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Explicitly Manage the NET Quota of Account\nsummary: 'Explicitly manage the NET bandwidth quota of account {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{#if_has_value net_weight}}\nExplicitly manage the network bandwidth quota of account {{account}} by pinning it to a weight of {{net_weight}}.\n\n{{account}} can stake and unstake, however, it will not change their NET bandwidth quota as long as it remains pinned.\n{{else}}\nUnpin the NET bandwidth quota of account {{account}}. The NET bandwidth quota of {{account}} will be driven by the current tokens staked for NET bandwidth by {{account}}.\n{{/if_has_value}}" + }, + { + "name": "setacctram", + "type": "setacctram", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Explicitly Manage the RAM Quota of Account\nsummary: 'Explicitly manage the RAM quota of account {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{#if_has_value ram_bytes}}\nExplicitly manage the RAM quota of account {{account}} by pinning it to {{ram_bytes}} bytes.\n\n{{account}} can buy and sell RAM, however, it will not change their RAM quota as long as it remains pinned.\n{{else}}\nUnpin the RAM quota of account {{account}}. The RAM quota of {{account}} will be driven by the current RAM holdings of {{account}}.\n{{/if_has_value}}" + }, + { + "name": "setalimits", + "type": "setalimits", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Adjust Resource Limits of Account\nsummary: 'Adjust resource limits of account {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} updates {{account}}’s resource limits to have a RAM quota of {{ram_bytes}} bytes, a NET bandwidth quota of {{net_weight}} and a CPU bandwidth quota of {{cpu_weight}}." + }, + { + "name": "setcode", + "type": "setcode", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Deploy Contract Code\nsummary: 'Deploy contract code on account {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nDeploy compiled contract code to the account {{account}}." + }, + { + "name": "setparams", + "type": "setparams", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Set System Parameters\nsummary: 'Set System Parameters'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} sets system parameters to:\n{{to_json params}}" + }, + { + "name": "setpayrates", + "type": "setpayrates", + "ricardian_contract": "" + }, + { + "name": "setpriv", + "type": "setpriv", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Make an Account Privileged or Unprivileged\nsummary: '{{#if is_priv}}Make {{nowrap account}} privileged{{else}}Remove privileged status of {{nowrap account}}{{/if}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{#if is_priv}}\n{{$action.account}} makes {{account}} privileged.\n{{else}}\n{{$action.account}} removes privileged status of {{account}}.\n{{/if}}" + }, + { + "name": "setram", + "type": "setram", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Configure the Available RAM\nsummary: 'Configure the available RAM'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} configures the available RAM to {{max_ram_size}} bytes." + }, + { + "name": "setramrate", + "type": "setramrate", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Set the Rate of Increase of RAM\nsummary: 'Set the rate of increase of RAM per block'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} sets the rate of increase of RAM to {{bytes_per_block}} bytes/block." + }, + { + "name": "setrex", + "type": "setrex", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Adjust REX Pool Virtual Balance\nsummary: 'Adjust REX Pool Virtual Balance'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} adjusts REX loan rate by setting REX pool virtual balance to {{balance}}. No token transfer or issue is executed in this action." + }, + { + "name": "undelegatebw", + "type": "undelegatebw", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unstake Tokens for NET and/or CPU\nsummary: 'Unstake tokens for NET and/or CPU from {{nowrap receiver}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\n{{from}} unstakes from {{receiver}} {{unstake_net_quantity}} for NET bandwidth and {{unstake_cpu_quantity}} for CPU bandwidth.\n\nThe sum of these two quantities will be removed from the vote weight of {{receiver}} and will be made available to {{from}} after an uninterrupted 3 day period without further unstaking by {{from}}. After the uninterrupted 3 day period passes, the system will attempt to automatically return the funds to {{from}}’s regular token balance. However, this automatic refund may occasionally fail which will then require {{from}} to manually claim the funds with the refund action." + }, + { + "name": "unlinkauth", + "type": "unlinkauth", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unlink Action from Permission\nsummary: '{{nowrap account}} unsets the minimum required permission for the {{#if type}}{{nowrap type}} action of the{{/if}} {{nowrap code}} contract'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\n{{account}} removes the association between the {{#if type}}{{type}} action of the{{/if}} {{code}} contract and its minimum required permission.\n\n{{#if type}}{{else}}This will not remove any links explicitly associated to specific actions of {{code}}.{{/if}}" + }, + { + "name": "unregprod", + "type": "unregprod", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unregister as a Block Producer Candidate\nsummary: '{{nowrap producer}} unregisters as a block producer candidate'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\n{{producer}} unregisters as a block producer candidate. {{producer}} account will retain its votes and those votes can change based on voter stake changes or votes removed from {{producer}}. However new voters will not be able to vote for {{producer}} while it remains unregistered." + }, + { + "name": "unregreason", + "type": "unregreason", + "ricardian_contract": "" + }, + { + "name": "unstaketorex", + "type": "unstaketorex", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Buy REX Tokens Using Staked Tokens\nsummary: '{{nowrap owner}} buys REX tokens in exchange for tokens currently staked to NET and/or CPU'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\n{{from_net}} and {{from_cpu}} are withdrawn from {{receiver}}’s NET and CPU bandwidths respectively. These funds are used to purchase REX tokens at the current market exchange rate. In order for the action to succeed, {{owner}} must have voted for a proxy or at least 21 block producers.\n\nA sell order of the purchased amount can only be initiated after waiting for the maturity period of 4 to 5 days to pass. Even then, depending on the market conditions, the initiated sell order may not be executed immediately." + }, + { + "name": "updateauth", + "type": "updateauth", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Modify Account Permission\nsummary: 'Add or update the {{nowrap permission}} permission of {{nowrap account}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nModify, and create if necessary, the {{permission}} permission of {{account}} to have a parent permission of {{parent}} and the following authority:\n{{to_json auth}}" + }, + { + "name": "updaterex", + "type": "updaterex", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Update REX Owner Vote Weight\nsummary: 'Update vote weight to current value of held REX tokens'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\nUpdate vote weight of {{owner}} account to current value of held REX tokens." + }, + { + "name": "updtrevision", + "type": "updtrevision", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Update System Contract Revision Number\nsummary: 'Update system contract revision number'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} advances the system contract revision number to {{revision}}." + }, + { + "name": "votebpout", + "type": "votebpout", + "ricardian_contract": "" + }, + { + "name": "voteproducer", + "type": "voteproducer", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Vote for Block Producers\nsummary: '{{nowrap voter}} votes for {{#if proxy}}the proxy {{nowrap proxy}}{{else}}up to 30 block producer candidates{{/if}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\n{{#if proxy}}\n{{voter}} votes for the proxy {{proxy}}.\nAt the time of voting the full weight of voter’s staked (CPU + NET) tokens will be cast towards each of the producers voted by {{proxy}}.\n{{else}}\n{{voter}} votes for the following block producer candidates:\n\n{{#each producers}}\n + {{this}}\n{{/each}}\n\nAt the time of voting the full weight of voter’s staked (CPU + NET) tokens will be cast towards each of the above producers.\n{{/if}}" + }, + { + "name": "withdraw", + "type": "withdraw", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Withdraw from REX Fund\nsummary: 'Withdraw {{nowrap amount}} from {{nowrap owner}}’s REX fund by transferring to {{owner}}’s liquid balance'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/rex.png#d229837fa62a464b9c71e06060aa86179adf0b3f4e3b8c4f9702f4f4b0c340a8\n---\n\nWithdraws {{amount}} from {{owner}}’s REX fund and transfer them to {{owner}}’s liquid balance." + } + ], + "tables": [ + { + "name": "abihash", + "type": "abi_hash", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "bidrefunds", + "type": "bid_refund", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "cpuloan", + "type": "rex_loan", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "delband", + "type": "delegated_bandwidth", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "global", + "type": "eosio_global_state", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "namebids", + "type": "name_bid", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "netloan", + "type": "rex_loan", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "payments", + "type": "payment_info", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "payrate", + "type": "payrates", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "producers", + "type": "producer_info", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "rammarket", + "type": "exchange_state", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "refunds", + "type": "refund_request", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "rexbal", + "type": "rex_balance", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "rexfund", + "type": "rex_fund", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "rexpool", + "type": "rex_pool", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "rexqueue", + "type": "rex_order", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "rotations", + "type": "rotation_state", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "schedulemetr", + "type": "schedule_metrics_state", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "userres", + "type": "user_resources", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "voters", + "type": "voter_info", + "index_type": "i64", + "key_names": [], + "key_types": [] + } + ], + "ricardian_clauses": [ + { + "id": "UserAgreement", + "body": "User agreement for the chain can go here." + }, + { + "id": "BlockProducerAgreement", + "body": "I, {{producer}}, hereby nominate myself for consideration as an elected block producer.\n\nIf {{producer}} is selected to produce blocks by the system contract, I will sign blocks with {{producer_key}} and I hereby attest that I will keep this key secret and secure.\n\nIf {{producer}} is unable to perform obligations under this contract I will resign my position by resubmitting this contract with the null producer key.\n\nI acknowledge that a block is 'objectively valid' if it conforms to the deterministic blockchain rules in force at the time of its creation, and is 'objectively invalid' if it fails to conform to those rules.\n\n{{producer}} hereby agrees to only use {{producer_key}} to sign messages under the following scenarios:\n\n* proposing an objectively valid block at the time appointed by the block scheduling algorithm;\n* pre-confirming a block produced by another producer in the schedule when I find said block objectively valid;\n* and, confirming a block for which {{producer}} has received pre-confirmation messages from more than two-thirds of the active block producers.\n\nI hereby accept liability for any and all provable damages that result from my:\n\n* signing two different block proposals with the same timestamp with {{producer_key}};\n* signing two different block proposals with the same block number with {{producer_key}};\n* signing any block proposal which builds off of an objectively invalid block;\n* signing a pre-confirmation for an objectively invalid block;\n* or, signing a confirmation for a block for which I do not possess pre-confirmation messages from more than two-thirds of the active block producers.\n\nI hereby agree that double-signing for a timestamp or block number in concert with two or more other block producers shall automatically be deemed malicious and cause {{producer}} to be subject to:\n\n* a fine equal to the past year of compensation received,\n* immediate disqualification from being a producer,\n* and/or other damages.\n\nAn exception may be made if {{producer}} can demonstrate that the double-signing occurred due to a bug in the reference software; the burden of proof is on {{producer}}.\n\nI hereby agree not to interfere with the producer election process. I agree to process all producer election transactions that occur in blocks I create, to sign all objectively valid blocks I create that contain election transactions, and to sign all pre-confirmations and confirmations necessary to facilitate transfer of control to the next set of producers as determined by the system contract.\n\nI hereby acknowledge that more than two-thirds of the active block producers may vote to disqualify {{producer}} in the event {{producer}} is unable to produce blocks or is unable to be reached, according to criteria agreed to among block producers.\n\nIf {{producer}} qualifies for and chooses to collect compensation due to votes received, {{producer}} will provide a public endpoint allowing at least 100 peers to maintain synchronization with the blockchain and/or submit transactions to be included. {{producer}} shall maintain at least one validating node with full state and signature checking and shall report any objectively invalid blocks produced by the active block producers. Reporting shall be via a method to be agreed to among block producers, said method and reports to be made public.\n\nThe community agrees to allow {{producer}} to authenticate peers as necessary to prevent abuse and denial of service attacks; however, {{producer}} agrees not to discriminate against non-abusive peers.\n\nI agree to process transactions on a FIFO (first in, first out) best-effort basis and to honestly bill transactions for measured execution time.\n\nI {{producer}} agree not to manipulate the contents of blocks in order to derive profit from: the order in which transactions are included, or the hash of the block that is produced.\n\nI, {{producer}}, hereby agree to disclose and attest under penalty of perjury all ultimate beneficial owners of my business entity who own more than 10% and all direct shareholders.\n\nI, {{producer}}, hereby agree to cooperate with other block producers to carry out our respective and mutual obligations under this agreement, including but not limited to maintaining network stability and a valid blockchain.\n\nI, {{producer}}, agree to maintain a website hosted at {{url}} which contains up-to-date information on all disclosures required by this contract.\n\nI, {{producer}}, agree to set the location value of {{location}} such that {{producer}} is scheduled with minimal latency between my previous and next peer.\n\nI, {{producer}}, agree to maintain time synchronization within 10 ms of global atomic clock time, using a method agreed to among block producers.\n\nI, {{producer}}, agree not to produce blocks before my scheduled time unless I have received all blocks produced by the prior block producer.\n\nI, {{producer}}, agree not to publish blocks with timestamps more than 500ms in the future unless the prior block is more than 75% full by either NET or CPU bandwidth metrics.\n\nI, {{producer}}, agree not to set the RAM supply to more RAM than my nodes contain and to resign if I am unable to provide the RAM approved by more than two-thirds of active block producers, as shown in the system parameters." + } + ], + "variants": [] +} \ No newline at end of file diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.wasm b/docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.wasm new file mode 100755 index 0000000000000000000000000000000000000000..8a8a12832f5bb497e3ffd87bd031e677175df298 GIT binary patch literal 344968 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 literal 0 HcmV?d00001 diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.abi b/docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.abi new file mode 100644 index 0000000..d054da7 --- /dev/null +++ b/docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.abi @@ -0,0 +1,185 @@ +{ + "____comment": "This file was generated with eosio-abigen. DO NOT EDIT ", + "version": "eosio::abi/1.1", + "types": [], + "structs": [ + { + "name": "account", + "base": "", + "fields": [ + { + "name": "balance", + "type": "asset" + } + ] + }, + { + "name": "close", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "symbol", + "type": "symbol" + } + ] + }, + { + "name": "create", + "base": "", + "fields": [ + { + "name": "issuer", + "type": "name" + }, + { + "name": "maximum_supply", + "type": "asset" + } + ] + }, + { + "name": "currency_stats", + "base": "", + "fields": [ + { + "name": "supply", + "type": "asset" + }, + { + "name": "max_supply", + "type": "asset" + }, + { + "name": "issuer", + "type": "name" + } + ] + }, + { + "name": "issue", + "base": "", + "fields": [ + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "open", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "symbol", + "type": "symbol" + }, + { + "name": "ram_payer", + "type": "name" + } + ] + }, + { + "name": "retire", + "base": "", + "fields": [ + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "transfer", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + } + ], + "actions": [ + { + "name": "close", + "type": "close", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Close Token Balance\nsummary: 'Close {{nowrap owner}}’s zero quantity balance'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{owner}} agrees to close their zero quantity balance for the {{symbol_to_symbol_code symbol}} token.\n\nRAM will be refunded to the RAM payer of the {{symbol_to_symbol_code symbol}} token balance for {{owner}}." + }, + { + "name": "create", + "type": "create", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Create New Token\nsummary: 'Create a new token'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{$action.account}} agrees to create a new token with symbol {{asset_to_symbol_code maximum_supply}} to be managed by {{issuer}}.\n\nThis action will not result any any tokens being issued into circulation.\n\n{{issuer}} will be allowed to issue tokens into circulation, up to a maximum supply of {{maximum_supply}}.\n\nRAM will deducted from {{$action.account}}’s resources to create the necessary records." + }, + { + "name": "issue", + "type": "issue", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Issue Tokens into Circulation\nsummary: 'Issue {{nowrap quantity}} into circulation and transfer into {{nowrap to}}’s account'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nThe token manager agrees to issue {{quantity}} into circulation, and transfer it into {{to}}’s account.\n\n{{#if memo}}There is a memo attached to the transfer stating:\n{{memo}}\n{{/if}}\n\nIf {{to}} does not have a balance for {{asset_to_symbol_code quantity}}, or the token manager does not have a balance for {{asset_to_symbol_code quantity}}, the token manager will be designated as the RAM payer of the {{asset_to_symbol_code quantity}} token balance for {{to}}. As a result, RAM will be deducted from the token manager’s resources to create the necessary records.\n\nThis action does not allow the total quantity to exceed the max allowed supply of the token." + }, + { + "name": "open", + "type": "open", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Open Token Balance\nsummary: 'Open a zero quantity balance for {{nowrap owner}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{ram_payer}} agrees to establish a zero quantity balance for {{owner}} for the {{symbol_to_symbol_code symbol}} token.\n\nIf {{owner}} does not have a balance for {{symbol_to_symbol_code symbol}}, {{ram_payer}} will be designated as the RAM payer of the {{symbol_to_symbol_code symbol}} token balance for {{owner}}. As a result, RAM will be deducted from {{ram_payer}}’s resources to create the necessary records." + }, + { + "name": "retire", + "type": "retire", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Remove Tokens from Circulation\nsummary: 'Remove {{nowrap quantity}} from circulation'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nThe token manager agrees to remove {{quantity}} from circulation, taken from their own account.\n\n{{#if memo}} There is a memo attached to the action stating:\n{{memo}}\n{{/if}}" + }, + { + "name": "transfer", + "type": "transfer", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Transfer Tokens\nsummary: 'Send {{nowrap quantity}} from {{nowrap from}} to {{nowrap to}}'\nicon: http://127.0.0.1/ricardian_assets/eosio.contracts/icons/transfer.png#5dfad0df72772ee1ccc155e670c1d124f5c5122f1d5027565df38b418042d1dd\n---\n\n{{from}} agrees to send {{quantity}} to {{to}}.\n\n{{#if memo}}There is a memo attached to the transfer stating:\n{{memo}}\n{{/if}}\n\nIf {{from}} is not already the RAM payer of their {{asset_to_symbol_code quantity}} token balance, {{from}} will be designated as such. As a result, RAM will be deducted from {{from}}’s resources to refund the original RAM payer.\n\nIf {{to}} does not have a balance for {{asset_to_symbol_code quantity}}, {{from}} will be designated as the RAM payer of the {{asset_to_symbol_code quantity}} token balance for {{to}}. As a result, RAM will be deducted from {{from}}’s resources to create the necessary records." + } + ], + "tables": [ + { + "name": "accounts", + "type": "account", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "stat", + "type": "currency_stats", + "index_type": "i64", + "key_names": [], + "key_types": [] + } + ], + "ricardian_clauses": [], + "variants": [] +} \ No newline at end of file diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.wasm b/docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.wasm new file mode 100755 index 0000000000000000000000000000000000000000..f21c5c528462d526097faa62be0901a752264510 GIT binary patch literal 17637 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 literal 0 HcmV?d00001 diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.abi b/docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.abi new file mode 100644 index 0000000..7d1f2b5 --- /dev/null +++ b/docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.abi @@ -0,0 +1,130 @@ +{ + "____comment": "This file was generated with eosio-abigen. DO NOT EDIT Thu Apr 14 07:49:40 2022", + "version": "eosio::abi/1.1", + "structs": [ + { + "name": "action", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "name", + "type": "name" + }, + { + "name": "authorization", + "type": "permission_level[]" + }, + { + "name": "data", + "type": "bytes" + } + ] + }, + { + "name": "exec", + "base": "", + "fields": [ + { + "name": "executer", + "type": "name" + }, + { + "name": "trx", + "type": "transaction" + } + ] + }, + { + "name": "extension", + "base": "", + "fields": [ + { + "name": "type", + "type": "uint16" + }, + { + "name": "data", + "type": "bytes" + } + ] + }, + { + "name": "permission_level", + "base": "", + "fields": [ + { + "name": "actor", + "type": "name" + }, + { + "name": "permission", + "type": "name" + } + ] + }, + { + "name": "transaction", + "base": "transaction_header", + "fields": [ + { + "name": "context_free_actions", + "type": "action[]" + }, + { + "name": "actions", + "type": "action[]" + }, + { + "name": "transaction_extensions", + "type": "extension[]" + } + ] + }, + { + "name": "transaction_header", + "base": "", + "fields": [ + { + "name": "expiration", + "type": "time_point_sec" + }, + { + "name": "ref_block_num", + "type": "uint16" + }, + { + "name": "ref_block_prefix", + "type": "uint32" + }, + { + "name": "max_net_usage_words", + "type": "varuint32" + }, + { + "name": "max_cpu_usage_ms", + "type": "uint8" + }, + { + "name": "delay_sec", + "type": "varuint32" + } + ] + } + ], + "types": [], + "actions": [ + { + "name": "exec", + "type": "exec", + "ricardian_contract": "" + } + ], + "tables": [], + "ricardian_clauses": [], + "variants": [], + "abi_extensions": [] +} \ No newline at end of file diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.wasm b/docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.wasm new file mode 100755 index 0000000000000000000000000000000000000000..e9c17cb7a18972f554da1d9ad4fc6d7f33a6e6f1 GIT binary patch literal 2366 zcmai0JC7Vi5U%c?$KK8kH%16q=cJ}_U-|c2Bv8m)f|vIJ_ws*ObFvf_GA_N$Hy?mQ|1Z}{g>*RYWB3Zs+NbNRaKOSn9o@Cuze ztaF3hFj`7HBV0!{o_UlYN_2?`+bs-W&EA(7LJ+Em`gO^@J_Yevmi!i|UL!QY4_>In zsk1r-g}FxgPJf}=Wk8mI;ZBW5P`h|(&6rV{D}qA) z({|Cyn`rp9Dm4i*zJviKiTmg5>?~$8Mh(fmMV5KAO*fAvo43w(a&3?qYe~i`_Y(Le zipO5t9gO$gpD&cxv?PZG+m$@zT$jb5&QWFeytX~rOdX=0#Y<00>gZF?Ifw*nIHFnA z;4par=>%dQhzW87pY6fgXTs*5?)tcDS7J*>ZP1qFU3f^o4^^jW)gVdg zzM~uTvJY`Nkou~RK2XVR%C$p?kFkSz+GQJw@Tw0?=ZZoO8lVl-_tXZr$%c{-#6RxS z9-xMd#k;B6x-|kngfk<4a8$TOrFxS*@B;PDmNs-Je;bjM26QMagaClZPa$uyikDb} zRG=pGI$C_3>+?KP0(Nr&J1&Tg^)aQxyx^hKwroZ&22A4GJzH9cO{&l)6e6LHBouN- z5@IaeO>xe16d@Uo{3U=~jBEBGf;Jn`;kneJRwQA~WB5%HX)bxHuATS zpWsTt?I5kvwj@H5bo=%S`VVVTOS9m2IF#=KBDpt`FQ?@Mp900B)qILaRSo&8a?QhP tJ$gLDWHaaaY;wd8D;~fSE9E16DzN{(_NIgRbTPr_1B|?@a)U33e*rLL%AWuL literal 0 HcmV?d00001 diff --git a/docker/leap-skynet-4.0.0/contracts/telos.decide/decide.abi b/docker/leap-skynet-4.0.0/contracts/telos.decide/decide.abi new file mode 100644 index 0000000..232bf5c --- /dev/null +++ b/docker/leap-skynet-4.0.0/contracts/telos.decide/decide.abi @@ -0,0 +1,1666 @@ +{ + "____comment": "This file was generated with eosio-abigen. DO NOT EDIT ", + "version": "eosio::abi/1.1", + "types": [], + "structs": [ + { + "name": "account", + "base": "", + "fields": [ + { + "name": "balance", + "type": "asset" + } + ] + }, + { + "name": "addfunds", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "addoption", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "new_option_name", + "type": "name" + } + ] + }, + { + "name": "addseat", + "base": "", + "fields": [ + { + "name": "committee_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "new_seat_name", + "type": "name" + } + ] + }, + { + "name": "archival", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "archived_until", + "type": "time_point_sec" + } + ] + }, + { + "name": "archive", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "archived_until", + "type": "time_point_sec" + } + ] + }, + { + "name": "assignseat", + "base": "", + "fields": [ + { + "name": "committee_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "seat_name", + "type": "name" + }, + { + "name": "seat_holder", + "type": "name" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "ballot", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "category", + "type": "name" + }, + { + "name": "publisher", + "type": "name" + }, + { + "name": "status", + "type": "name" + }, + { + "name": "title", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "content", + "type": "string" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "voting_method", + "type": "name" + }, + { + "name": "min_options", + "type": "uint8" + }, + { + "name": "max_options", + "type": "uint8" + }, + { + "name": "options", + "type": "pair_name_asset[]" + }, + { + "name": "total_voters", + "type": "uint32" + }, + { + "name": "total_delegates", + "type": "uint32" + }, + { + "name": "total_raw_weight", + "type": "asset" + }, + { + "name": "cleaned_count", + "type": "uint32" + }, + { + "name": "settings", + "type": "pair_name_bool[]" + }, + { + "name": "begin_time", + "type": "time_point_sec" + }, + { + "name": "end_time", + "type": "time_point_sec" + } + ] + }, + { + "name": "broadcast", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "final_results", + "type": "pair_name_asset[]" + }, + { + "name": "total_voters", + "type": "uint32" + } + ] + }, + { + "name": "burn", + "base": "", + "fields": [ + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "cancelballot", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "castvote", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "options", + "type": "name[]" + } + ] + }, + { + "name": "claimpayment", + "base": "", + "fields": [ + { + "name": "worker_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + } + ] + }, + { + "name": "cleanupvote", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "worker", + "type": "name?" + } + ] + }, + { + "name": "closevoting", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "broadcast", + "type": "bool" + } + ] + }, + { + "name": "committee", + "base": "", + "fields": [ + { + "name": "committee_title", + "type": "string" + }, + { + "name": "committee_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "seats", + "type": "pair_name_name[]" + }, + { + "name": "updater_acct", + "type": "name" + }, + { + "name": "updater_auth", + "type": "name" + } + ] + }, + { + "name": "config", + "base": "", + "fields": [ + { + "name": "app_name", + "type": "string" + }, + { + "name": "app_version", + "type": "string" + }, + { + "name": "total_deposits", + "type": "asset" + }, + { + "name": "fees", + "type": "pair_name_asset[]" + }, + { + "name": "times", + "type": "pair_name_uint32[]" + } + ] + }, + { + "name": "delcommittee", + "base": "", + "fields": [ + { + "name": "committee_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "delegate", + "base": "", + "fields": [ + { + "name": "delegate_name", + "type": "name" + }, + { + "name": "total_delegated", + "type": "asset" + }, + { + "name": "constituents", + "type": "uint32" + } + ] + }, + { + "name": "deleteballot", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + } + ] + }, + { + "name": "editdetails", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "title", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "content", + "type": "string" + } + ] + }, + { + "name": "editminmax", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "new_min_options", + "type": "uint8" + }, + { + "name": "new_max_options", + "type": "uint8" + } + ] + }, + { + "name": "editpayrate", + "base": "", + "fields": [ + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "period_length", + "type": "uint32" + }, + { + "name": "per_period", + "type": "asset" + } + ] + }, + { + "name": "edittrsinfo", + "base": "", + "fields": [ + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "title", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "icon", + "type": "string" + } + ] + }, + { + "name": "featured_ballot", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "featured_until", + "type": "time_point_sec" + } + ] + }, + { + "name": "forfeitwork", + "base": "", + "fields": [ + { + "name": "worker_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + } + ] + }, + { + "name": "init", + "base": "", + "fields": [ + { + "name": "app_version", + "type": "string" + } + ] + }, + { + "name": "labor", + "base": "", + "fields": [ + { + "name": "worker_name", + "type": "name" + }, + { + "name": "start_time", + "type": "time_point_sec" + }, + { + "name": "unclaimed_volume", + "type": "pair_name_asset[]" + }, + { + "name": "unclaimed_events", + "type": "pair_name_uint32[]" + } + ] + }, + { + "name": "labor_bucket", + "base": "", + "fields": [ + { + "name": "payroll_name", + "type": "name" + }, + { + "name": "claimable_volume", + "type": "pair_name_asset[]" + }, + { + "name": "claimable_events", + "type": "pair_name_uint32[]" + } + ] + }, + { + "name": "lock", + "base": "", + "fields": [ + { + "name": "treasury_symbol", + "type": "symbol" + } + ] + }, + { + "name": "mint", + "base": "", + "fields": [ + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "mutatemax", + "base": "", + "fields": [ + { + "name": "new_max_supply", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "newballot", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "category", + "type": "name" + }, + { + "name": "publisher", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "voting_method", + "type": "name" + }, + { + "name": "initial_options", + "type": "name[]" + } + ] + }, + { + "name": "newtreasury", + "base": "", + "fields": [ + { + "name": "manager", + "type": "name" + }, + { + "name": "max_supply", + "type": "asset" + }, + { + "name": "access", + "type": "name" + } + ] + }, + { + "name": "openvoting", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "end_time", + "type": "time_point_sec" + } + ] + }, + { + "name": "pair_name_asset", + "base": "", + "fields": [ + { + "name": "key", + "type": "name" + }, + { + "name": "value", + "type": "asset" + } + ] + }, + { + "name": "pair_name_bool", + "base": "", + "fields": [ + { + "name": "key", + "type": "name" + }, + { + "name": "value", + "type": "bool" + } + ] + }, + { + "name": "pair_name_name", + "base": "", + "fields": [ + { + "name": "key", + "type": "name" + }, + { + "name": "value", + "type": "name" + } + ] + }, + { + "name": "pair_name_uint32", + "base": "", + "fields": [ + { + "name": "key", + "type": "name" + }, + { + "name": "value", + "type": "uint32" + } + ] + }, + { + "name": "payroll", + "base": "", + "fields": [ + { + "name": "payroll_name", + "type": "name" + }, + { + "name": "payroll_funds", + "type": "asset" + }, + { + "name": "period_length", + "type": "uint32" + }, + { + "name": "per_period", + "type": "asset" + }, + { + "name": "last_claim_time", + "type": "time_point_sec" + }, + { + "name": "claimable_pay", + "type": "asset" + }, + { + "name": "payee", + "type": "name" + } + ] + }, + { + "name": "postresults", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "light_results", + "type": "pair_name_asset[]" + }, + { + "name": "total_voters", + "type": "uint32" + } + ] + }, + { + "name": "rebalance", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "worker", + "type": "name?" + } + ] + }, + { + "name": "reclaim", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "refresh", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + } + ] + }, + { + "name": "regcommittee", + "base": "", + "fields": [ + { + "name": "committee_name", + "type": "name" + }, + { + "name": "committee_title", + "type": "string" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "initial_seats", + "type": "name[]" + }, + { + "name": "registree", + "type": "name" + } + ] + }, + { + "name": "regvoter", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "referrer", + "type": "name?" + } + ] + }, + { + "name": "removeseat", + "base": "", + "fields": [ + { + "name": "committee_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "seat_name", + "type": "name" + } + ] + }, + { + "name": "rmvoption", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "option_name", + "type": "name" + } + ] + }, + { + "name": "setunlocker", + "base": "", + "fields": [ + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "new_unlock_acct", + "type": "name" + }, + { + "name": "new_unlock_auth", + "type": "name" + } + ] + }, + { + "name": "setupdater", + "base": "", + "fields": [ + { + "name": "committee_name", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "updater_account", + "type": "name" + }, + { + "name": "updater_auth", + "type": "name" + } + ] + }, + { + "name": "setversion", + "base": "", + "fields": [ + { + "name": "new_app_version", + "type": "string" + } + ] + }, + { + "name": "stake", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "toggle", + "base": "", + "fields": [ + { + "name": "treasury_symbol", + "type": "symbol" + }, + { + "name": "setting_name", + "type": "name" + } + ] + }, + { + "name": "togglebal", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "setting_name", + "type": "name" + } + ] + }, + { + "name": "transfer", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "treasury", + "base": "", + "fields": [ + { + "name": "supply", + "type": "asset" + }, + { + "name": "max_supply", + "type": "asset" + }, + { + "name": "access", + "type": "name" + }, + { + "name": "manager", + "type": "name" + }, + { + "name": "title", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "icon", + "type": "string" + }, + { + "name": "voters", + "type": "uint32" + }, + { + "name": "delegates", + "type": "uint32" + }, + { + "name": "committees", + "type": "uint32" + }, + { + "name": "open_ballots", + "type": "uint32" + }, + { + "name": "locked", + "type": "bool" + }, + { + "name": "unlock_acct", + "type": "name" + }, + { + "name": "unlock_auth", + "type": "name" + }, + { + "name": "settings", + "type": "pair_name_bool[]" + } + ] + }, + { + "name": "unarchive", + "base": "", + "fields": [ + { + "name": "ballot_name", + "type": "name" + }, + { + "name": "force", + "type": "bool" + } + ] + }, + { + "name": "unlock", + "base": "", + "fields": [ + { + "name": "treasury_symbol", + "type": "symbol" + } + ] + }, + { + "name": "unregvoter", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "treasury_symbol", + "type": "symbol" + } + ] + }, + { + "name": "unstake", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "unvoteall", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "ballot_name", + "type": "name" + } + ] + }, + { + "name": "updatefee", + "base": "", + "fields": [ + { + "name": "fee_name", + "type": "name" + }, + { + "name": "fee_amount", + "type": "asset" + } + ] + }, + { + "name": "updatetime", + "base": "", + "fields": [ + { + "name": "time_name", + "type": "name" + }, + { + "name": "length", + "type": "uint32" + } + ] + }, + { + "name": "vote", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "is_delegate", + "type": "bool" + }, + { + "name": "raw_votes", + "type": "asset" + }, + { + "name": "weighted_votes", + "type": "pair_name_asset[]" + }, + { + "name": "vote_time", + "type": "time_point_sec" + }, + { + "name": "worker", + "type": "name" + }, + { + "name": "rebalances", + "type": "uint8" + }, + { + "name": "rebalance_volume", + "type": "asset" + } + ] + }, + { + "name": "voter", + "base": "", + "fields": [ + { + "name": "liquid", + "type": "asset" + }, + { + "name": "staked", + "type": "asset" + }, + { + "name": "staked_time", + "type": "time_point_sec" + }, + { + "name": "delegated", + "type": "asset" + }, + { + "name": "delegated_to", + "type": "name" + }, + { + "name": "delegation_time", + "type": "time_point_sec" + } + ] + }, + { + "name": "withdraw", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + } + ], + "actions": [ + { + "name": "addfunds", + "type": "addfunds", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Add Payroll Funds\nsummary: 'Add to Treasury Payroll Funds'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{$action.account}} adds {{quantity}} to the {{treasury_symbol}}'s {{payroll_name}} payroll." + }, + { + "name": "addoption", + "type": "addoption", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Add Ballot Option\nsummary: 'Add Ballot Option'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} adds the {{new_option_name}} option to the {{ballot_name}} ballot." + }, + { + "name": "addseat", + "type": "addseat", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Add Committee Seat\nsummary: 'Add Committee Seat'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nCommittee updater {{$action.account}} adds the {{seat_name}} seat to the {{committee_name}} committee." + }, + { + "name": "archive", + "type": "archive", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Archive Ballot\nsummary: 'Archive Ballot'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} archives the {{ballot_name}} ballot until {{archived_until}}." + }, + { + "name": "assignseat", + "type": "assignseat", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Assign Committee Seat\nsummary: 'Assign Committee Seat'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nCommittee updater {{$action.account}} assigns {{seat_holder}} to the {{seat_name}} seat in the {{committee_name}} committee." + }, + { + "name": "broadcast", + "type": "broadcast", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Broadcast Ballot Results\nsummary: 'Broadcast Ballot Results'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBroadcasts the final ballot results to the ballot publisher's account." + }, + { + "name": "burn", + "type": "burn", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Burn Tokens\nsummary: 'Burn Treasury Tokens from Manager Balance'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury Manager {{$action.account}} burns {{quantity}} from their account." + }, + { + "name": "cancelballot", + "type": "cancelballot", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Cancel Ballot\nsummary: 'Cancel Ballot'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} cancels the {{ballot_name}} ballot and forfeits all fees spent in its creation.\n\n## Ballot Status Change\n\nThe {{ballot_name}} will be changed to the \"cancelled\" status. From there, it can be fully deleted with the deleteballot() action." + }, + { + "name": "castvote", + "type": "castvote", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Cast Vote\nsummary: 'Cast Vote on a Ballot'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nVoter {{$action.account}} casts votes for {{options}} on the {{ballot_name}} ballot." + }, + { + "name": "claimpayment", + "type": "claimpayment", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Claim Payment\nsummary: 'Claim Payment'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nWorker {{$action.account}} claims payment for all committed work on the {{treasury_symbol}} treasury." + }, + { + "name": "cleanupvote", + "type": "cleanupvote", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Clean Vote\nsummary: 'Clean Vote'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nWorker {{$action.account}} cleans {{voter}}'s vote on the {{ballot_name}}." + }, + { + "name": "closevoting", + "type": "closevoting", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Close Ballot Voting\nsummary: 'Close Ballot Voting'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} closes the completed {{ballot_name}} ballot." + }, + { + "name": "delcommittee", + "type": "delcommittee", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Delete Committee\nsummary: 'Delete Committee'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nCommittee Updater {{$action.account}} deletes the committee." + }, + { + "name": "deleteballot", + "type": "deleteballot", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Delete Ballot\nsummary: 'Delete Ballot'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{$action.account}} deletes the {{ballot_name}} ballot." + }, + { + "name": "editdetails", + "type": "editdetails", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Edit Ballot Info\nsummary: 'Edit Ballot Info'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publiser {{$action.account}} edits the ballot title to {{title}}, the description to {{description}}, and the content to {{content}}." + }, + { + "name": "editminmax", + "type": "editminmax", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Edit Ballot Min/Max\nsummary: 'Edit Ballot Min/Max Options'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} edits number of minimum vote options to {{new_min_options}}, and the max vote options to {{new_max_options}}. This means voters can select between {{new_min_options}} and {{new_max_options}} options when voting." + }, + { + "name": "editpayrate", + "type": "editpayrate", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Edit Payroll Pay Rate\nsummary: 'Edit Payroll Pay Rate'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury Manager {{$action.account}} changes {{treasury_symbol}}'s {{payroll_name}} pay rate to {{per_period}} per {{period_length}} second period." + }, + { + "name": "edittrsinfo", + "type": "edittrsinfo", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Edit Treasury Info\nsummary: 'Edit Treasury Info'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\n{{$action.account}} sets the treasury title to {{title}}, the description to {{description}}, and the icon to {{icon}} for the {{treasury_symbol}} treasury." + }, + { + "name": "forfeitwork", + "type": "forfeitwork", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Forfeit Work\nsummary: 'Forfeit Work'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nWorker {{$action.account}} forfeits all currently commited work on the {{treasury_symbol}} treasury." + }, + { + "name": "init", + "type": "init", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Set Decide Config\nsummary: 'Set Decide Config Version'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\n{{$action.account}} sets the Telos Decide config to version {{app_version}}." + }, + { + "name": "lock", + "type": "lock", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Lock Treasury Settings\nsummary: 'Lock Treasury Settings'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury Manager {{$action.account}} locks the {{treasury_symbol}} treasury." + }, + { + "name": "mint", + "type": "mint", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Mint Tokens\nsummary: 'Mint Treasury Tokens'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury Manager {{$action.account}} mints {{quantity}} to {{to}} account." + }, + { + "name": "mutatemax", + "type": "mutatemax", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Mutate Max Supply\nsummary: 'Mutate Treasury Max Supply'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury Manager {{$action.account}} mutates the {{treasury_symbol}} treasury's max supply to {{new_max_supply}}." + }, + { + "name": "newballot", + "type": "newballot", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: New Treasury Ballot\nsummary: 'Create New Treasury Ballot'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nVoter {{publisher}} creates the {{ballot_name}} ballot for the {{treasury_symbol}} treasury." + }, + { + "name": "newtreasury", + "type": "newtreasury", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Define New Treasury\nsummary: 'Define New Treasury'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\n{{manager}} is defining a new treasury with a max supply of {{max_supply}} and an initial access method of {{access}}.\n\n## Initial Settings\n\nAll settings on the new treasury will be initialized to false." + }, + { + "name": "openvoting", + "type": "openvoting", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Open Ballot Voting\nsummary: 'Open Ballot For Voting'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot puboisher {{$action.account}} opens voting on {{ballot_name}} ballot until {{end_time}}." + }, + { + "name": "postresults", + "type": "postresults", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Post Light Ballot Results\nsummary: 'Post Light Ballot Results'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} posts the results of the {{ballot_name}} light ballot." + }, + { + "name": "rebalance", + "type": "rebalance", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Rebalance Vote\nsummary: 'Rebalance Vote'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nWorker {{$action.account}} rebalances {{voter}}'s vote on the {{ballot_name}} ballot." + }, + { + "name": "reclaim", + "type": "reclaim", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Reclaim Tokens\nsummary: 'Reclaim Treasury Tokens from Voter'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury manager {{$action.account}} reclaims {{quantity}} from {{voter}}." + }, + { + "name": "refresh", + "type": "refresh", + "ricardian_contract": "" + }, + { + "name": "regcommittee", + "type": "regcommittee", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Register New Committee\nsummary: 'Register New Committee'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nVoter {{$action.account}} registers the {{committee_name}} committee for the {{treasury_symbol}} treasury." + }, + { + "name": "regvoter", + "type": "regvoter", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Register Voter\nsummary: 'Register Voter'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nRegister the new voter {{$action.account}} to the {{treasury_symbol}} treasury." + }, + { + "name": "removeseat", + "type": "removeseat", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Remove Committee Seat\nsummary: 'Remove Committee Seat'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nCommittee updater {{$action.account}} removes the {{seat_name}} seat from the {{committee_name}} committee." + }, + { + "name": "rmvoption", + "type": "rmvoption", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Remove Ballot Option\nsummary: 'Remove Ballot Option'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} removes the {{option_name}} option from the {{ballot_name}} ballot." + }, + { + "name": "setunlocker", + "type": "setunlocker", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Set Treasury Unlocker\nsummary: 'Set Treasury Unlocker Account and Permission'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury Manager {{$action.account}} sets the {{treasury_symbol}} unlocker auth to {{new_unlock_acct}}@{{new_unlock_auth}}." + }, + { + "name": "setupdater", + "type": "setupdater", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Set Committee Updater\nsummary: 'Set Committee Updater'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nCommittee updater {{$action.account}} sets the updater to {{updater_acct}}@{{updater_auth}} for the {{committee_name}} committee." + }, + { + "name": "setversion", + "type": "setversion", + "ricardian_contract": "" + }, + { + "name": "stake", + "type": "stake", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Stake Tokens\nsummary: 'Stake Tokens'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nVoter {{$action.account}} stakes {{quantity}}." + }, + { + "name": "toggle", + "type": "toggle", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Toggle Treasury Setting\nsummary: 'Toggle Treasury Setting'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/resource.png#3830f1ce8cb07f7757dbcf383b1ec1b11914ac34a1f9d8b065f07600fa9dac19\n---\n\n{{$action.account}} toggles the treasury setting {{setting_name}} for the {{treasury_symbol}} treasury." + }, + { + "name": "togglebal", + "type": "togglebal", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Toggle Ballot Setting\nsummary: 'Toggle Ballot Setting'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot publisher {{$action.account}} toggles the {{setting_name}} on the {{ballot_name}} ballot." + }, + { + "name": "transfer", + "type": "transfer", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Transfer Tokens\nsummary: 'Transfer Treasury Tokens'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/transfer.png#5dfad0df72772ee1ccc155e670c1d124f5c5122f1d5027565df38b418042d1dd\n---\n\nVoter {{$action.account}} transfers {{quantity}} to {{to}}." + }, + { + "name": "unarchive", + "type": "unarchive", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unarchive Ballot\nsummary: 'Unarchive Ballot'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nBallot archiver {{$action.account}} unarchives the {{ballot_name}} ballot." + }, + { + "name": "unlock", + "type": "unlock", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unlock Treasury Settings\nsummary: 'Unlock Treasury Settings'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nTreasury Unlocker {{$action.account}} unlocks the {{treasury_symbol}} treasury." + }, + { + "name": "unregvoter", + "type": "unregvoter", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unregister voter\nsummary: 'Unregister Voter'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nUnregister the {{$action.account}} voter from the {{treasury_symbol}} treasury." + }, + { + "name": "unstake", + "type": "unstake", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unstake Tokens\nsummary: 'Unstake Tokens'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nVoter {{$action.account}} unstakes {{quantity}}." + }, + { + "name": "unvoteall", + "type": "unvoteall", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Unvote All Options\nsummary: 'Unvote All Options'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/voting.png#db28cd3db6e62d4509af3644ce7d377329482a14bb4bfaca2aa5f1400d8e8a84\n---\n\nVoter {{$action.account}} unvotes all options on their vote." + }, + { + "name": "updatefee", + "type": "updatefee", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Update Fee\nsummary: 'Update Platform Fee'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\nTrail Admin {{$action.account}} updates {{fee_name}} fee to {{fee_amount}}." + }, + { + "name": "updatetime", + "type": "updatetime", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Update Time Setting\nsummary: 'Update Platform Time Setting'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/admin.png#9bf1cec664863bd6aaac0f814b235f8799fb02c850e9aa5da34e8a004bd6518e\n---\n\nTrail Admin {{$action.account}} udpates the {{time_name}} time to {{length}} seconds." + }, + { + "name": "withdraw", + "type": "withdraw", + "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Withdraw TLOS\nsummary: 'Withdraw TLOS from Trail to Wallet'\nicon: https://github.com/Telos-Foundation/images/raw/master/ricardian_assets/eosio.contracts/icons/account.png#3d55a2fc3a5c20b456f5657faf666bc25ffd06f4836c5e8256f741149b0b294f\n---\n\nAccount {{$action.account}} withdraws {{quantity}} from the Trail platform." + } + ], + "tables": [ + { + "name": "accounts", + "type": "account", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "archivals", + "type": "archival", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "ballots", + "type": "ballot", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "committees", + "type": "committee", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "config", + "type": "config", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "delegates", + "type": "delegate", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "featured", + "type": "featured_ballot", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "laborbuckets", + "type": "labor_bucket", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "labors", + "type": "labor", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "payrolls", + "type": "payroll", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "treasuries", + "type": "treasury", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "voters", + "type": "voter", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "votes", + "type": "vote", + "index_type": "i64", + "key_names": [], + "key_types": [] + } + ], + "ricardian_clauses": [ + { + "id": "Intent", + "body": "The intention of the authors and invoker of this smart contract is to ..." + }, + { + "id": "Term", + "body": "This Contract expires at the conclusion of code execution." + }, + { + "id": "Warranty", + "body": "{{ name }} shall uphold its Obligations under this Contract in a timely and workmanlike manner, using knowledge and recommendations for performing the services which meet generally acceptable standards set forth by Telos Blockchain Network Block Producers." + }, + { + "id": "Default", + "body": "The occurrence of any of the following shall constitute a material default under this Contract:" + }, + { + "id": "Remedies", + "body": "In addition to any and all other rights a party may have available according to law, if a party defaults by failing to substantially perform any provision, term or condition of this Contract, the other party may terminate the Contract by providing written notice to the defaulting party. This notice shall describe with sufficient detail the nature of the default. The party receiving such notice shall promptly be removed from being a Block Producer and this Contract shall be automatically terminated.\"" + }, + { + "id": "Force Majeure", + "body": "If performance of this Contract or any obligation under this Contract is prevented, restricted, or interfered with by causes beyond either party's reasonable control (\\\"Force Majeure\\\"), and if the party unable to carry out its obligations gives the other party prompt written notice of such event, then the obligations of the party invoking this provision shall be suspended to the extent necessary by such event. The term Force Majeure shall include, without limitation, acts of God, fire, explosion, vandalism, storm or other similar occurrence, orders or acts of military or civil authority, or by national emergencies, insurrections, riots, or wars, or strikes, lock-outs, work stoppages, or supplier failures. The excused party shall use reasonable efforts under the circumstances to avoid or remove such causes of non-performance and shall proceed to perform with reasonable dispatch whenever such causes are removed or ceased. An act or omission shall be deemed within the reasonable control of a party if committed, omitted, or caused by such party, or its employees, officers, agents, or affiliates.\"" + }, + { + "id": "Dispute Resolution", + "body": "Any controversies or disputes arising out of or relating to this Contract will be resolved by binding arbitration under Telos Blockchain Network Arbitration Rules and Procedures. The arbitrator's award will be final.\"" + }, + { + "id": "Entire Agreement", + "body": "This Contract contains the entire agreement of the parties, and there are no other promises or conditions in any other agreement whether oral or written concerning the subject matter of this Contract. This Contract supersedes any prior written or oral agreements between the parties, except for the Telos Blockchain Network Core Governance Documents\"" + }, + { + "id": "Severability", + "body": "If any provision of this Contract will be held to be invalid or unenforceable for any reason, the remaining provisions will continue to be valid and enforceable. If a court finds that any provision of this Contract is invalid or unenforceable, but that by limiting such provision it would become valid and enforceable, then such provision will be deemed to be written, construed, and enforced as so limited.\"" + }, + { + "id": "Governing Law", + "body": "This Contract shall be construed in accordance with the Telos Blockchain Network Core Governance Documents, previously referenced." + }, + { + "id": "Notice", + "body": "Any notice or communication required or permitted under this Contract shall be sufficiently given if delivered to a verifiable email address or to such other email address as one party may have publicly furnished in writing, or published on a broadcast contract provided by this blockchain for purposes of providing notices of this type." + }, + { + "id": "Waiver of Contractual Right", + "body": "The failure of either party to enforce any provision of this Contract shall not be construed as a waiver or limitation of that party's right to subsequently enforce and compel strict compliance with every provision of this Contract." + }, + { + "id": "Arbitrator's Fees to Prevailing Party", + "body": "In any action arising hereunder or any separate action pertaining to the validity of this Agreement, both sides shall pay half the initial cost of arbitration, and the prevailing party shall be awarded reasonable arbitrator's fees and costs." + }, + { + "id": "Construction and Interpretation", + "body": "The rule requiring construction or interpretation against the drafter is waived. The document shall be deemed as if it were drafted by both parties in a mutual effort.\"" + }, + { + "id": "In Witness Whereof", + "body": "IN WITNESS WHEREOF, the parties hereto have caused this Agreement to be executed by themselves or their duly authorized representatives as of the date of execution, and authorized as proven by the cryptographic signature on the transaction that invokes this contract." + } + ], + "variants": [] +} \ No newline at end of file diff --git a/docker/leap-skynet-4.0.0/contracts/telos.decide/decide.wasm b/docker/leap-skynet-4.0.0/contracts/telos.decide/decide.wasm new file mode 100755 index 0000000000000000000000000000000000000000..d818604cf036b39db6b5f8ecf66c63fba0d988d3 GIT binary patch literal 255437 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

-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 z

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 GLGYiE(V7?l literal 0 HcmV?d00001 diff --git a/docker/leap-skynet-4.0.0/genesis/skynet.json b/docker/leap-skynet-4.0.0/genesis/skynet.json new file mode 100644 index 0000000..82890c9 --- /dev/null +++ b/docker/leap-skynet-4.0.0/genesis/skynet.json @@ -0,0 +1,25 @@ +{ + "initial_timestamp": "2023-05-22T00:00:00.000", + "initial_key": "EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1", + "initial_configuration": { + "max_block_net_usage": 1048576, + "target_block_net_usage_pct": 1000, + "max_transaction_net_usage": 1048575, + "base_per_transaction_net_usage": 12, + "net_usage_leeway": 500, + "context_free_discount_net_usage_num": 20, + "context_free_discount_net_usage_den": 100, + "max_block_cpu_usage": 200000, + "target_block_cpu_usage_pct": 1000, + "max_transaction_cpu_usage": 150000, + "min_transaction_cpu_usage": 100, + "max_transaction_lifetime": 3600, + "deferred_trx_expiration_window": 600, + "max_transaction_delay": 3888000, + "max_inline_action_size": 4096, + "max_inline_action_depth": 4, + "max_authority_depth": 6 + } +} + + diff --git a/pytest.ini b/pytest.ini index 7f91c13..335075e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,3 @@ [pytest] log_cli = True log_level = info -trio_mode = true diff --git a/requirements.test.txt b/requirements.test.txt index f39926b..a35f4d3 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,6 +1,6 @@ pdbpp pytest -pytest-trio +docker psycopg2-binary -git+https://github.com/guilledk/pytest-dockerctl.git@multi_names#egg=pytest-dockerctl +git+https://github.com/guilledk/py-leap.git@async_net_requests#egg=py-eosio diff --git a/requirements.txt b/requirements.txt index c773225..3fa199a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,9 @@ trio -pynng +asks numpy Pillow triopg aiohttp -msgspec -protobuf -pyOpenSSL -trio_asyncio pyTelegramBotAPI git+https://github.com/goodboy/tractor.git@master#egg=tractor diff --git a/scripts/generate_cert.py b/scripts/generate_cert.py deleted file mode 100644 index 2b1634a..0000000 --- a/scripts/generate_cert.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/python - -'''Self signed x509 certificate generator - -can look at generated file using openssl: - openssl x509 -inform pem -in selfsigned.crt -noout -text''' -import sys - -from OpenSSL import crypto, SSL - -from skynet.constants import DEFAULT_CERTS_DIR - - -def input_or_skip(txt, default): - i = input(f'[default: {default}]: {txt}') - if len(i) == 0: - return default - else: - return i - - -if __name__ == '__main__': - # create a key pair - k = crypto.PKey() - k.generate_key(crypto.TYPE_RSA, 4096) - # create a self-signed cert - cert = crypto.X509() - cert.get_subject().C = input('country name two char ISO code (example: US): ') - cert.get_subject().ST = input('state or province name (example: Texas): ') - cert.get_subject().L = input('locality name (example: Dallas): ') - cert.get_subject().O = input('organization name: ') - cert.get_subject().OU = input_or_skip('organizational unit name: ', 'none') - cert.get_subject().CN = input('common name: ') - cert.get_subject().emailAddress = input('email address: ') - cert.set_serial_number(int(input_or_skip('numberic serial number: ', 0))) - cert.gmtime_adj_notBefore(int(input_or_skip('amount of seconds until cert is valid: ', 0))) - cert.gmtime_adj_notAfter(int(input_or_skip('amount of seconds until cert expires: ', 10*365*24*60*60))) - cert.set_issuer(cert.get_subject()) - cert.set_pubkey(k) - cert.sign(k, 'sha512') - with open(f'{DEFAULT_CERTS_DIR}/{sys.argv[1]}.cert', "wt") as f: - f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8")) - with open(f'{DEFAULT_CERTS_DIR}/{sys.argv[1]}.key', "wt") as f: - f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode("utf-8")) diff --git a/skynet.ini.example b/skynet.ini.example index 7035920..7580e0f 100644 --- a/skynet.ini.example +++ b/skynet.ini.example @@ -1,12 +1,6 @@ -[skynet] -certs_dir = certs - [skynet.dgpu] hf_home = hf_home hf_token = hf_XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx [skynet.telegram] token = XXXXXXXXXX:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - -[skynet.telegram-test] -token = XXXXXXXXXX:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/skynet/brain.py b/skynet/brain.py deleted file mode 100644 index b121bd3..0000000 --- a/skynet/brain.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/python - -import logging - -from contextlib import asynccontextmanager as acm -from collections import OrderedDict - -import trio - -from pynng import Context - -from .utils import time_ms -from .network import * -from .protobuf import * -from .constants import * - - - -class SkynetRPCBadRequest(BaseException): - ... - -class SkynetDGPUOffline(BaseException): - ... - -class SkynetDGPUOverloaded(BaseException): - ... - -class SkynetDGPUComputeError(BaseException): - ... - -class SkynetShutdownRequested(BaseException): - ... - - -@acm -async def run_skynet( - rpc_address: str = DEFAULT_RPC_ADDR -): - logging.basicConfig(level=logging.INFO) - logging.info('skynet is starting') - - nodes = OrderedDict() - heartbeats = {} - next_worker: Optional[int] = None - - def connect_node(req: SkynetRPCRequest): - nonlocal next_worker - - node_params = MessageToDict(req.params) - logging.info(f'got node params {node_params}') - - if 'dgpu_addr' not in node_params: - raise SkynetRPCBadRequest( - f'DGPU connection params don\'t include dgpu addr') - - session = SessionClient( - node_params['dgpu_addr'], - 'skynet', - cert_name='brain.cert', - key_name='brain.key', - ca_name=node_params['cert'] - ) - try: - session.connect() - - node = { - 'task': None, - 'session': session - } - node.update(node_params) - - nodes[req.uid] = node - logging.info(f'DGPU node online: {req.uid}') - - if not next_worker: - next_worker = 0 - - except pynng.exceptions.ConnectionRefused: - logging.warning(f'error while dialing dgpu node... dropping...') - raise SkynetDGPUOffline('Connection to dgpu node addr failed.') - - def disconnect_node(uid): - nonlocal next_worker - if uid not in nodes: - logging.warning(f'Attempt to disconnect unknown node {uid}') - return - - i = list(nodes.keys()).index(uid) - nodes[uid]['session'].disconnect() - del nodes[uid] - - if i < next_worker: - next_worker -= 1 - - logging.warning(f'DGPU node offline: {uid}') - - if len(nodes) == 0: - logging.info('All nodes disconnected.') - next_worker = None - - - def is_worker_busy(nid: str): - return nodes[nid]['task'] != None - - def are_all_workers_busy(): - for nid in nodes.keys(): - if not is_worker_busy(nid): - return False - - return True - - def get_next_worker(): - nonlocal next_worker - - if next_worker == None: - raise SkynetDGPUOffline('No workers connected, try again later') - - if are_all_workers_busy(): - raise SkynetDGPUOverloaded('All workers are busy at the moment') - - - nid = list(nodes.keys())[next_worker] - while is_worker_busy(nid): - next_worker += 1 - - if next_worker >= len(nodes): - next_worker = 0 - - nid = list(nodes.keys())[next_worker] - - next_worker += 1 - if next_worker >= len(nodes): - next_worker = 0 - - return nid - - async def rpc_handler(req: SkynetRPCRequest, ctx: Context): - result = {'ok': {}} - resp = SkynetRPCResponse() - - try: - match req.method: - case 'dgpu_online': - connect_node(req) - - case 'dgpu_call': - nid = get_next_worker() - idx = list(nodes.keys()).index(nid) - node = nodes[nid] - logging.info(f'dgpu_call {idx}/{len(nodes)} {nid} @ {node["dgpu_addr"]}') - dgpu_time = await node['session'].rpc('dgpu_time') - if 'ok' not in dgpu_time.result: - status = MessageToDict(dgpu_time.result) - logging.warning(json.dumps(status, indent=4)) - disconnect_node(nid) - raise SkynetDGPUComputeError(status['error']) - - dgpu_time = dgpu_time.result['ok'] - logging.info(f'ping to {nid}: {time_ms() - dgpu_time} ms') - - try: - dgpu_result = await node['session'].rpc( - timeout=45, # give this 45 sec to run cause its compute - binext=req.bin, - **req.params - ) - result = MessageToDict(dgpu_result.result) - - if dgpu_result.bin: - resp.bin = dgpu_result.bin - - except trio.TooSlowError: - result = {'error': 'timeout while processing request'} - - case 'dgpu_offline': - disconnect_node(req.uid) - - case 'dgpu_workers': - result = {'ok': len(nodes)} - - case 'dgpu_next': - result = {'ok': next_worker} - - case 'skynet_shutdown': - raise SkynetShutdownRequested - - case _: - logging.warning(f'Unknown method {req.method}') - result = {'error': 'unknown method'} - - except BaseException as e: - result = {'error': str(e)} - - resp.result.update(result) - - return resp - - rpc_server = SessionServer( - rpc_address, - rpc_handler, - cert_name='brain.cert', - key_name='brain.key' - ) - - async with rpc_server.open(): - logging.info('rpc server is up') - yield - logging.info('skynet is shuting down...') - - logging.info('skynet down.') diff --git a/skynet/cli.py b/skynet/cli.py index 021e1e6..28c4eb0 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -4,21 +4,27 @@ torch_enabled = importlib.util.find_spec('torch') != None import os import json +import logging from typing import Optional from functools import partial import trio import click -import trio_asyncio +import docker +import asyncio + +from leap.cleos import CLEOS, default_nodeos_image +from leap.sugar import get_container if torch_enabled: from . import utils from .dgpu import open_dgpu_node -from .brain import run_skynet +from .db import open_new_database from .config import * -from .constants import ALGOS, DEFAULT_RPC_ADDR, DEFAULT_DGPU_ADDR +from .nodeos import open_nodeos +from .constants import ALGOS from .frontend.telegram import run_skynet_telegram @@ -86,79 +92,93 @@ def download(): def run(*args, **kwargs): pass +@run.command() +def db(): + logging.basicConfig(level=logging.INFO) + with open_new_database(cleanup=False) as db_params: + container, passwd, host = db_params + logging.info(('skynet', passwd, host)) + +@run.command() +def nodeos(): + logging.basicConfig(level=logging.INFO) + with open_nodeos(cleanup=False): + ... @run.command() @click.option('--loglevel', '-l', default='warning', help='Logging level') @click.option( - '--host', '-H', default=DEFAULT_RPC_ADDR) -def brain( - loglevel: str, - host: str -): - async def _run_skynet(): - async with run_skynet( - rpc_address=host - ): - await trio.sleep_forever() - - trio.run(_run_skynet) - - -@run.command() -@click.option('--loglevel', '-l', default='warning', help='Logging level') + '--account', '-a', default='testworker1') @click.option( - '--uid', '-u', required=True) + '--permission', '-p', default='active') @click.option( - '--key', '-k', default='dgpu.key') + '--key', '-k', default=None) @click.option( - '--cert', '-c', default='whitelist/dgpu.cert') + '--node-url', '-n', default='http://test1.us.telos.net:42000') @click.option( - '--algos', '-a', default=json.dumps(['midj'])) -@click.option( - '--rpc', '-r', default=DEFAULT_RPC_ADDR) -@click.option( - '--dgpu', '-d', default=DEFAULT_DGPU_ADDR) + '--algos', '-A', default=json.dumps(['midj'])) def dgpu( loglevel: str, - uid: str, - key: str, - cert: str, - algos: str, - rpc: str, - dgpu: str + account: str, + permission: str, + key: str | None, + node_url: str, + algos: list[str] ): + dclient = docker.from_env() + vtestnet = get_container( + dclient, + default_nodeos_image(), + force_unique=True, + detach=True, + network='host', + remove=True) + + cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) + trio.run( partial( open_dgpu_node, - cert, - uid, - key_name=key, - rpc_address=rpc, - dgpu_address=dgpu, - initial_algos=json.loads(algos) + account, permission, + cleos, key=key, initial_algos=json.loads(algos) )) + vtestnet.stop() + @run.command() -@click.option('--loglevel', '-l', default='warning', help='Logging level') +@click.option('--loglevel', '-l', default='warning', help='logging level') @click.option( - '--key', '-k', default='telegram-frontend') + '--account', '-a', default='telegram1') @click.option( - '--cert', '-c', default='whitelist/telegram-frontend') + '--permission', '-p', default='active') @click.option( - '--rpc', '-r', default=DEFAULT_RPC_ADDR) + '--key', '-k', default=None) +@click.option( + '--node-url', '-n', default='http://test1.us.telos.net:42000') +@click.option( + '--db-host', '-h', default='localhost:5432') +@click.option( + '--db-user', '-u', default='skynet') +@click.option( + '--db-pass', '-u', default='password') def telegram( loglevel: str, - key: str, - cert: str, - rpc: str + account: str, + permission: str, + key: str | None, + node_url: str, + db_host: str, + db_user: str, + db_pass: str ): _, _, tg_token, cfg = init_env_from_config() - trio_asyncio.run( - partial( - run_skynet_telegram, + asyncio.run( + run_skynet_telegram( tg_token, - key_name=key, - cert_name=cert, - rpc_address=rpc + account, + permission, + node_url, + db_host, db_user, db_pass, + key=key )) diff --git a/skynet/constants.py b/skynet/constants.py index 3d96a2c..b590bcb 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -102,7 +102,7 @@ MAX_WIDTH = 512 MAX_HEIGHT = 656 MAX_GUIDANCE = 20 -DEFAULT_SEED = None +DEFAULT_SEED = 0 DEFAULT_WIDTH = 512 DEFAULT_HEIGHT = 512 DEFAULT_GUIDANCE = 7.5 @@ -114,15 +114,7 @@ DEFAULT_ROLE = 'pleb' DEFAULT_UPSCALER = None DEFAULT_CONFIG_PATH = 'skynet.ini' -DEFAULT_CERTS_DIR = 'certs' -DEFAULT_CERT_WHITELIST_DIR = 'whitelist' -DEFAULT_CERT_SKYNET_PUB = 'brain.cert' -DEFAULT_CERT_SKYNET_PRIV = 'brain.key' -DEFAULT_CERT_DGPU = 'dgpu.key' -DEFAULT_RPC_ADDR = 'tcp://127.0.0.1:41000' - -DEFAULT_DGPU_ADDR = 'tcp://127.0.0.1:41069' DEFAULT_DGPU_MAX_TASKS = 2 DEFAULT_INITAL_ALGOS = ['midj', 'stable', 'ink'] diff --git a/skynet/db/__init__.py b/skynet/db/__init__.py index fd45c9e..ae1dd6b 100644 --- a/skynet/db/__init__.py +++ b/skynet/db/__init__.py @@ -1,5 +1,3 @@ #!/usr/bin/python -from .proxy import open_database_connection - -from .functions import open_new_database +from .functions import open_new_database, open_database_connection diff --git a/skynet/db/functions.py b/skynet/db/functions.py index 10863c2..4cea259 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -4,12 +4,15 @@ import time import random import string import logging +import importlib from typing import Optional from datetime import datetime from contextlib import contextmanager as cm +from contextlib import asynccontextmanager as acm import docker +import asyncpg import psycopg2 from asyncpg.exceptions import UndefinedColumnError @@ -51,7 +54,7 @@ CREATE TABLE IF NOT EXISTS skynet.user_config( step INT NOT NULL, width INT NOT NULL, height INT NOT NULL, - seed BIGINT, + seed BIGINT NOT NULL, guidance REAL NOT NULL, strength REAL NOT NULL, upscaler VARCHAR(128) @@ -79,7 +82,7 @@ def try_decode_uid(uid: str): @cm -def open_new_database(): +def open_new_database(cleanup=True): rpassword = ''.join( random.choice(string.ascii_lowercase) for i in range(12)) @@ -99,46 +102,79 @@ def open_new_database(): detach=True, remove=True ) + try: - for log in container.logs(stream=True): - log = log.decode().rstrip() - logging.info(log) - if ('database system is ready to accept connections' in log or - 'database system is shut down' in log): - break + for log in container.logs(stream=True): + log = log.decode().rstrip() + logging.info(log) + if ('database system is ready to accept connections' in log or + 'database system is shut down' in log): + break - # ip = container.attrs['NetworkSettings']['IPAddress'] - container.reload() - port = container.ports['5432/tcp'][0]['HostPort'] - host = f'localhost:{port}' + # ip = container.attrs['NetworkSettings']['IPAddress'] + container.reload() + port = container.ports['5432/tcp'][0]['HostPort'] + host = f'localhost:{port}' - # why print the system is ready to accept connections when its not - # postgres? wtf - time.sleep(1) - logging.info('creating skynet db...') + # why print the system is ready to accept connections when its not + # postgres? wtf + time.sleep(1) + logging.info('creating skynet db...') - conn = psycopg2.connect( - user='postgres', - password=rpassword, - host='localhost', - port=port - ) - logging.info('connected...') - conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) - with conn.cursor() as cursor: - cursor.execute( - f'CREATE USER skynet WITH PASSWORD \'{password}\'') - cursor.execute( - f'CREATE DATABASE skynet') - cursor.execute( - f'GRANT ALL PRIVILEGES ON DATABASE skynet TO skynet') + conn = psycopg2.connect( + user='postgres', + password=rpassword, + host='localhost', + port=port + ) + logging.info('connected...') + conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) + with conn.cursor() as cursor: + cursor.execute( + f'CREATE USER skynet WITH PASSWORD \'{password}\'') + cursor.execute( + f'CREATE DATABASE skynet') + cursor.execute( + f'GRANT ALL PRIVILEGES ON DATABASE skynet TO skynet') - conn.close() + conn.close() - logging.info('done.') - yield container, password, host + logging.info('done.') + yield container, password, host - container.stop() + finally: + if container and cleanup: + container.stop() + +@acm +async def open_database_connection( + db_user: str = 'skynet', + db_pass: str = 'password', + db_host: str = 'localhost:5432', + db_name: str = 'skynet' +): + db = importlib.import_module('skynet.db.functions') + pool = await asyncpg.create_pool( + dsn=f'postgres://{db_user}:{db_pass}@{db_host}/{db_name}') + + async with pool.acquire() as conn: + res = await conn.execute(f''' + select distinct table_schema + from information_schema.tables + where table_schema = \'{db_name}\' + ''') + if '1' in res: + logging.info('schema already in db, skipping init') + else: + await conn.execute(DB_INIT_SQL) + + async def _db_call(method: str, *args, **kwargs): + method = getattr(db, method) + + async with pool.acquire() as conn: + return await method(conn, *args, **kwargs) + + yield _db_call async def get_user(conn, uid: str): diff --git a/skynet/db/proxy.py b/skynet/db/proxy.py deleted file mode 100644 index d2f86c1..0000000 --- a/skynet/db/proxy.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/python - -import importlib - -from contextlib import asynccontextmanager as acm - -import trio -import tractor -import asyncpg -import asyncio -import trio_asyncio - - -_spawn_kwargs = { - 'infect_asyncio': True, -} - - -async def aio_db_proxy( - to_trio: trio.MemorySendChannel, - from_trio: asyncio.Queue, - db_user: str = 'skynet', - db_pass: str = 'password', - db_host: str = 'localhost:5432', - db_name: str = 'skynet' -) -> None: - db = importlib.import_module('skynet.db.functions') - - pool = await asyncpg.create_pool( - dsn=f'postgres://{db_user}:{db_pass}@{db_host}/{db_name}') - - async with pool_conn.acquire() as conn: - res = await conn.execute(f''' - select distinct table_schema - from information_schema.tables - where table_schema = \'{db_name}\' - ''') - if '1' in res: - logging.info('schema already in db, skipping init') - else: - await conn.execute(DB_INIT_SQL) - - # a first message must be sent **from** this ``asyncio`` - # task or the ``trio`` side will never unblock from - # ``tractor.to_asyncio.open_channel_from():`` - to_trio.send_nowait('start') - - # XXX: this uses an ``from_trio: asyncio.Queue`` currently but we - # should probably offer something better. - while True: - msg = await from_trio.get() - - method = getattr(db, msg.get('method')) - args = getattr(db, msg.get('args', [])) - kwargs = getattr(db, msg.get('kwargs', {})) - - async with pool_conn.acquire() as conn: - result = await method(conn, *args, **kwargs) - to_trio.send_nowait(result) - - -@tractor.context -async def trio_to_aio_db_proxy( - ctx: tractor.Context, - db_user: str = 'skynet', - db_pass: str = 'password', - db_host: str = 'localhost:5432', - db_name: str = 'skynet' -): - # this will block until the ``asyncio`` task sends a "first" - # message. - async with tractor.to_asyncio.open_channel_from( - aio_db_proxy, - db_user=db_user, - db_pass=db_pass, - db_host=db_host, - db_name=db_name - ) as (first, chan): - - assert first == 'start' - await ctx.started(first) - - async with ctx.open_stream() as stream: - - async for msg in stream: - await chan.send(msg) - - out = await chan.receive() - # echo back to parent actor-task - await stream.send(out) - - -@acm -async def open_database_connection( - db_user: str = 'skynet', - db_pass: str = 'password', - db_host: str = 'localhost:5432', - db_name: str = 'skynet' -): - async with tractor.open_nursery() as n: - p = await n.start_actor( - 'aio_db_proxy', - enable_modules=[__name__], - infect_asyncio=True, - ) - async with p.open_context( - trio_to_aio_db_proxy, - db_user=db_user, - db_pass=db_pass, - db_host=db_host, - db_name=db_name - ) as (ctx, first): - async with ctx.open_stream() as stream: - - async def _db_pc(method: str, *args, **kwargs): - await stream.send({ - 'method': method, - 'args': args, - 'kwargs': kwargs - }) - return await stream.receive() - - yield _db_pc diff --git a/skynet/dgpu.py b/skynet/dgpu.py index 79c6c49..01a5310 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -3,16 +3,21 @@ import gc import io import json +import time import random import logging from PIL import Image from typing import List, Optional +from hashlib import sha256 import trio +import asks import torch -from pynng import Context +from leap.cleos import CLEOS, default_nodeos_image +from leap.sugar import get_container + from diffusers import ( StableDiffusionPipeline, StableDiffusionImg2ImgPipeline, @@ -22,9 +27,8 @@ from realesrgan import RealESRGANer from basicsr.archs.rrdbnet_arch import RRDBNet from diffusers.models import UNet2DConditionModel +from .ipfs import IPFSDocker, open_ipfs_node from .utils import * -from .network import * -from .protobuf import * from .constants import * @@ -50,15 +54,20 @@ class DGPUComputeError(BaseException): async def open_dgpu_node( - cert_name: str, - unique_id: str, - key_name: Optional[str], - rpc_address: str = DEFAULT_RPC_ADDR, - dgpu_address: str = DEFAULT_DGPU_ADDR, + account: str, + permission: str, + cleos: CLEOS, + key: str = None, initial_algos: Optional[List[str]] = None ): - logging.basicConfig(level=logging.DEBUG) + + logging.basicConfig(level=logging.INFO) logging.info(f'starting dgpu node!') + logging.info(f'launching toolchain container!') + + if key: + cleos.setup_wallet(key) + logging.info(f'loading models...') upscaler = init_upscaler() @@ -77,7 +86,7 @@ async def open_dgpu_node( logging.info('memory summary:') logging.info('\n' + torch.cuda.memory_summary()) - async def gpu_compute_one(method: str, params: dict, binext: Optional[bytes] = None): + def gpu_compute_one(method: str, params: dict, binext: Optional[bytes] = None): match method: case 'diffuse': image = None @@ -126,9 +135,7 @@ async def open_dgpu_node( **_params, guidance_scale=params['guidance'], num_inference_steps=int(params['step']), - generator=torch.Generator("cuda").manual_seed( - int(params['seed']) if params['seed'] else random.randint(0, 2 ** 64) - ) + generator=torch.manual_seed(int(params['seed'])) ).images[0] if params['upscaler'] == 'x4': @@ -144,9 +151,12 @@ async def open_dgpu_node( img_byte_arr = io.BytesIO() image.save(img_byte_arr, format='PNG') raw_img = img_byte_arr.getvalue() + img_sha = sha256(raw_img).hexdigest() logging.info(f'final img size {len(raw_img)} bytes.') - return raw_img + logging.info(params) + + return img_sha, raw_img except BaseException as e: logging.error(e) @@ -158,59 +168,99 @@ async def open_dgpu_node( case _: raise DGPUComputeError('Unsupported compute method') - async def rpc_handler(req: SkynetRPCRequest, ctx: Context): - result = {} - resp = SkynetRPCResponse() + async def get_work_requests_last_hour(): + return await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'queue', + index_position=2, + key_type='i64', + lower_bound=int(time.time()) - 3600 + ) - match req.method: - case 'dgpu_time': - result = {'ok': time_ms()} + async def get_status_by_request_id(request_id: int): + return await cleos.aget_table( + 'telos.gpu', request_id, 'status') - case _: - logging.debug(f'dgpu got one request: {req.method}') - try: - resp.bin = await gpu_compute_one( - req.method, MessageToDict(req.params), - binext=req.bin if req.bin else None - ) - logging.debug(f'dgpu processed one request') + def begin_work(request_id: int): + ec, out = cleos.push_action( + 'telos.gpu', + 'workbegin', + [account, request_id], + f'{account}@{permission}' + ) + assert ec == 0 - except DGPUComputeError as e: - result = {'error': str(e)} + async def find_my_results(): + return await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'results', + index_position=4, + key_type='name', + lower_bound=account, + upper_bound=account + ) - resp.result.update(result) - return resp + ipfs_node = None + def publish_on_ipfs(img_sha: str, raw_img: bytes): + img = Image.open(io.BytesIO(raw_img)) + img.save(f'tmp/ipfs-docker-staging/image.png') - rpc_server = SessionServer( - dgpu_address, - rpc_handler, - cert_name=cert_name, - key_name=key_name - ) - skynet_rpc = SessionClient( - rpc_address, - unique_id, - cert_name=cert_name, - key_name=key_name - ) - skynet_rpc.connect() + ipfs_hash = ipfs_node.add('image.png') + ipfs_node.pin(ipfs_hash) - async with rpc_server.open() as rpc_server: - res = await skynet_rpc.rpc( - 'dgpu_online', { - 'dgpu_addr': rpc_server.addr, - 'cert': cert_name - }) + return ipfs_hash - assert 'ok' in res.result + def submit_work(request_id: int, result_hash: str, ipfs_hash: str): + ec, out = cleos.push_action( + 'telos.gpu', + 'submit', + [account, request_id, result_hash, ipfs_hash], + f'{account}@{permission}' + ) + assert ec == 0 + with open_ipfs_node() as ipfs_node: try: - await trio.sleep_forever() + while True: + queue = await get_work_requests_last_hour() + + for req in queue: + rid = req['id'] + + my_results = [res['id'] for res in (await find_my_results())] + if rid in my_results: + continue + + statuses = await get_status_by_request_id(rid) + + if len(statuses) < 3: + + # parse request + body = json.loads(req['body']) + binary = bytes.fromhex(req['binary_data']) + + # TODO: validate request + + # perform work + logging.info(f'working on {body}') + + begin_work(rid) + img_sha, raw_img = gpu_compute_one( + body['method'], body['params'], binext=binary) + + ipfs_hash = publish_on_ipfs(img_sha, raw_img) + + submit_work(rid, img_sha, ipfs_hash) + + break + + else: + logging.info(f'request {rid} already beign worked on, skip...') + continue + + await trio.sleep(1) except KeyboardInterrupt: - logging.info('interrupt caught, stopping...') + ... + + - finally: - res = await skynet_rpc.rpc('dgpu_offline') - assert 'ok' in res.result diff --git a/skynet/frontend/__init__.py b/skynet/frontend/__init__.py index 04d6b90..19ea716 100644 --- a/skynet/frontend/__init__.py +++ b/skynet/frontend/__init__.py @@ -6,23 +6,8 @@ from typing import Union, Optional from pathlib import Path from contextlib import contextmanager as cm -import pynng - -from pynng import TLSConfig -from OpenSSL.crypto import ( - load_privatekey, - load_certificate, - FILETYPE_PEM -) - -from google.protobuf.struct_pb2 import Struct - -from ..network import SessionClient from ..constants import * -from ..protobuf.auth import * -from ..protobuf.skynet_pb2 import SkynetRPCRequest, SkynetRPCResponse - class ConfigRequestFormatError(BaseException): ... @@ -40,24 +25,6 @@ class ConfigSizeDivisionByEight(BaseException): ... -@cm -def open_skynet_rpc( - unique_id: str, - rpc_address: str = DEFAULT_RPC_ADDR, - cert_name: Optional[str] = None, - key_name: Optional[str] = None -): - sesh = SessionClient( - rpc_address, - unique_id, - cert_name=cert_name, - key_name=key_name - ) - logging.debug(f'opening skynet rpc...') - sesh.connect() - yield sesh - sesh.disconnect() - def validate_user_config_request(req: str): params = req.split(' ') diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 65a6fcb..2f47433 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -3,18 +3,22 @@ import io import zlib import logging +import asyncio from datetime import datetime -from PIL import Image -from trio_asyncio import aio_as_trio +import docker +from PIL import Image +from leap.cleos import CLEOS, default_nodeos_image +from leap.sugar import get_container, collect_stdout +from trio_asyncio import aio_as_trio from telebot.types import ( InputFile, InputMediaPhoto, InlineKeyboardButton, InlineKeyboardMarkup ) from telebot.async_telebot import AsyncTeleBot -from ..db import open_database_connection +from ..db import open_new_database, open_database_connection from ..constants import * from . import * @@ -55,283 +59,340 @@ def prepare_metainfo_caption(tguser, meta: dict) -> str: async def run_skynet_telegram( - name: str, tg_token: str, - key_name: str = 'telegram-frontend.key', - cert_name: str = 'whitelist/telegram-frontend.cert', - rpc_address: str = DEFAULT_RPC_ADDR, - db_host: str = 'localhost:5432', - db_user: str = 'skynet', - db_pass: str = 'password' + account: str, + permission: str, + node_url: str, + db_host: str, + db_user: str, + db_pass: str, + key: str = None ): + dclient = docker.from_env() + vtestnet = get_container( + dclient, + default_nodeos_image(), + force_unique=True, + detach=True, + network='host', + remove=True) + + cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) logging.basicConfig(level=logging.INFO) + + if key: + cleos.setup_wallet(key) + bot = AsyncTeleBot(tg_token) logging.info(f'tg_token: {tg_token}') async with open_database_connection( db_user, db_pass, db_host ) as db_call: - with open_skynet_rpc( - f'skynet-telegram-{name}', - rpc_address=rpc_address, - cert_name=cert_name, - key_name=key_name - ) as session: - @bot.message_handler(commands=['help']) - async def send_help(message): - splt_msg = message.text.split(' ') + @bot.message_handler(commands=['help']) + async def send_help(message): + splt_msg = message.text.split(' ') - if len(splt_msg) == 1: - await bot.reply_to(message, HELP_TEXT) + if len(splt_msg) == 1: + await bot.reply_to(message, HELP_TEXT) + + else: + param = splt_msg[1] + if param in HELP_TOPICS: + await bot.reply_to(message, HELP_TOPICS[param]) else: - param = splt_msg[1] - if param in HELP_TOPICS: - await bot.reply_to(message, HELP_TOPICS[param]) + await bot.reply_to(message, HELP_UNKWNOWN_PARAM) - else: - await bot.reply_to(message, HELP_UNKWNOWN_PARAM) + @bot.message_handler(commands=['cool']) + async def send_cool_words(message): + await bot.reply_to(message, '\n'.join(COOL_WORDS)) - @bot.message_handler(commands=['cool']) - async def send_cool_words(message): - await bot.reply_to(message, '\n'.join(COOL_WORDS)) + @bot.message_handler(commands=['txt2img']) + async def send_txt2img(message): + chat = message.chat + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id - @bot.message_handler(commands=['txt2img']) - async def send_txt2img(message): - chat = message.chat - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id + user_id = f'tg+{message.from_user.id}' - user_id = f'tg+{message.from_user.id}' + prompt = ' '.join(message.text.split(' ')[1:]) - prompt = ' '.join(message.text.split(' ')[1:]) + if len(prompt) == 0: + await bot.reply_to(message, 'Empty text prompt ignored.') + return - if len(prompt) == 0: - await bot.reply_to(message, 'Empty text prompt ignored.') - return + logging.info(f'mid: {message.id}') + user = await db_call('get_or_create_user', user_id) + user_config = {**(await db_call('get_user_config', user))} + del user_config['id'] - logging.info(f'mid: {message.id}') - user = await db_call('get_or_create_user', user_id) - user_config = {**(await db_call('get_user_config', user))} - del user_config['id'] + req = json.dumps({ + 'method': 'diffuse', + 'params': { + 'prompt': prompt, + **user_config + } + }) - resp = await session.rpc( - 'dgpu_call', { - 'method': 'diffuse', - 'params': { - 'prompt': prompt, - **user_config - } - }, - timeout=60 + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [account, req, ''], f'{account}@{permission}' + ) + out = collect_stdout(out) + if ec != 0: + await bot.reply_to(message, out) + return + + request_id = int(out) + logging.info(f'{request_id} enqueued.') + + ipfs_hash = None + sha_hash = None + for i in range(60): + results = cleos.get_table( + 'telos.gpu', 'telos.gpu', 'results', + index_position=2, + key_type='i64', + lower_bound=request_id, + upper_bound=request_id ) - logging.info(f'resp to {message.id} arrived') - - resp_txt = '' - result = MessageToDict(resp.result) - if 'error' in resp.result: - resp_txt = resp.result['message'] - await bot.reply_to(message, resp_txt) - + if len(results) > 0: + ipfs_hash = results[0]['ipfs_hash'] + sha_hash = results[0]['result_hash'] + break else: - logging.info(result['id']) - img_raw = resp.bin - logging.info(f'got image of size: {len(img_raw)}') - img = Image.open(io.BytesIO(img_raw)) + await asyncio.sleep(1) - await bot.send_photo( - GROUP_ID, - caption=prepare_metainfo_caption(message.from_user, result['meta']['meta']), - photo=img, - reply_to_message_id=reply_id, - reply_markup=build_redo_menu() - ) - return + if not ipfs_hash: + await bot.reply_to(message, 'timeout processing request') + return + ipfs_link = f'https://ipfs.io/ipfs/{ipfs_hash}/image.png' - @bot.message_handler(func=lambda message: True, content_types=['photo']) - async def send_img2img(message): - chat = message.chat - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id + await bot.reply_to( + message, + ipfs_link, + reply_markup=build_redo_menu() + ) - user_id = f'tg+{message.from_user.id}' + @bot.message_handler(func=lambda message: True, content_types=['photo']) + async def send_img2img(message): + chat = message.chat + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id - if not message.caption.startswith('/img2img'): - await bot.reply_to( - message, - 'For image to image you need to add /img2img to the beggining of your caption' - ) - return + user_id = f'tg+{message.from_user.id}' - prompt = ' '.join(message.caption.split(' ')[1:]) - - if len(prompt) == 0: - await bot.reply_to(message, 'Empty text prompt ignored.') - return - - file_id = message.photo[-1].file_id - file_path = (await bot.get_file(file_id)).file_path - file_raw = await bot.download_file(file_path) - - logging.info(f'mid: {message.id}') - - user = await db_call('get_or_create_user', user_id) - user_config = {**(await db_call('get_user_config', user))} - del user_config['id'] - - resp = await session.rpc( - 'dgpu_call', { - 'method': 'diffuse', - 'params': { - 'prompt': prompt, - **user_config - } - }, - binext=file_raw, - timeout=60 - ) - logging.info(f'resp to {message.id} arrived') - - resp_txt = '' - result = MessageToDict(resp.result) - if 'error' in resp.result: - resp_txt = resp.result['message'] - await bot.reply_to(message, resp_txt) - - else: - logging.info(result['id']) - img_raw = resp.bin - logging.info(f'got image of size: {len(img_raw)}') - img = Image.open(io.BytesIO(img_raw)) - - await bot.send_media_group( - GROUP_ID, - media=[ - InputMediaPhoto(file_id), - InputMediaPhoto( - img, - caption=prepare_metainfo_caption(message.from_user, result['meta']['meta']) - ) - ], - reply_to_message_id=reply_id - ) - return - - - @bot.message_handler(commands=['img2img']) - async def img2img_missing_image(message): + if not message.caption.startswith('/img2img'): await bot.reply_to( message, - 'seems you tried to do an img2img command without sending image' + 'For image to image you need to add /img2img to the beggining of your caption' ) + return - @bot.message_handler(commands=['redo']) - async def redo(message): - chat = message.chat - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id + prompt = ' '.join(message.caption.split(' ')[1:]) - user_config = {**(await db_call('get_user_config', user))} - del user_config['id'] - prompt = await db_call('get_last_prompt_of', user) + if len(prompt) == 0: + await bot.reply_to(message, 'Empty text prompt ignored.') + return - resp = await session.rpc( - 'dgpu_call', { - 'method': 'diffuse', - 'params': { - 'prompt': prompt, - **user_config - } - }, - timeout=60 + file_id = message.photo[-1].file_id + file_path = (await bot.get_file(file_id)).file_path + file_raw = await bot.download_file(file_path) + + logging.info(f'mid: {message.id}') + + user = await db_call('get_or_create_user', user_id) + user_config = {**(await db_call('get_user_config', user))} + del user_config['id'] + + req = json.dumps({ + 'method': 'diffuse', + 'params': { + 'prompt': prompt, + **user_config + } + }) + + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [account, req, file_raw.hex()], f'{account}@{permission}' + ) + if ec != 0: + await bot.reply_to(message, out) + return + + request_id = int(out) + logging.info(f'{request_id} enqueued.') + + ipfs_hash = None + sha_hash = None + for i in range(60): + result = cleos.get_table( + 'telos.gpu', 'telos.gpu', 'results', + index_position=2, + key_type='i64', + lower_bound=request_id, + upper_bound=request_id ) - logging.info(f'resp to {message.id} arrived') - - resp_txt = '' - result = MessageToDict(resp.result) - if 'error' in resp.result: - resp_txt = resp.result['message'] - await bot.reply_to(message, resp_txt) - + if len(results) > 0: + ipfs_hash = result[0]['ipfs_hash'] + sha_hash = result[0]['result_hash'] + break else: - logging.info(result['id']) - img_raw = resp.bin - logging.info(f'got image of size: {len(img_raw)}') - img = Image.open(io.BytesIO(img_raw)) + await asyncio.sleep(1) - await bot.send_photo( - GROUP_ID, - caption=prepare_metainfo_caption(message.from_user, result['meta']['meta']), - photo=img, - reply_to_message_id=reply_id - ) - return + if not ipfs_hash: + await bot.reply_to(message, 'timeout processing request') - @bot.message_handler(commands=['config']) - async def set_config(message): - rpc_params = {} - try: - attr, val, reply_txt = validate_user_config_request( - message.text) + ipfs_link = f'https://ipfs.io/ipfs/{ipfs_hash}/image.png' - logging.info(f'user config update: {attr} to {val}') - await db_call('update_user_config', - user, req.params['attr'], req.params['val']) - logging.info('done') - - except BaseException as e: - reply_txt = str(e) - - finally: - await bot.reply_to(message, reply_txt) - - @bot.message_handler(commands=['stats']) - async def user_stats(message): - - generated, joined, role = await db_call('get_user_stats', user) - - stats_str = f'generated: {generated}\n' - stats_str += f'joined: {joined}\n' - stats_str += f'role: {role}\n' - - await bot.reply_to( - message, stats_str) - - @bot.message_handler(commands=['donate']) - async def donation_info(message): - await bot.reply_to( - message, DONATION_INFO) - - @bot.message_handler(commands=['say']) - async def say(message): - chat = message.chat - user = message.from_user - - if (chat.type == 'group') or (user.id != 383385940): - return - - await bot.send_message(GROUP_ID, message.text[4:]) + await bot.reply_to( + message, + ipfs_link + '\n' + + prepare_metainfo_caption(message.from_user, result['meta']['meta']), + reply_to_message_id=reply_id, + reply_markup=build_redo_menu() + ) + return - @bot.message_handler(func=lambda message: True) - async def echo_message(message): - if message.text[0] == '/': - await bot.reply_to(message, UNKNOWN_CMD_TEXT) + @bot.message_handler(commands=['img2img']) + async def img2img_missing_image(message): + await bot.reply_to( + message, + 'seems you tried to do an img2img command without sending image' + ) - @bot.callback_query_handler(func=lambda call: True) - async def callback_query(call): - msg = json.loads(call.data) - logging.info(call.data) - method = msg.get('method') - match method: - case 'redo': - await _redo(call) + @bot.message_handler(commands=['redo']) + async def redo(message): + chat = message.chat + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id + + user_config = {**(await db_call('get_user_config', user))} + del user_config['id'] + prompt = await db_call('get_last_prompt_of', user) + + req = json.dumps({ + 'method': 'diffuse', + 'params': { + 'prompt': prompt, + **user_config + } + }) + + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [account, req, ''], f'{account}@{permission}' + ) + if ec != 0: + await bot.reply_to(message, out) + return + + request_id = int(out) + logging.info(f'{request_id} enqueued.') + + ipfs_hash = None + sha_hash = None + for i in range(60): + result = cleos.get_table( + 'telos.gpu', 'telos.gpu', 'results', + index_position=2, + key_type='i64', + lower_bound=request_id, + upper_bound=request_id + ) + if len(results) > 0: + ipfs_hash = result[0]['ipfs_hash'] + sha_hash = result[0]['result_hash'] + break + else: + await asyncio.sleep(1) + + if not ipfs_hash: + await bot.reply_to(message, 'timeout processing request') + + ipfs_link = f'https://ipfs.io/ipfs/{ipfs_hash}/image.png' + + await bot.reply_to( + message, + ipfs_link + '\n' + + prepare_metainfo_caption(message.from_user, result['meta']['meta']), + reply_to_message_id=reply_id, + reply_markup=build_redo_menu() + ) + return + + @bot.message_handler(commands=['config']) + async def set_config(message): + rpc_params = {} + try: + attr, val, reply_txt = validate_user_config_request( + message.text) + + logging.info(f'user config update: {attr} to {val}') + await db_call('update_user_config', + user, req.params['attr'], req.params['val']) + logging.info('done') + + except BaseException as e: + reply_txt = str(e) + + finally: + await bot.reply_to(message, reply_txt) + + @bot.message_handler(commands=['stats']) + async def user_stats(message): + user = message.from_user.id + + generated, joined, role = await db_call('get_user_stats', user) + + stats_str = f'generated: {generated}\n' + stats_str += f'joined: {joined}\n' + stats_str += f'role: {role}\n' + + await bot.reply_to( + message, stats_str) + + @bot.message_handler(commands=['donate']) + async def donation_info(message): + await bot.reply_to( + message, DONATION_INFO) + + @bot.message_handler(commands=['say']) + async def say(message): + chat = message.chat + user = message.from_user + + if (chat.type == 'group') or (user.id != 383385940): + return + + await bot.send_message(GROUP_ID, message.text[4:]) - await aio_as_trio(bot.infinity_polling)() + @bot.message_handler(func=lambda message: True) + async def echo_message(message): + if message.text[0] == '/': + await bot.reply_to(message, UNKNOWN_CMD_TEXT) + + @bot.callback_query_handler(func=lambda call: True) + async def callback_query(call): + msg = json.loads(call.data) + logging.info(call.data) + method = msg.get('method') + match method: + case 'redo': + await _redo(call) + + try: + await bot.infinity_polling() + + except KeyboardInterrupt: + ... + + finally: + vtestnet.stop() diff --git a/skynet/ipfs.py b/skynet/ipfs.py new file mode 100644 index 0000000..acd63c9 --- /dev/null +++ b/skynet/ipfs.py @@ -0,0 +1,62 @@ +#!/usr/bin/python + +import logging + +from pathlib import Path +from contextlib import contextmanager as cm + +import docker + +from docker.models.containers import Container + + +class IPFSDocker: + + def __init__(self, container: Container): + self._container = container + + def add(self, file: str) -> str: + ec, out = self._container.exec_run( + ['ipfs', 'add', '-w', f'/export/{file}', '-Q']) + assert ec == 0 + + return out.decode().rstrip() + + def pin(self, ipfs_hash: str): + ec, out = self._container.exec_run( + ['ipfs', 'pin', 'add', ipfs_hash]) + assert ec == 0 + +@cm +def open_ipfs_node(): + dclient = docker.from_env() + + container = dclient.containers.run( + 'ipfs/go-ipfs:latest', + name='skynet-ipfs', + ports={ + '8080/tcp': 8080, + '4001/tcp': 4001, + '5001/tcp': ('127.0.0.1', 5001) + }, + volumes=[ + str(Path().resolve() / 'tmp/ipfs-docker-staging') + ':/export', + str(Path().resolve() / 'tmp/ipfs-docker-data') + ':/data/ipfs' + ], + detach=True, + remove=True + ) + try: + + for log in container.logs(stream=True): + log = log.decode().rstrip() + logging.info(log) + if 'Daemon is ready' in log: + break + + yield IPFSDocker(container) + + finally: + if container: + container.stop() + diff --git a/skynet/models.py b/skynet/models.py deleted file mode 100644 index b95bf40..0000000 --- a/skynet/models.py +++ /dev/null @@ -1,33 +0,0 @@ - - -class ModelStore: - - def __init__( - self, - max_models: int = 2 - ): - self.max_models = max_models - - self._models = {} - - def get(self, model_name: str): - if model_name in self._models: - return self._models[model_name]['pipe'] - - if len(self._models) == max_models: - least_used = list(self._models.keys())[0] - for model in self._models: - if self._models[least_used]['generated'] > self._models[model]['generated']: - least_used = model - - del self._models[least_used] - gc.collect() - - pipe = pipeline_for(model_name) - - self._models[model_name] = { - 'pipe': pipe, - 'generated': 0 - } - - return pipe diff --git a/skynet/network.py b/skynet/network.py deleted file mode 100644 index 95fb60f..0000000 --- a/skynet/network.py +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/python - -import zlib -import socket - -from typing import Callable, Awaitable, Optional -from pathlib import Path -from contextlib import asynccontextmanager as acm -from cryptography import x509 -from cryptography.hazmat.primitives import serialization - -import trio -import pynng - -from pynng import TLSConfig, Context - -from .protobuf import * -from .constants import * - - -def get_random_port(): - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind(('', 0)) - return s.getsockname()[1] - - -def load_certs( - certs_dir: str, - cert_name: str, - key_name: str -): - certs_dir = Path(certs_dir).resolve() - tls_key_data = (certs_dir / key_name).read_bytes() - tls_key = serialization.load_pem_private_key( - tls_key_data, - password=None - ) - - tls_cert_data = (certs_dir / cert_name).read_bytes() - tls_cert = x509.load_pem_x509_certificate( - tls_cert_data - ) - - tls_whitelist = {} - for cert_path in (*(certs_dir / 'whitelist').glob('*.cert'), certs_dir / 'brain.cert'): - tls_whitelist[cert_path.stem] = x509.load_pem_x509_certificate( - cert_path.read_bytes() - ) - - return ( - SessionTLSConfig( - TLSConfig.MODE_SERVER, - own_key_string=tls_key_data, - own_cert_string=tls_cert_data - ), - - tls_whitelist - ) - - -def load_certs_client( - certs_dir: str, - cert_name: str, - key_name: str, - ca_name: Optional[str] = None -): - certs_dir = Path(certs_dir).resolve() - if not ca_name: - ca_name = 'brain.cert' - - ca_cert_data = (certs_dir / ca_name).read_bytes() - - tls_key_data = (certs_dir / key_name).read_bytes() - - - tls_cert_data = (certs_dir / cert_name).read_bytes() - - - tls_whitelist = {} - for cert_path in (*(certs_dir / 'whitelist').glob('*.cert'), certs_dir / 'brain.cert'): - tls_whitelist[cert_path.stem] = x509.load_pem_x509_certificate( - cert_path.read_bytes() - ) - - return ( - SessionTLSConfig( - TLSConfig.MODE_CLIENT, - own_key_string=tls_key_data, - own_cert_string=tls_cert_data, - ca_string=ca_cert_data - ), - - tls_whitelist - ) - - -class SessionError(BaseException): - ... - - -class SessionTLSConfig(TLSConfig): - - def __init__( - self, - mode, - server_name=None, - ca_string=None, - own_key_string=None, - own_cert_string=None, - auth_mode=None, - ca_files=None, - cert_key_file=None, - passwd=None - ): - super().__init__( - mode, - server_name=server_name, - ca_string=ca_string, - own_key_string=own_key_string, - own_cert_string=own_cert_string, - auth_mode=auth_mode, - ca_files=ca_files, - cert_key_file=cert_key_file, - passwd=passwd - ) - - if ca_string: - self.ca_cert = x509.load_pem_x509_certificate(ca_string) - - self.cert = x509.load_pem_x509_certificate(own_cert_string) - self.key = serialization.load_pem_private_key( - own_key_string, - password=passwd - ) - - -class SessionServer: - - def __init__( - self, - addr: str, - msg_handler: Callable[ - [SkynetRPCRequest, Context], Awaitable[SkynetRPCResponse] - ], - cert_name: Optional[str] = None, - key_name: Optional[str] = None, - cert_dir: str = DEFAULT_CERTS_DIR, - recv_max_size = 0 - ): - self.addr = addr - self.msg_handler = msg_handler - - self.cert_name = cert_name - self.tls_config = None - self.tls_whitelist = None - if cert_name and key_name: - self.cert_name = cert_name - self.tls_config, self.tls_whitelist = load_certs( - cert_dir, cert_name, key_name) - - self.addr = 'tls+' + self.addr - - self.recv_max_size = recv_max_size - - async def _handle_msg(self, req: SkynetRPCRequest, ctx: Context): - resp = await self.msg_handler(req, ctx) - - if self.tls_config: - resp.auth.cert = 'skynet' - resp.auth.sig = sign_protobuf_msg( - resp, self.tls_config.key) - - raw_msg = zlib.compress(resp.SerializeToString()) - - await ctx.asend(raw_msg) - - ctx.close() - - async def _listener (self, sock): - async with trio.open_nursery() as n: - while True: - ctx = sock.new_context() - - raw_msg = await ctx.arecv() - raw_size = len(raw_msg) - logging.debug(f'rpc server new msg {raw_size} bytes') - - try: - msg = zlib.decompress(raw_msg) - msg_size = len(msg) - - except zlib.error: - logging.warning(f'Zlib decompress error, dropping msg of size {len(raw_msg)}') - continue - - logging.debug(f'msg after decompress {msg_size} bytes, +{msg_size - raw_size} bytes') - - req = SkynetRPCRequest() - try: - req.ParseFromString(msg) - - except google.protobuf.message.DecodeError: - logging.warning(f'Dropping malfomed msg of size {len(msg)}') - continue - - logging.debug(f'msg method: {req.method}') - - if self.tls_config: - if req.auth.cert not in self.tls_whitelist: - logging.warning( - f'{req.auth.cert} not in tls whitelist') - continue - - try: - verify_protobuf_msg(req, self.tls_whitelist[req.auth.cert]) - - except ValueError: - logging.warning( - f'{req.cert} sent an unauthenticated msg') - continue - - n.start_soon(self._handle_msg, req, ctx) - - @acm - async def open(self): - with pynng.Rep0( - recv_max_size=self.recv_max_size - ) as sock: - - if self.tls_config: - sock.tls_config = self.tls_config - - sock.listen(self.addr) - - logging.debug(f'server socket listening at {self.addr}') - - async with trio.open_nursery() as n: - n.start_soon(self._listener, sock) - - try: - yield self - - finally: - n.cancel_scope.cancel() - - logging.debug('server socket is off.') - - -class SessionClient: - - def __init__( - self, - connect_addr: str, - uid: str, - cert_name: Optional[str] = None, - key_name: Optional[str] = None, - ca_name: Optional[str] = None, - cert_dir: str = DEFAULT_CERTS_DIR, - recv_max_size = 0 - ): - self.uid = uid - self.connect_addr = connect_addr - - self.cert_name = None - self.tls_config = None - self.tls_whitelist = None - self.tls_cert = None - self.tls_key = None - if cert_name and key_name: - self.cert_name = Path(cert_name).stem - self.tls_config, self.tls_whitelist = load_certs_client( - cert_dir, cert_name, key_name, ca_name=ca_name) - - if not self.connect_addr.startswith('tls'): - self.connect_addr = 'tls+' + self.connect_addr - - self.recv_max_size = recv_max_size - - self._connected = False - self._sock = None - - def connect(self): - self._sock = pynng.Req0( - recv_max_size=0, - name=self.uid - ) - - if self.tls_config: - self._sock.tls_config = self.tls_config - - logging.debug(f'client is dialing {self.connect_addr}...') - self._sock.dial(self.connect_addr, block=True) - self._connected = True - logging.debug(f'client is connected to {self.connect_addr}') - - def disconnect(self): - self._sock.close() - self._connected = False - logging.debug(f'client disconnected.') - - async def rpc( - self, - method: str, - params: dict = {}, - binext: Optional[bytes] = None, - timeout: float = 2. - ): - if not self._connected: - raise SessionError('tried to use rpc without connecting') - - req = SkynetRPCRequest() - req.uid = self.uid - req.method = method - req.params.update(params) - if binext: - logging.debug('added binary extension') - req.bin = binext - - if self.tls_config: - req.auth.cert = self.cert_name - req.auth.sig = sign_protobuf_msg(req, self.tls_config.key) - - with trio.fail_after(timeout): - ctx = self._sock.new_context() - raw_req = zlib.compress(req.SerializeToString()) - logging.debug(f'rpc client sending new msg {method} of size {len(raw_req)}') - await ctx.asend(raw_req) - logging.debug('sent, awaiting response...') - raw_resp = await ctx.arecv() - logging.debug(f'rpc client got response of size {len(raw_resp)}') - raw_resp = zlib.decompress(raw_resp) - - resp = SkynetRPCResponse() - resp.ParseFromString(raw_resp) - ctx.close() - - if self.tls_config: - verify_protobuf_msg(resp, self.tls_config.ca_cert) - - return resp diff --git a/skynet/nodeos.py b/skynet/nodeos.py new file mode 100644 index 0000000..39f0672 --- /dev/null +++ b/skynet/nodeos.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 + +import time +import logging + +from contextlib import contextmanager as cm + +import docker + +from leap.cleos import CLEOS +from leap.sugar import get_container + + +@cm +def open_nodeos(cleanup: bool = True): + dclient = docker.from_env() + vtestnet = get_container( + dclient, + 'guilledk/py-eosio:leap-skynet-4.0.0', + force_unique=True, + detach=True, + network='host') + + try: + cleos = CLEOS( + dclient, vtestnet, + url='http://127.0.0.1:42000', + remote='http://127.0.0.1:42000' + ) + + cleos.start_keosd() + + cleos.start_nodeos_from_config( + '/root/nodeos/config.ini', + data_dir='/root/nodeos/data', + genesis='/root/nodeos/genesis/skynet.json', + state_plugin=True) + + time.sleep(0.5) + + cleos.setup_wallet('5JnvSc6pewpHHuUHwvbJopsew6AKwiGnexwDRc2Pj2tbdw6iML9') + cleos.wait_blocks(1) + cleos.boot_sequence() + + cleos.new_account('telos.gpu', ram=300000) + + for i in range(1, 4): + cleos.create_account_staked( + 'eosio', f'testworker{i}', + key='EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1') + + cleos.create_account_staked( + 'eosio', 'telegram1', ram=500000, + key='EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1') + + cleos.deploy_contract_from_host( + 'telos.gpu', + 'tests/contracts/telos.gpu', + verify_hash=False, + create_account=False + ) + + yield cleos + + finally: + # ec, out = cleos.list_all_keys() + # logging.info(out) + if cleanup: + vtestnet.stop() + vtestnet.remove() diff --git a/skynet/protobuf/__init__.py b/skynet/protobuf/__init__.py deleted file mode 100644 index acafec8..0000000 --- a/skynet/protobuf/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/python - -from .auth import * -from .skynet_pb2 import * diff --git a/skynet/protobuf/auth.py b/skynet/protobuf/auth.py deleted file mode 100644 index 876683d..0000000 --- a/skynet/protobuf/auth.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/python - -import json -import logging - -from hashlib import sha256 -from collections import OrderedDict - -from google.protobuf.json_format import MessageToDict -from cryptography.hazmat.primitives import serialization, hashes -from cryptography.hazmat.primitives.asymmetric import padding - -from .skynet_pb2 import * - - -def serialize_msg_deterministic(msg): - descriptors = sorted( - type(msg).DESCRIPTOR.fields_by_name.items(), - key=lambda x: x[0] - ) - shasum = sha256() - - def hash_dict(d): - data = [ - (key, val) - for (key, val) in d.items() - ] - for key, val in sorted(data, key=lambda x: x[0]): - if not isinstance(val, dict): - shasum.update(key.encode()) - shasum.update(json.dumps(val).encode()) - else: - hash_dict(val) - - for (field_name, field_descriptor) in descriptors: - if not field_descriptor.message_type: - shasum.update(field_name.encode()) - - value = getattr(msg, field_name) - - if isinstance(value, bytes): - value = value.hex() - - shasum.update(json.dumps(value).encode()) - continue - - if field_descriptor.message_type.name == 'Struct': - hash_dict(MessageToDict(getattr(msg, field_name))) - - deterministic_msg = shasum.digest() - - return deterministic_msg - - -def sign_protobuf_msg(msg, key): - return key.sign( - serialize_msg_deterministic(msg), - padding.PKCS1v15(), - hashes.SHA256() - ).hex() - - -def verify_protobuf_msg(msg, cert): - return cert.public_key().verify( - bytes.fromhex(msg.auth.sig), - serialize_msg_deterministic(msg), - padding.PKCS1v15(), - hashes.SHA256() - ) diff --git a/skynet/protobuf/skynet.proto b/skynet/protobuf/skynet.proto deleted file mode 100644 index 0bdccad..0000000 --- a/skynet/protobuf/skynet.proto +++ /dev/null @@ -1,24 +0,0 @@ -syntax = "proto3"; - -package skynet; - -import "google/protobuf/struct.proto"; - -message Auth { - string cert = 1; - string sig = 2; -} - -message SkynetRPCRequest { - string uid = 1; - string method = 2; - google.protobuf.Struct params = 3; - optional bytes bin = 4; - optional Auth auth = 5; -} - -message SkynetRPCResponse { - google.protobuf.Struct result = 1; - optional bytes bin = 2; - optional Auth auth = 3; -} diff --git a/skynet/protobuf/skynet_pb2.py b/skynet/protobuf/skynet_pb2.py deleted file mode 100644 index 84b0527..0000000 --- a/skynet/protobuf/skynet_pb2.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: skynet.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cskynet.proto\x12\x06skynet\x1a\x1cgoogle/protobuf/struct.proto\"!\n\x04\x41uth\x12\x0c\n\x04\x63\x65rt\x18\x01 \x01(\t\x12\x0b\n\x03sig\x18\x02 \x01(\t\"\x9c\x01\n\x10SkynetRPCRequest\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\x0e\n\x06method\x18\x02 \x01(\t\x12\'\n\x06params\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x10\n\x03\x62in\x18\x04 \x01(\x0cH\x00\x88\x01\x01\x12\x1f\n\x04\x61uth\x18\x05 \x01(\x0b\x32\x0c.skynet.AuthH\x01\x88\x01\x01\x42\x06\n\x04_binB\x07\n\x05_auth\"\x80\x01\n\x11SkynetRPCResponse\x12\'\n\x06result\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x10\n\x03\x62in\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x1f\n\x04\x61uth\x18\x03 \x01(\x0b\x32\x0c.skynet.AuthH\x01\x88\x01\x01\x42\x06\n\x04_binB\x07\n\x05_authb\x06proto3') - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'skynet_pb2', globals()) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - _AUTH._serialized_start=54 - _AUTH._serialized_end=87 - _SKYNETRPCREQUEST._serialized_start=90 - _SKYNETRPCREQUEST._serialized_end=246 - _SKYNETRPCRESPONSE._serialized_start=249 - _SKYNETRPCRESPONSE._serialized_end=377 -# @@protoc_insertion_point(module_scope) diff --git a/skynet/structs.py b/skynet/structs.py deleted file mode 100644 index f110b6b..0000000 --- a/skynet/structs.py +++ /dev/null @@ -1,148 +0,0 @@ -# piker: trading gear for hackers -# Copyright (C) Guillermo Rodriguez (in stewardship for piker0) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -""" -Built-in (extension) types. -""" -import sys -import json - -from typing import Optional, Union -from pprint import pformat - -import msgspec - - -class Struct(msgspec.Struct): - ''' - A "human friendlier" (aka repl buddy) struct subtype. - ''' - def to_dict(self) -> dict: - return { - f: getattr(self, f) - for f in self.__struct_fields__ - } - - def __repr__(self): - # only turn on pprint when we detect a python REPL - # at runtime B) - if ( - hasattr(sys, 'ps1') - # TODO: check if we're in pdb - ): - return self.pformat() - - return super().__repr__() - - def pformat(self) -> str: - return f'Struct({pformat(self.to_dict())})' - - def copy( - self, - update: Optional[dict] = None, - - ) -> msgspec.Struct: - ''' - Validate-typecast all self defined fields, return a copy of us - with all such fields. - This is kinda like the default behaviour in `pydantic.BaseModel`. - ''' - if update: - for k, v in update.items(): - setattr(self, k, v) - - # roundtrip serialize to validate - return msgspec.msgpack.Decoder( - type=type(self) - ).decode( - msgspec.msgpack.Encoder().encode(self) - ) - - def typecast( - self, - # fields: Optional[list[str]] = None, - ) -> None: - for fname, ftype in self.__annotations__.items(): - setattr(self, fname, ftype(getattr(self, fname))) - -# proto -from OpenSSL.crypto import PKey, X509, verify, sign - - -class AuthenticatedStruct(Struct, kw_only=True): - cert: Optional[str] = None - sig: Optional[str] = None - - def to_unsigned_dict(self) -> dict: - self_dict = self.to_dict() - - if 'sig' in self_dict: - del self_dict['sig'] - - if 'cert' in self_dict: - del self_dict['cert'] - - return self_dict - - def unsigned_to_bytes(self) -> bytes: - return json.dumps( - self.to_unsigned_dict()).encode() - - def sign(self, key: PKey, cert: str): - self.cert = cert - self.sig = sign( - key, self.unsigned_to_bytes(), 'sha256').hex() - - def verify(self, cert: X509): - if not self.sig: - raise ValueError('Tried to verify unsigned request') - - return verify( - cert, bytes.fromhex(self.sig), self.unsigned_to_bytes(), 'sha256') - - -class SkynetRPCRequest(AuthenticatedStruct): - uid: Union[str, int] # user unique id - method: str # rpc method name - params: dict # variable params - - -class SkynetRPCResponse(AuthenticatedStruct): - result: dict - - -class ImageGenRequest(Struct): - prompt: str - step: int - width: int - height: int - guidance: int - seed: Optional[int] - algo: str - upscaler: Optional[str] - - -class DGPUBusRequest(AuthenticatedStruct): - rid: str # req id - nid: str # node id - task: str - params: dict - - -class DGPUBusResponse(AuthenticatedStruct): - rid: str # req id - nid: str # node id - params: dict diff --git a/skynet/utils.py b/skynet/utils.py index 637078b..3789aa4 100644 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -1,5 +1,6 @@ #!/usr/bin/python +import os import time import random @@ -43,6 +44,13 @@ def pipeline_for(algo: str, mem_fraction: float = 1.0, image=False): torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True + # full determinism + # https://huggingface.co/docs/diffusers/using-diffusers/reproducibility#deterministic-algorithms + os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":16:8" + + torch.backends.cudnn.benchmark = False + torch.use_deterministic_algorithms(True) + params = { 'torch_dtype': torch.float16, 'safety_checker': None diff --git a/tests/conftest.py b/tests/conftest.py index 0b4c335..8f631c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,71 +1,33 @@ #!/usr/bin/python -import os -import json -import time import logging from pathlib import Path -from functools import partial import pytest -from docker.types import Mount, DeviceRequest - from skynet.db import open_new_database -from skynet.brain import run_skynet -from skynet.network import get_random_port -from skynet.constants import * +from skynet.nodeos import open_nodeos @pytest.fixture(scope='session') -def postgres_db(dockerctl): +def postgres_db(): with open_new_database() as db_params: yield db_params +@pytest.fixture(scope='session') +def cleos(): + with open_nodeos() as cli: + contract_acc = cli.new_account('telos.gpu', ram=300000) -@pytest.fixture -async def skynet_running(): - async with run_skynet(): - yield + cli.new_account(name='testworker1') + cli.new_account(name='testworker2') + cli.new_account(name='testworker3') - -@pytest.fixture -def dgpu_workers(request, dockerctl, skynet_running): - devices = [DeviceRequest(capabilities=[['gpu']])] - mounts = [Mount( - '/skynet', str(Path().resolve()), type='bind')] - - num_containers, initial_algos = request.param - - cmds = [] - for i in range(num_containers): - dgpu_addr = f'tcp://127.0.0.1:{get_random_port()}' - cmd = f''' - pip install -e . && \ - skynet run dgpu \ - --algos=\'{json.dumps(initial_algos)}\' \ - --uid=dgpu-{i} \ - --dgpu={dgpu_addr} - ''' - cmds.append(['bash', '-c', cmd]) - - logging.info(f'launching: \n{cmd}') - - with dockerctl.run( - DOCKER_RUNTIME_CUDA, - name='skynet-test-runtime-cuda', - commands=cmds, - environment={ - 'HF_HOME': '/skynet/hf_home' - }, - network='host', - mounts=mounts, - device_requests=devices, - num=num_containers, - ) as containers: - yield containers - - for i, container in enumerate(containers): - logging.info(f'container {i} logs:') - logging.info(container.logs().decode()) + cli.deploy_contract_from_host( + 'telos.gpu', + 'tests/contracts/telos.gpu', + verify_hash=False, + create_account=False + ) + yield cli diff --git a/tests/contracts/telos.gpu/telos.gpu.abi b/tests/contracts/telos.gpu/telos.gpu.abi new file mode 100644 index 0000000..75240ee --- /dev/null +++ b/tests/contracts/telos.gpu/telos.gpu.abi @@ -0,0 +1,220 @@ +{ + "____comment": "This file was generated with eosio-abigen. DO NOT EDIT ", + "version": "eosio::abi/1.2", + "types": [], + "structs": [ + { + "name": "dequeue", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "enqueue", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "request_body", + "type": "string" + }, + { + "name": "binary_data", + "type": "bytes" + } + ] + }, + { + "name": "submit", + "base": "", + "fields": [ + { + "name": "worker", + "type": "name" + }, + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "result_hash", + "type": "checksum256" + }, + { + "name": "ipfs_hash", + "type": "string" + } + ] + }, + { + "name": "work_request_struct", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "user", + "type": "name" + }, + { + "name": "body", + "type": "string" + }, + { + "name": "binary_data", + "type": "bytes" + }, + { + "name": "timestamp", + "type": "time_point_sec" + } + ] + }, + { + "name": "work_result_struct", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "user", + "type": "name" + }, + { + "name": "worker", + "type": "name" + }, + { + "name": "result_hash", + "type": "checksum256" + }, + { + "name": "ipfs_hash", + "type": "string" + }, + { + "name": "submited", + "type": "time_point_sec" + } + ] + }, + { + "name": "workbegin", + "base": "", + "fields": [ + { + "name": "worker", + "type": "name" + }, + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "workcancel", + "base": "", + "fields": [ + { + "name": "worker", + "type": "name" + }, + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "worker_status_struct", + "base": "", + "fields": [ + { + "name": "worker", + "type": "name" + }, + { + "name": "status", + "type": "string" + }, + { + "name": "started", + "type": "time_point_sec" + } + ] + } + ], + "actions": [ + { + "name": "dequeue", + "type": "dequeue", + "ricardian_contract": "" + }, + { + "name": "enqueue", + "type": "enqueue", + "ricardian_contract": "" + }, + { + "name": "submit", + "type": "submit", + "ricardian_contract": "" + }, + { + "name": "workbegin", + "type": "workbegin", + "ricardian_contract": "" + }, + { + "name": "workcancel", + "type": "workcancel", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "queue", + "type": "work_request_struct", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "results", + "type": "work_result_struct", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "status", + "type": "worker_status_struct", + "index_type": "i64", + "key_names": [], + "key_types": [] + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/tests/contracts/telos.gpu/telos.gpu.wasm b/tests/contracts/telos.gpu/telos.gpu.wasm new file mode 100755 index 0000000000000000000000000000000000000000..de8c6072459cd11de3bed0bb970b8e529be3394a GIT binary patch literal 21761 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�r+2yon?)zDB)%q!%o*1OO_#dUi{17? z=W;$FYFy53_t?^^VfQ`m8e_fg$MzqYp?|vAUOIM|pFqs{S)a!B2d3w=(gEfKg!%oQ z_JQtFIy0Acr}rP`!CmPz?+>*X(*yH!-9>&0kddEfzyHZ-3CtQaasD z`FY0qnK>pt!Y?zXhi6!wp({_g#+B{G#rZ`5nLao(cXtX@i_`oPAaB`;XuMoMp}5rL z?GdKwwvQ|bY6zwN5DD4j8PsBID16lHpgAtieFw7KIKAFYJ1K> z_aiW8W{Mx8Y@c8)zN%Iy=QtIwE5n&QSS>lTWXD%Uv~rr%Ak$w ojv;#s2r6{+q6FRUnmNG_TJFEM+g_5WB0$Fuc%fqK*fq}m7wF7U%m4rY literal 0 HcmV?d00001 diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..5847849 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +import time +import json + +from hashlib import sha256 +from functools import partial + +import trio +import requests + +from skynet.dgpu import open_dgpu_node + + +def test_enqueue_work(ipfs_node, cleos): + + user = cleos.new_account() + req = json.dumps({ + 'method': 'diffuse', + 'params': { + 'algo': 'midj', + 'prompt': 'skynet terminator dystopic', + 'width': 512, + 'height': 512, + 'guidance': 10, + 'step': 28, + 'seed': 420, + 'upscaler': 'x4' + } + }) + binary = '' + + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [user, req, binary], f'{user}@active' + ) + + assert ec == 0 + + queue = cleos.get_table('telos.gpu', 'telos.gpu', 'queue') + + assert len(queue) == 1 + + req_on_chain = queue[0] + + assert req_on_chain['user'] == user + assert req_on_chain['body'] == req + assert req_on_chain['binary_data'] == binary + + ipfs_hash = None + sha_hash = None + for i in range(1, 4): + trio.run( + partial( + open_dgpu_node, + f'testworker{i}', + 'active', + cleos, + ipfs_node, + initial_algos=['midj'] + ) + ) + + if ipfs_hash == None: + result = cleos.get_table( + 'telos.gpu', 'telos.gpu', 'results', + index_position=4, + key_type='name', + lower_bound=f'testworker{i}', + upper_bound=f'testworker{i}' + ) + assert len(result) == 1 + ipfs_hash = result[0]['ipfs_hash'] + sha_hash = result[0]['result_hash'] + + queue = cleos.get_table('telos.gpu', 'telos.gpu', 'queue') + + assert len(queue) == 0 + + resp = requests.get(f'https://ipfs.io/ipfs/{ipfs_hash}/image.png') + assert resp.status_code == 200 + + assert sha_hash == sha256(resp.content).hexdigest() diff --git a/tests/test_dgpu.py b/tests/test_dgpu.py deleted file mode 100644 index c187af0..0000000 --- a/tests/test_dgpu.py +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/python - -import io -import time -import json -import zlib -import logging - -from typing import Optional -from hashlib import sha256 -from functools import partial - -import trio -import pytest - -from PIL import Image -from google.protobuf.json_format import MessageToDict - -from skynet.brain import SkynetDGPUComputeError -from skynet.network import get_random_port, SessionServer -from skynet.protobuf import SkynetRPCResponse -from skynet.frontend import open_skynet_rpc -from skynet.constants import * - - -async def wait_for_dgpus(session, amount: int, timeout: float = 30.0): - gpu_ready = False - with trio.fail_after(timeout): - while not gpu_ready: - res = await session.rpc('dgpu_workers') - if res.result['ok'] >= amount: - break - - await trio.sleep(1) - - -_images = set() -async def check_request_img( - i: int, - uid: str = '1', - width: int = 512, - height: int = 512, - expect_unique = True, - upscaler: Optional[str] = None -): - global _images - - with open_skynet_rpc( - uid, - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - res = await session.rpc( - 'dgpu_call', { - 'method': 'diffuse', - 'params': { - 'prompt': 'red old tractor in a sunny wheat field', - 'step': 28, - 'width': width, 'height': height, - 'guidance': 7.5, - 'seed': None, - 'algo': list(ALGOS.keys())[i], - 'upscaler': upscaler - } - }, - timeout=60 - ) - - if 'error' in res.result: - raise SkynetDGPUComputeError(MessageToDict(res.result)) - - img_raw = res.bin - img_sha = sha256(img_raw).hexdigest() - img = Image.open(io.BytesIO(img_raw)) - - if expect_unique and img_sha in _images: - raise ValueError('Duplicated image sha: {img_sha}') - - _images.add(img_sha) - - logging.info(f'img sha256: {img_sha} size: {len(img_raw)}') - - assert len(img_raw) > 100000 - - return img - - -@pytest.mark.parametrize( - 'dgpu_workers', [(1, ['midj'])], indirect=True) -async def test_dgpu_worker_compute_error(dgpu_workers): - '''Attempt to generate a huge image and check we get the right error, - then generate a smaller image to show gpu worker recovery - ''' - - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 1) - - with pytest.raises(SkynetDGPUComputeError) as e: - await check_request_img(0, width=4096, height=4096) - - logging.info(e) - - await check_request_img(0) - - -@pytest.mark.parametrize( - 'dgpu_workers', [(1, ['midj'])], indirect=True) -async def test_dgpu_worker(dgpu_workers): - '''Generate one image in a single dgpu worker - ''' - - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 1) - - await check_request_img(0) - - -@pytest.mark.parametrize( - 'dgpu_workers', [(1, ['midj', 'stable'])], indirect=True) -async def test_dgpu_worker_two_models(dgpu_workers): - '''Generate two images in a single dgpu worker using - two different models. - ''' - - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 1) - - await check_request_img(0) - await check_request_img(1) - - -@pytest.mark.parametrize( - 'dgpu_workers', [(1, ['midj'])], indirect=True) -async def test_dgpu_worker_upscale(dgpu_workers): - '''Generate two images in a single dgpu worker using - two different models. - ''' - - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 1) - - img = await check_request_img(0, upscaler='x4') - - assert img.size == (2048, 2048) - - -@pytest.mark.parametrize( - 'dgpu_workers', [(2, ['midj'])], indirect=True) -async def test_dgpu_workers_two(dgpu_workers): - '''Generate two images in two separate dgpu workers - ''' - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 2, timeout=60) - - async with trio.open_nursery() as n: - n.start_soon(check_request_img, 0) - n.start_soon(check_request_img, 0) - - -@pytest.mark.parametrize( - 'dgpu_workers', [(1, ['midj'])], indirect=True) -async def test_dgpu_worker_algo_swap(dgpu_workers): - '''Generate an image using a non default model - ''' - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 1) - await check_request_img(5) - - -@pytest.mark.parametrize( - 'dgpu_workers', [(3, ['midj'])], indirect=True) -async def test_dgpu_rotation_next_worker(dgpu_workers): - '''Connect three dgpu workers, disconnect and check next_worker - rotation happens correctly - ''' - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 3) - - res = await session.rpc('dgpu_next') - assert 'ok' in res.result - assert res.result['ok'] == 0 - - await check_request_img(0) - - res = await session.rpc('dgpu_next') - assert 'ok' in res.result - assert res.result['ok'] == 1 - - await check_request_img(0) - - res = await session.rpc('dgpu_next') - assert 'ok' in res.result - assert res.result['ok'] == 2 - - await check_request_img(0) - - res = await session.rpc('dgpu_next') - assert 'ok' in res.result - assert res.result['ok'] == 0 - - -@pytest.mark.parametrize( - 'dgpu_workers', [(3, ['midj'])], indirect=True) -async def test_dgpu_rotation_next_worker_disconnect(dgpu_workers): - '''Connect three dgpu workers, disconnect the first one and check - next_worker rotation happens correctly - ''' - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 3) - - await trio.sleep(3) - - # stop worker who's turn is next - for _ in range(2): - ec, out = dgpu_workers[0].exec_run(['pkill', '-INT', '-f', 'skynet']) - assert ec == 0 - - dgpu_workers[0].wait() - - res = await session.rpc('dgpu_workers') - assert 'ok' in res.result - assert res.result['ok'] == 2 - - async with trio.open_nursery() as n: - n.start_soon(check_request_img, 0) - n.start_soon(check_request_img, 0) - - -async def test_dgpu_no_ack_node_disconnect(skynet_running): - '''Mock a node that connects, gets a request but fails to - acknowledge it, then check skynet correctly drops the node - ''' - - async def mock_rpc(req, ctx): - resp = SkynetRPCResponse() - resp.result.update({'error': 'can\'t do it mate'}) - return resp - - dgpu_addr = f'tcp://127.0.0.1:{get_random_port()}' - mock_server = SessionServer( - dgpu_addr, - mock_rpc, - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) - - async with mock_server.open(): - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - - res = await session.rpc('dgpu_online', { - 'dgpu_addr': dgpu_addr, - 'cert': 'whitelist/testing.cert' - }) - assert 'ok' in res.result - - await wait_for_dgpus(session, 1) - - with pytest.raises(SkynetDGPUComputeError) as e: - await check_request_img(0) - - assert 'can\'t do it mate' in str(e.value) - - res = await session.rpc('dgpu_workers') - assert 'ok' in res.result - assert res.result['ok'] == 0 - - -@pytest.mark.parametrize( - 'dgpu_workers', [(1, ['midj'])], indirect=True) -async def test_dgpu_timeout_while_processing(dgpu_workers): - '''Stop node while processing request to cause timeout and - then check skynet correctly drops the node. - ''' - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 1) - - async def check_request_img_raises(): - with pytest.raises(SkynetDGPUComputeError) as e: - await check_request_img(0) - - assert 'timeout while processing request' in str(e) - - async with trio.open_nursery() as n: - n.start_soon(check_request_img_raises) - await trio.sleep(1) - ec, out = dgpu_workers[0].exec_run( - ['pkill', '-TERM', '-f', 'skynet']) - assert ec == 0 - - -@pytest.mark.parametrize( - 'dgpu_workers', [(1, ['midj'])], indirect=True) -async def test_dgpu_img2img(dgpu_workers): - - with open_skynet_rpc( - 'test-ctx', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - await wait_for_dgpus(session, 1) - - await trio.sleep(2) - - res = await session.rpc( - 'dgpu_call', { - 'method': 'diffuse', - 'params': { - 'prompt': 'red old tractor in a sunny wheat field', - 'step': 28, - 'width': 512, 'height': 512, - 'guidance': 7.5, - 'seed': None, - 'algo': list(ALGOS.keys())[0], - 'upscaler': None - } - }, - timeout=60 - ) - - if 'error' in res.result: - raise SkynetDGPUComputeError(MessageToDict(res.result)) - - img_raw = res.bin - img = Image.open(io.BytesIO(img_raw)) - img.save('txt2img.png') - - res = await session.rpc( - 'dgpu_call', { - 'method': 'diffuse', - 'params': { - 'prompt': 'red ferrari in a sunny wheat field', - 'step': 28, - 'guidance': 8, - 'strength': 0.7, - 'seed': None, - 'algo': list(ALGOS.keys())[0], - 'upscaler': 'x4' - } - }, - binext=img_raw, - timeout=60 - ) - - if 'error' in res.result: - raise SkynetDGPUComputeError(MessageToDict(res.result)) - - img_raw = res.bin - img = Image.open(io.BytesIO(img_raw)) - img.save('img2img.png') diff --git a/tests/test_skynet.py b/tests/test_skynet.py deleted file mode 100644 index 1587d5d..0000000 --- a/tests/test_skynet.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/python - -import logging - -import trio -import pynng -import pytest -import trio_asyncio - -from skynet.brain import run_skynet -from skynet.structs import * -from skynet.network import SessionServer -from skynet.frontend import open_skynet_rpc - - -async def test_skynet(skynet_running): - ... - - -async def test_skynet_attempt_insecure(skynet_running): - with pytest.raises(pynng.exceptions.NNGException) as e: - with open_skynet_rpc('bad-actor') as session: - with trio.fail_after(5): - await session.rpc('skynet_shutdown') - - -async def test_skynet_dgpu_connection_simple(skynet_running): - - async def rpc_handler(req, ctx): - ... - - fake_dgpu_addr = 'tcp://127.0.0.1:41001' - rpc_server = SessionServer( - fake_dgpu_addr, - rpc_handler, - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) - - with open_skynet_rpc( - 'dgpu-0', - cert_name='whitelist/testing.cert', - key_name='testing.key' - ) as session: - # check 0 nodes are connected - res = await session.rpc('dgpu_workers') - assert 'ok' in res.result.keys() - assert res.result['ok'] == 0 - - # check next worker is None - res = await session.rpc('dgpu_next') - assert 'ok' in res.result.keys() - assert res.result['ok'] == None - - async with rpc_server.open() as rpc_server: - # connect 1 dgpu - res = await session.rpc( - 'dgpu_online', { - 'dgpu_addr': fake_dgpu_addr, - 'cert': 'whitelist/testing.cert' - }) - assert 'ok' in res.result.keys() - - # check 1 node is connected - res = await session.rpc('dgpu_workers') - assert 'ok' in res.result.keys() - assert res.result['ok'] == 1 - - # check next worker is 0 - res = await session.rpc('dgpu_next') - assert 'ok' in res.result.keys() - assert res.result['ok'] == 0 - - # disconnect 1 dgpu - res = await session.rpc('dgpu_offline') - assert 'ok' in res.result.keys() - - # check 0 nodes are connected - res = await session.rpc('dgpu_workers') - assert 'ok' in res.result.keys() - assert res.result['ok'] == 0 - - # check next worker is None - res = await session.rpc('dgpu_next') - assert 'ok' in res.result.keys() - assert res.result['ok'] == None diff --git a/tests/test_telegram.py b/tests/test_telegram.py deleted file mode 100644 index d94a6bf..0000000 --- a/tests/test_telegram.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/python - -import trio - -from functools import partial - -from skynet.db import open_new_database -from skynet.brain import run_skynet -from skynet.config import load_skynet_ini -from skynet.frontend.telegram import run_skynet_telegram - - -if __name__ == '__main__': - '''You will need a telegram bot token configured on skynet.ini for this - ''' - with open_new_database() as db_params: - db_container, db_pass, db_host = db_params - config = load_skynet_ini() - - async def main(): - await run_skynet_telegram( - 'telegram-test', - config['skynet.telegram-test']['token'], - db_host=db_host, - db_pass=db_pass - ) - - trio.run(main) From dcd020f0da1c808ced51dd929284a4a381f730cb Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Wed, 24 May 2023 13:24:46 -0300 Subject: [PATCH 02/88] Init chain with GPU token, tweak dgpu init --- skynet/nodeos.py | 4 ++-- tests/conftest.py | 12 ------------ tests/test_deploy.py | 3 +-- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/skynet/nodeos.py b/skynet/nodeos.py index 39f0672..e5089e9 100644 --- a/skynet/nodeos.py +++ b/skynet/nodeos.py @@ -8,7 +8,7 @@ from contextlib import contextmanager as cm import docker from leap.cleos import CLEOS -from leap.sugar import get_container +from leap.sugar import get_container, Symbol @cm @@ -40,7 +40,7 @@ def open_nodeos(cleanup: bool = True): cleos.setup_wallet('5JnvSc6pewpHHuUHwvbJopsew6AKwiGnexwDRc2Pj2tbdw6iML9') cleos.wait_blocks(1) - cleos.boot_sequence() + cleos.boot_sequence(token_sym=Symbol('GPU', 4)) cleos.new_account('telos.gpu', ram=300000) diff --git a/tests/conftest.py b/tests/conftest.py index 8f631c7..8cc392e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,16 +18,4 @@ def postgres_db(): @pytest.fixture(scope='session') def cleos(): with open_nodeos() as cli: - contract_acc = cli.new_account('telos.gpu', ram=300000) - - cli.new_account(name='testworker1') - cli.new_account(name='testworker2') - cli.new_account(name='testworker3') - - cli.deploy_contract_from_host( - 'telos.gpu', - 'tests/contracts/telos.gpu', - verify_hash=False, - create_account=False - ) yield cli diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 5847849..2bea848 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -12,7 +12,7 @@ import requests from skynet.dgpu import open_dgpu_node -def test_enqueue_work(ipfs_node, cleos): +def test_enqueue_work(cleos): user = cleos.new_account() req = json.dumps({ @@ -55,7 +55,6 @@ def test_enqueue_work(ipfs_node, cleos): f'testworker{i}', 'active', cleos, - ipfs_node, initial_algos=['midj'] ) ) From 3607c568de186bf6e226f077b460fcdd3901f327 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 27 May 2023 17:50:47 -0300 Subject: [PATCH 03/88] Drop separate reqs file for tests Update docker containers Create cli helpers for interacting with skynet Add test Begin adding hyperion api to telegram frontend --- docker/Dockerfile.runtime | 5 +- docker/Dockerfile.runtime+cuda | 10 +-- requirements.test.txt | 6 -- requirements.txt | 6 +- skynet/cli.py | 150 +++++++++++++++++++++++++++------ skynet/frontend/telegram.py | 3 +- skynet/nodeos.py | 44 ++++++++-- tests/test_deploy.py | 43 ++++++++++ 8 files changed, 214 insertions(+), 53 deletions(-) delete mode 100644 requirements.test.txt diff --git a/docker/Dockerfile.runtime b/docker/Dockerfile.runtime index 7f09a6e..316fdcb 100644 --- a/docker/Dockerfile.runtime +++ b/docker/Dockerfile.runtime @@ -4,7 +4,6 @@ env DEBIAN_FRONTEND=noninteractive workdir /skynet -copy requirements.test.txt requirements.test.txt copy requirements.txt requirements.txt copy pytest.ini ./ copy setup.py ./ @@ -12,8 +11,6 @@ copy skynet ./skynet run pip install \ -e . \ - -r requirements.txt \ - -r requirements.test.txt + -r requirements.txt -copy scripts ./ copy tests ./ diff --git a/docker/Dockerfile.runtime+cuda b/docker/Dockerfile.runtime+cuda index 27a5a66..6d52960 100644 --- a/docker/Dockerfile.runtime+cuda +++ b/docker/Dockerfile.runtime+cuda @@ -1,5 +1,5 @@ from nvidia/cuda:11.7.0-devel-ubuntu20.04 -from python:3.10.0 +from python:3.11 env DEBIAN_FRONTEND=noninteractive @@ -15,21 +15,15 @@ run pip install -v -r requirements.cuda.0.txt run pip install -v -r requirements.cuda.1.txt run pip install -v -r requirements.cuda.2.txt -copy requirements.test.txt requirements.test.txt copy requirements.txt requirements.txt copy pytest.ini pytest.ini copy setup.py setup.py copy skynet skynet -run pip install -e . \ - -r requirements.txt \ - -r requirements.test.txt +run pip install -e . -r requirements.txt env PYTORCH_CUDA_ALLOC_CONF max_split_size_mb:128 env NVIDIA_VISIBLE_DEVICES=all env HF_HOME /hf_home -copy scripts scripts copy tests tests - -expose 40000-45000 diff --git a/requirements.test.txt b/requirements.test.txt deleted file mode 100644 index a35f4d3..0000000 --- a/requirements.test.txt +++ /dev/null @@ -1,6 +0,0 @@ -pdbpp -pytest -docker -psycopg2-binary - -git+https://github.com/guilledk/py-leap.git@async_net_requests#egg=py-eosio diff --git a/requirements.txt b/requirements.txt index 3fa199a..b6f2e30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,13 @@ trio asks numpy +pdbpp Pillow triopg +pytest +docker aiohttp +psycopg2-binary pyTelegramBotAPI -git+https://github.com/goodboy/tractor.git@master#egg=tractor +py-leap@git+https://github.com/guilledk/py-leap.git@v0.1a11 diff --git a/skynet/cli.py b/skynet/cli.py index 28c4eb0..34c8619 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -1,6 +1,4 @@ #!/usr/bin/python -import importlib.util -torch_enabled = importlib.util.find_spec('torch') != None import os import json @@ -13,17 +11,14 @@ import trio import click import docker import asyncio +import requests from leap.cleos import CLEOS, default_nodeos_image -from leap.sugar import get_container - -if torch_enabled: - from . import utils - from .dgpu import open_dgpu_node +from leap.sugar import get_container, collect_stdout from .db import open_new_database from .config import * -from .nodeos import open_nodeos +from .nodeos import open_cleos, open_nodeos from .constants import ALGOS from .frontend.telegram import run_skynet_telegram @@ -44,6 +39,7 @@ def skynet(*args, **kwargs): @click.option('--steps', '-s', default=26) @click.option('--seed', '-S', default=None) def txt2img(*args, **kwargs): + from . import utils _, hf_token, _, cfg = init_env_from_config() utils.txt2img(hf_token, **kwargs) @@ -58,6 +54,7 @@ def txt2img(*args, **kwargs): @click.option('--steps', '-s', default=26) @click.option('--seed', '-S', default=None) def img2img(model, prompt, input, output, strength, guidance, steps, seed): + from . import utils _, hf_token, _, cfg = init_env_from_config() utils.img2img( hf_token, @@ -76,6 +73,7 @@ def img2img(model, prompt, input, output, strength, guidance, steps, seed): @click.option('--output', '-o', default='output.png') @click.option('--model', '-m', default='weights/RealESRGAN_x4plus.pth') def upscale(input, output, model): + from . import utils utils.upscale( img_path=input, output=output, @@ -84,9 +82,104 @@ def upscale(input, output, model): @skynet.command() def download(): + from . import utils _, hf_token, _, cfg = init_env_from_config() utils.download_all_models(hf_token) +@skynet.command() +@click.option( + '--account', '-a', default='telegram1') +@click.option( + '--permission', '-p', default='active') +@click.option( + '--key', '-k', default=None) +@click.option( + '--node-url', '-n', default='http://skynet.ancap.tech') +@click.option('--algo', '-a', default='midj') +@click.option( + '--prompt', '-p', default='a red old tractor in a sunny wheat field') +@click.option('--output', '-o', default='output.png') +@click.option('--width', '-w', default=512) +@click.option('--height', '-h', default=512) +@click.option('--guidance', '-g', default=10) +@click.option('--step', '-s', default=26) +@click.option('--seed', '-S', default=420) +@click.option('--upscaler', '-U', default='x4') +def enqueue( + account: str, + permission: str, + key: str | None, + node_url: str, + **kwargs +): + with open_cleos(node_url, key=key) as cleos: + req = json.dumps({ + 'method': 'diffuse', + 'params': kwargs + }) + binary = '' + + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [account, req, binary], f'{account}@{permission}' + ) + + assert ec == 0 + print(collect_stdout(out)) + + +@skynet.command() +@click.option( + '--node-url', '-n', default='http://skynet.ancap.tech') +def queue(node_url: str): + resp = requests.post( + f'{node_url}/v1/chain/get_table_rows', + json={ + 'code': 'telos.gpu', + 'table': 'queue', + 'scope': 'telos.gpu', + 'json': True + } + ) + print(json.dumps(resp.json(), indent=4)) + +@skynet.command() +@click.option( + '--node-url', '-n', default='http://skynet.ancap.tech') +@click.argument('request-id') +def status(node_url: str, request_id: int): + resp = requests.post( + f'{node_url}/v1/chain/get_table_rows', + json={ + 'code': 'telos.gpu', + 'table': 'status', + 'scope': request_id, + 'json': True + } + ) + print(json.dumps(resp.json(), indent=4)) + +@skynet.command() +@click.option( + '--account', '-a', default='telegram1') +@click.option( + '--permission', '-p', default='active') +@click.option( + '--key', '-k', default=None) +@click.option( + '--node-url', '-n', default='http://skynet.ancap.tech') +@click.argument('request-id') +def dequeue( + account: str, + permission: str, + key: str | None, + node_url: str, + request_id: int +): + with open_cleos(node_url, key=key) as cleos: + ec, out = cleos.push_action( + 'telos.gpu', 'dequeue', [account, request_id], f'{account}@{permission}' + ) + assert ec == 0 @skynet.group() def run(*args, **kwargs): @@ -114,7 +207,7 @@ def nodeos(): @click.option( '--key', '-k', default=None) @click.option( - '--node-url', '-n', default='http://test1.us.telos.net:42000') + '--node-url', '-n', default='http://skynet.ancap.tech') @click.option( '--algos', '-A', default=json.dumps(['midj'])) def dgpu( @@ -125,25 +218,30 @@ def dgpu( node_url: str, algos: list[str] ): - dclient = docker.from_env() - vtestnet = get_container( - dclient, - default_nodeos_image(), - force_unique=True, - detach=True, - network='host', - remove=True) + from .dgpu import open_dgpu_node + vtestnet = None + try: + dclient = docker.from_env() + vtestnet = get_container( + dclient, + default_nodeos_image(), + force_unique=True, + detach=True, + network='host', + remove=True) - cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) + cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) - trio.run( - partial( - open_dgpu_node, - account, permission, - cleos, key=key, initial_algos=json.loads(algos) - )) + trio.run( + partial( + open_dgpu_node, + account, permission, + cleos, key=key, initial_algos=json.loads(algos) + )) - vtestnet.stop() + finally: + if vtestnet: + vtestnet.stop() @run.command() @@ -155,7 +253,7 @@ def dgpu( @click.option( '--key', '-k', default=None) @click.option( - '--node-url', '-n', default='http://test1.us.telos.net:42000') + '--node-url', '-n', default='http://skynet.ancap.tech') @click.option( '--db-host', '-h', default='localhost:5432') @click.option( diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 2f47433..9f3dc2a 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -12,6 +12,7 @@ import docker from PIL import Image from leap.cleos import CLEOS, default_nodeos_image from leap.sugar import get_container, collect_stdout +from leap.hyperion import HyperionAPI from trio_asyncio import aio_as_trio from telebot.types import ( InputFile, InputMediaPhoto, InlineKeyboardButton, InlineKeyboardMarkup @@ -53,7 +54,6 @@ def prepare_metainfo_caption(tguser, meta: dict) -> str: meta_str += f'algo: \"{meta["algo"]}\"\n' if meta['upscaler']: meta_str += f'upscaler: \"{meta["upscaler"]}\"\n' - meta_str += f'sampler: k_euler_ancestral\n' meta_str += f'skynet v{VERSION}' return meta_str @@ -78,6 +78,7 @@ async def run_skynet_telegram( remove=True) cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) + hyperion = HyperionAPI(node_url) logging.basicConfig(level=logging.INFO) diff --git a/skynet/nodeos.py b/skynet/nodeos.py index e5089e9..6ddd51f 100644 --- a/skynet/nodeos.py +++ b/skynet/nodeos.py @@ -7,8 +7,38 @@ from contextlib import contextmanager as cm import docker -from leap.cleos import CLEOS -from leap.sugar import get_container, Symbol +from leap.cleos import CLEOS, default_nodeos_image +from leap.sugar import get_container, Symbol, random_string + + +@cm +def open_cleos( + node_url: str, + key: str | None +): + vtestnet = None + try: + dclient = docker.from_env() + vtestnet = get_container( + dclient, + default_nodeos_image(), + name=f'skynet-wallet-{random_string(size=8)}', + force_unique=True, + detach=True, + network='host', + remove=True) + + cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) + + if key: + cleos.setup_wallet(key) + + yield cleos + + finally: + if vtestnet: + vtestnet.stop() + @cm @@ -17,6 +47,7 @@ def open_nodeos(cleanup: bool = True): vtestnet = get_container( dclient, 'guilledk/py-eosio:leap-skynet-4.0.0', + name='skynet-nodeos', force_unique=True, detach=True, network='host') @@ -38,20 +69,19 @@ def open_nodeos(cleanup: bool = True): time.sleep(0.5) + public_dev_key = 'EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1' cleos.setup_wallet('5JnvSc6pewpHHuUHwvbJopsew6AKwiGnexwDRc2Pj2tbdw6iML9') cleos.wait_blocks(1) cleos.boot_sequence(token_sym=Symbol('GPU', 4)) - cleos.new_account('telos.gpu', ram=300000) + cleos.new_account('telos.gpu', ram=300000, key=public_dev_key) for i in range(1, 4): cleos.create_account_staked( - 'eosio', f'testworker{i}', - key='EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1') + 'eosio', f'testworker{i}', key=public_dev_key) cleos.create_account_staked( - 'eosio', 'telegram1', ram=500000, - key='EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1') + 'eosio', 'telegram1', ram=500000, key=public_dev_key) cleos.deploy_contract_from_host( 'telos.gpu', diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 2bea848..17e1a15 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -11,6 +11,8 @@ import requests from skynet.dgpu import open_dgpu_node +from leap.sugar import collect_stdout + def test_enqueue_work(cleos): @@ -79,3 +81,44 @@ def test_enqueue_work(cleos): assert resp.status_code == 200 assert sha_hash == sha256(resp.content).hexdigest() + + +def test_enqueue_dequeue(cleos): + + user = cleos.new_account() + req = json.dumps({ + 'method': 'diffuse', + 'params': { + 'algo': 'midj', + 'prompt': 'skynet terminator dystopic', + 'width': 512, + 'height': 512, + 'guidance': 10, + 'step': 28, + 'seed': 420, + 'upscaler': 'x4' + } + }) + binary = '' + + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [user, req, binary], f'{user}@active' + ) + + assert ec == 0 + + request_id = int(collect_stdout(out)) + + queue = cleos.get_table('telos.gpu', 'telos.gpu', 'queue') + + assert len(queue) == 1 + + ec, out = cleos.push_action( + 'telos.gpu', 'dequeue', [user, request_id], f'{user}@active' + ) + + assert ec == 0 + + queue = cleos.get_table('telos.gpu', 'telos.gpu', 'queue') + + assert len(queue) == 0 From 1d7d11a9c1e562bc464c40d084646150f445485f Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 27 May 2023 21:38:04 -0300 Subject: [PATCH 04/88] Add .ini example & new account config Add new smart contract clis config etc Update GPU worker software to match contract updates Do dynamic nodeos genesis --- .../Dockerfile | 4 +- .../config.ini | 0 .../contracts/eosio.msig/eosio.msig.abi | 0 .../contracts/eosio.msig/eosio.msig.wasm | Bin .../contracts/eosio.system/eosio.system.abi | 0 .../contracts/eosio.system/eosio.system.wasm | Bin .../contracts/eosio.token/eosio.token.abi | 0 .../contracts/eosio.token/eosio.token.wasm | Bin .../contracts/eosio.wrap/eosio.wrap.abi | 0 .../contracts/eosio.wrap/eosio.wrap.wasm | Bin .../contracts/telos.decide/decide.abi | 0 .../contracts/telos.decide/decide.wasm | Bin .../genesis/skynet.json | 0 skynet.ini.example | 5 + skynet/cli.py | 90 ++++++++++++- skynet/config.py | 18 +++ skynet/dgpu.py | 106 ++++++++++++++-- skynet/frontend/telegram.py | 21 ++-- skynet/nodeos.py | 92 ++++++++++++-- tests/contracts/telos.gpu/telos.gpu.abi | 118 ++++++++++++++++++ tests/contracts/telos.gpu/telos.gpu.wasm | Bin 21761 -> 44297 bytes tests/test_deploy.py | 46 ++----- 22 files changed, 434 insertions(+), 66 deletions(-) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/Dockerfile (79%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/config.ini (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.msig/eosio.msig.abi (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.msig/eosio.msig.wasm (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.system/eosio.system.abi (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.system/eosio.system.wasm (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.token/eosio.token.abi (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.token/eosio.token.wasm (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.wrap/eosio.wrap.abi (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/eosio.wrap/eosio.wrap.wasm (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/telos.decide/decide.abi (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/contracts/telos.decide/decide.wasm (100%) rename docker/{leap-skynet-4.0.0 => leap-skynet-4.0.1}/genesis/skynet.json (100%) diff --git a/docker/leap-skynet-4.0.0/Dockerfile b/docker/leap-skynet-4.0.1/Dockerfile similarity index 79% rename from docker/leap-skynet-4.0.0/Dockerfile rename to docker/leap-skynet-4.0.1/Dockerfile index d529cba..4cd0dec 100644 --- a/docker/leap-skynet-4.0.0/Dockerfile +++ b/docker/leap-skynet-4.0.1/Dockerfile @@ -5,9 +5,9 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y wget # install eosio tools -RUN wget https://github.com/AntelopeIO/leap/releases/download/v4.0.0/leap_4.0.0-ubuntu22.04_amd64.deb +RUN wget https://github.com/AntelopeIO/leap/releases/download/v4.0.1/leap_4.0.1-ubuntu22.04_amd64.deb -RUN apt-get install -y ./leap_4.0.0-ubuntu22.04_amd64.deb +RUN apt-get install -y ./leap_4.0.1-ubuntu22.04_amd64.deb RUN mkdir -p /root/nodeos WORKDIR /root/nodeos diff --git a/docker/leap-skynet-4.0.0/config.ini b/docker/leap-skynet-4.0.1/config.ini similarity index 100% rename from docker/leap-skynet-4.0.0/config.ini rename to docker/leap-skynet-4.0.1/config.ini diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.abi b/docker/leap-skynet-4.0.1/contracts/eosio.msig/eosio.msig.abi similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.abi rename to docker/leap-skynet-4.0.1/contracts/eosio.msig/eosio.msig.abi diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.wasm b/docker/leap-skynet-4.0.1/contracts/eosio.msig/eosio.msig.wasm similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.msig/eosio.msig.wasm rename to docker/leap-skynet-4.0.1/contracts/eosio.msig/eosio.msig.wasm diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.abi b/docker/leap-skynet-4.0.1/contracts/eosio.system/eosio.system.abi similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.abi rename to docker/leap-skynet-4.0.1/contracts/eosio.system/eosio.system.abi diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.wasm b/docker/leap-skynet-4.0.1/contracts/eosio.system/eosio.system.wasm similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.system/eosio.system.wasm rename to docker/leap-skynet-4.0.1/contracts/eosio.system/eosio.system.wasm diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.abi b/docker/leap-skynet-4.0.1/contracts/eosio.token/eosio.token.abi similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.abi rename to docker/leap-skynet-4.0.1/contracts/eosio.token/eosio.token.abi diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.wasm b/docker/leap-skynet-4.0.1/contracts/eosio.token/eosio.token.wasm similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.token/eosio.token.wasm rename to docker/leap-skynet-4.0.1/contracts/eosio.token/eosio.token.wasm diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.abi b/docker/leap-skynet-4.0.1/contracts/eosio.wrap/eosio.wrap.abi similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.abi rename to docker/leap-skynet-4.0.1/contracts/eosio.wrap/eosio.wrap.abi diff --git a/docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.wasm b/docker/leap-skynet-4.0.1/contracts/eosio.wrap/eosio.wrap.wasm similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/eosio.wrap/eosio.wrap.wasm rename to docker/leap-skynet-4.0.1/contracts/eosio.wrap/eosio.wrap.wasm diff --git a/docker/leap-skynet-4.0.0/contracts/telos.decide/decide.abi b/docker/leap-skynet-4.0.1/contracts/telos.decide/decide.abi similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/telos.decide/decide.abi rename to docker/leap-skynet-4.0.1/contracts/telos.decide/decide.abi diff --git a/docker/leap-skynet-4.0.0/contracts/telos.decide/decide.wasm b/docker/leap-skynet-4.0.1/contracts/telos.decide/decide.wasm similarity index 100% rename from docker/leap-skynet-4.0.0/contracts/telos.decide/decide.wasm rename to docker/leap-skynet-4.0.1/contracts/telos.decide/decide.wasm diff --git a/docker/leap-skynet-4.0.0/genesis/skynet.json b/docker/leap-skynet-4.0.1/genesis/skynet.json similarity index 100% rename from docker/leap-skynet-4.0.0/genesis/skynet.json rename to docker/leap-skynet-4.0.1/genesis/skynet.json diff --git a/skynet.ini.example b/skynet.ini.example index 7580e0f..1faf8b8 100644 --- a/skynet.ini.example +++ b/skynet.ini.example @@ -1,3 +1,8 @@ +[skynet.account] +name = xxxxxxxxxxxx +permission = active +key = EOSXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + [skynet.dgpu] hf_home = hf_home hf_token = hf_XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx diff --git a/skynet/cli.py b/skynet/cli.py index 34c8619..4e956f2 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -3,6 +3,7 @@ import os import json import logging +import random from typing import Optional from functools import partial @@ -88,13 +89,15 @@ def download(): @skynet.command() @click.option( - '--account', '-a', default='telegram1') + '--account', '-A', default=None) @click.option( - '--permission', '-p', default='active') + '--permission', '-p', default=None) @click.option( '--key', '-k', default=None) @click.option( '--node-url', '-n', default='http://skynet.ancap.tech') +@click.option( + '--reward', '-r', default='20.0000 GPU') @click.option('--algo', '-a', default='midj') @click.option( '--prompt', '-p', default='a red old tractor in a sunny wheat field') @@ -103,16 +106,22 @@ def download(): @click.option('--height', '-h', default=512) @click.option('--guidance', '-g', default=10) @click.option('--step', '-s', default=26) -@click.option('--seed', '-S', default=420) +@click.option('--seed', '-S', default=None) @click.option('--upscaler', '-U', default='x4') def enqueue( account: str, permission: str, key: str | None, node_url: str, + reward: str, **kwargs ): + key, account, permission = load_account_info( + key, account, permission) with open_cleos(node_url, key=key) as cleos: + if not kwargs['seed']: + kwargs['seed'] = random.randint(0, 10e9) + req = json.dumps({ 'method': 'diffuse', 'params': kwargs @@ -120,11 +129,11 @@ def enqueue( binary = '' ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, binary], f'{account}@{permission}' + 'telos.gpu', 'enqueue', [account, req, binary, reward], f'{account}@{permission}' ) - assert ec == 0 print(collect_stdout(out)) + assert ec == 0 @skynet.command() @@ -175,10 +184,73 @@ def dequeue( node_url: str, request_id: int ): + key, account, permission = load_account_info( + key, account, permission) with open_cleos(node_url, key=key) as cleos: ec, out = cleos.push_action( 'telos.gpu', 'dequeue', [account, request_id], f'{account}@{permission}' ) + + print(collect_stdout(out)) + assert ec == 0 + +@skynet.command() +@click.option( + '--account', '-a', default='telegram1') +@click.option( + '--permission', '-p', default='active') +@click.option( + '--key', '-k', default=None) +@click.option( + '--node-url', '-n', default='http://skynet.ancap.tech') +@click.option( + '--verifications', '-v', default=1) +@click.option( + '--token-contract', '-c', default='eosio.token') +@click.option( + '--token-symbol', '-S', default='4,GPU') +def config( + account: str, + permission: str, + key: str | None, + node_url: str, + verifications: int, + token_contract: str, + token_symbol: str +): + key, account, permission = load_account_info( + key, account, permission) + with open_cleos(node_url, key=key) as cleos: + ec, out = cleos.push_action( + 'telos.gpu', 'config', [verifications, token_contract, token_symbol], f'{account}@{permission}' + ) + + print(collect_stdout(out)) + assert ec == 0 + +@skynet.command() +@click.option( + '--account', '-a', default='telegram1') +@click.option( + '--permission', '-p', default='active') +@click.option( + '--key', '-k', default=None) +@click.option( + '--node-url', '-n', default='http://skynet.ancap.tech') +@click.argument('quantity') +def deposit( + account: str, + permission: str, + key: str | None, + node_url: str, + quantity: str +): + key, account, permission = load_account_info( + key, account, permission) + with open_cleos(node_url, key=key) as cleos: + ec, out = cleos.transfer_token(account, 'telos.gpu', quantity) + + print(collect_stdout(out)) assert ec == 0 @skynet.group() @@ -219,6 +291,10 @@ def dgpu( algos: list[str] ): from .dgpu import open_dgpu_node + + key, account, permission = load_account_info( + key, account, permission) + vtestnet = None try: dclient = docker.from_env() @@ -270,6 +346,10 @@ def telegram( db_user: str, db_pass: str ): + + key, account, permission = load_account_info( + key, account, permission) + _, _, tg_token, cfg = init_env_from_config() asyncio.run( run_skynet_telegram( diff --git a/skynet/config.py b/skynet/config.py index 91d6101..95bdddf 100644 --- a/skynet/config.py +++ b/skynet/config.py @@ -37,3 +37,21 @@ def init_env_from_config( tg_token = config['skynet.telegram']['token'] return hf_home, hf_token, tg_token, config + + +def load_account_info( + key, account, permission + file_path=DEFAULT_CONFIG_PATH +): + _, _, _, config = init_env_from_config() + + if not key: + key = config['skynet.account']['key'] + + if not account: + account = config['skynet.account']['name'] + + if not permission: + permission = config['skynet.account']['permission'] + + return diff --git a/skynet/dgpu.py b/skynet/dgpu.py index 01a5310..37820b4 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -16,7 +16,7 @@ import asks import torch from leap.cleos import CLEOS, default_nodeos_image -from leap.sugar import get_container +from leap.sugar import get_container, collect_stdout from diffusers import ( StableDiffusionPipeline, @@ -102,6 +102,9 @@ async def open_dgpu_node( logging.info(f'resized it to {image.size}') if algo not in models: + if algo not in ALGOS: + raise DGPUComputeError(f'Unknown algo \"{algo}\"') + logging.info(f'{algo} not in loaded models, swapping...') least_used = list(models.keys())[0] for model in models: @@ -169,6 +172,7 @@ async def open_dgpu_node( raise DGPUComputeError('Unsupported compute method') async def get_work_requests_last_hour(): + logging.info('get_work_requests_last_hour') return await cleos.aget_table( 'telos.gpu', 'telos.gpu', 'queue', index_position=2, @@ -177,10 +181,41 @@ async def open_dgpu_node( ) async def get_status_by_request_id(request_id: int): + logging.info('get_status_by_request_id') return await cleos.aget_table( 'telos.gpu', request_id, 'status') + async def get_global_config(): + logging.info('get_global_config') + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'config'))[0] + + def get_worker_balance(): + logging.info('get_worker_balance') + rows = cleos.get_table( + 'telos.gpu', 'telos.gpu', 'users', + index_position=1, + key_type='name', + lower_bound=account, + upper_bound=account + ) + if len(rows) == 1: + return rows[0]['balance'] + else: + return None + + async def get_user_nonce(user: str): + logging.info('get_user_nonce') + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'users', + index_position=1, + key_type='name', + lower_bound=user, + upper_bound=user + ))[0]['nonce'] + def begin_work(request_id: int): + logging.info('begin_work') ec, out = cleos.push_action( 'telos.gpu', 'workbegin', @@ -189,7 +224,35 @@ async def open_dgpu_node( ) assert ec == 0 + def cancel_work(request_id: int, reason: str): + logging.info('cancel_work') + ec, out = cleos.push_action( + 'telos.gpu', + 'workcancel', + [account, request_id, reason], + f'{account}@{permission}' + ) + assert ec == 0 + + def maybe_withdraw_all(): + logging.info('maybe_withdraw_all') + balance = get_worker_balance() + if not balance: + return + + balance_amount = float(balance.split(' ')[0]) + if balance_amount > 0: + ec, out = cleos.push_action( + 'telos.gpu', + 'withdraw', + [account, balance], + f'{account}@{permission}' + ) + logging.info(collect_stdout(out)) + assert ec == 0 + async def find_my_results(): + logging.info('find_my_results') return await cleos.aget_table( 'telos.gpu', 'telos.gpu', 'results', index_position=4, @@ -200,6 +263,7 @@ async def open_dgpu_node( ipfs_node = None def publish_on_ipfs(img_sha: str, raw_img: bytes): + logging.info('publish_on_ipfs') img = Image.open(io.BytesIO(raw_img)) img.save(f'tmp/ipfs-docker-staging/image.png') @@ -209,18 +273,30 @@ async def open_dgpu_node( return ipfs_hash - def submit_work(request_id: int, result_hash: str, ipfs_hash: str): + def submit_work( + request_id: int, + request_hash: str, + result_hash: str, + ipfs_hash: str + ): + logging.info('submit_work') ec, out = cleos.push_action( 'telos.gpu', 'submit', - [account, request_id, result_hash, ipfs_hash], + [account, request_id, request_hash, result_hash, ipfs_hash], f'{account}@{permission}' ) + + print(collect_stdout(out)) assert ec == 0 + config = await get_global_config() + with open_ipfs_node() as ipfs_node: try: while True: + maybe_withdraw_all() + queue = await get_work_requests_last_hour() for req in queue: @@ -232,11 +308,18 @@ async def open_dgpu_node( statuses = await get_status_by_request_id(rid) - if len(statuses) < 3: + if len(statuses) < config['verification_amount']: # parse request body = json.loads(req['body']) binary = bytes.fromhex(req['binary_data']) + hash_str = ( + str(await get_user_nonce(req['user'])) + + + req['body'] + ) + logging.info(f'hashing: {hash_str}') + request_hash = sha256(hash_str.encode('utf-8')).hexdigest() # TODO: validate request @@ -244,14 +327,19 @@ async def open_dgpu_node( logging.info(f'working on {body}') begin_work(rid) - img_sha, raw_img = gpu_compute_one( - body['method'], body['params'], binext=binary) - ipfs_hash = publish_on_ipfs(img_sha, raw_img) + try: + img_sha, raw_img = gpu_compute_one( + body['method'], body['params'], binext=binary) - submit_work(rid, img_sha, ipfs_hash) + ipfs_hash = publish_on_ipfs(img_sha, raw_img) - break + submit_work(rid, request_hash, img_sha, ipfs_hash) + + break + + except BaseException as e: + cancel_work(rid, str(e)) else: logging.info(f'request {rid} already beign worked on, skip...') diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 9f3dc2a..f3bef2f 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -88,6 +88,10 @@ async def run_skynet_telegram( bot = AsyncTeleBot(tg_token) logging.info(f'tg_token: {tg_token}') + async def get_global_config(): + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'config'))[0] + async with open_database_connection( db_user, db_pass, db_host ) as db_call: @@ -139,6 +143,7 @@ async def run_skynet_telegram( } }) + request_time = datetime.datetime.now().isoformat() ec, out = cleos.push_action( 'telos.gpu', 'enqueue', [account, req, ''], f'{account}@{permission}' ) @@ -150,17 +155,19 @@ async def run_skynet_telegram( request_id = int(out) logging.info(f'{request_id} enqueued.') + config = await get_global_config() + ipfs_hash = None sha_hash = None for i in range(60): - results = cleos.get_table( - 'telos.gpu', 'telos.gpu', 'results', - index_position=2, - key_type='i64', - lower_bound=request_id, - upper_bound=request_id + submits = await hyperion.aget_actions( + account=account, + filter='telos.gpu:submit', + sort='desc', + after=request_Time ) - if len(results) > 0: + actions = submits['actions'] + if len(actions) > 0: ipfs_hash = results[0]['ipfs_hash'] sha_hash = results[0]['result_hash'] break diff --git a/skynet/nodeos.py b/skynet/nodeos.py index 6ddd51f..af1ce00 100644 --- a/skynet/nodeos.py +++ b/skynet/nodeos.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 +import json import time import logging +from datetime import datetime from contextlib import contextmanager as cm import docker @@ -46,7 +48,7 @@ def open_nodeos(cleanup: bool = True): dclient = docker.from_env() vtestnet = get_container( dclient, - 'guilledk/py-eosio:leap-skynet-4.0.0', + 'guilledk/skynet:leap-4.0.1', name='skynet-nodeos', force_unique=True, detach=True, @@ -61,27 +63,76 @@ def open_nodeos(cleanup: bool = True): cleos.start_keosd() + priv, pub = cleos.create_key_pair() + logging.info(f'SUDO KEYS: {(priv, pub)}') + + cleos.setup_wallet(priv) + + genesis = json.dumps({ + "initial_timestamp": datetime.now().isoformat(), + "initial_key": pub, + "initial_configuration": { + "max_block_net_usage": 1048576, + "target_block_net_usage_pct": 1000, + "max_transaction_net_usage": 1048575, + "base_per_transaction_net_usage": 12, + "net_usage_leeway": 500, + "context_free_discount_net_usage_num": 20, + "context_free_discount_net_usage_den": 100, + "max_block_cpu_usage": 200000, + "target_block_cpu_usage_pct": 1000, + "max_transaction_cpu_usage": 150000, + "min_transaction_cpu_usage": 100, + "max_transaction_lifetime": 3600, + "deferred_trx_expiration_window": 600, + "max_transaction_delay": 3888000, + "max_inline_action_size": 4096, + "max_inline_action_depth": 4, + "max_authority_depth": 6 + } + }, indent=4) + + ec, out = cleos.run( + ['bash', '-c', f'echo \'{genesis}\' > /root/skynet.json']) + assert ec == 0 + + place_holder = 'EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1=KEY:5JnvSc6pewpHHuUHwvbJopsew6AKwiGnexwDRc2Pj2tbdw6iML9' + sig_provider = f'{pub}=KEY:{priv}' + nodeos_config_ini = '/root/nodeos/config.ini' + ec, out = cleos.run( + ['bash', '-c', f'sed -i -e \'s/{place_holder}/{sig_provider}/g\' {nodeos_config_ini}']) + assert ec == 0 + cleos.start_nodeos_from_config( - '/root/nodeos/config.ini', + nodeos_config_ini, data_dir='/root/nodeos/data', - genesis='/root/nodeos/genesis/skynet.json', + genesis='/root/skynet.json', state_plugin=True) time.sleep(0.5) - - public_dev_key = 'EOS5fLreY5Zq5owBhmNJTgQaLqQ4ufzXSTpStQakEyfxNFuUEgNs1' - cleos.setup_wallet('5JnvSc6pewpHHuUHwvbJopsew6AKwiGnexwDRc2Pj2tbdw6iML9') cleos.wait_blocks(1) cleos.boot_sequence(token_sym=Symbol('GPU', 4)) - cleos.new_account('telos.gpu', ram=300000, key=public_dev_key) + priv, pub = cleos.create_key_pair() + cleos.import_key(priv) + logging.info(f'GPU KEYS: {(priv, pub)}') + cleos.new_account('telos.gpu', ram=4200000, key=pub) for i in range(1, 4): + priv, pub = cleos.create_key_pair() + cleos.import_key(priv) + logging.info(f'testworker{i} KEYS: {(priv, pub)}') cleos.create_account_staked( - 'eosio', f'testworker{i}', key=public_dev_key) + 'eosio', f'testworker{i}', key=pub) + priv, pub = cleos.create_key_pair() + cleos.import_key(priv) + logging.info(f'TELEGRAM KEYS: {(priv, pub)}') cleos.create_account_staked( - 'eosio', 'telegram1', ram=500000, key=public_dev_key) + 'eosio', 'telegram', ram=500000, key=pub) + + cleos.transfer_token( + 'eosio', 'telegram', '1000000.0000 GPU', 'Initial testing funds') cleos.deploy_contract_from_host( 'telos.gpu', @@ -90,6 +141,29 @@ def open_nodeos(cleanup: bool = True): create_account=False ) + ec, out = cleos.push_action( + 'telos.gpu', + 'config', + [1, 'eosio.token', '4,GPU'], + f'telos.gpu@active' + ) + assert ec == 0 + + ec, out = cleos.transfer_token( + 'telegram', 'telos.gpu', '1000000.0000 GPU', 'Initial testing funds') + assert ec == 0 + + user_row = cleos.get_table( + 'telos.gpu', + 'telos.gpu', + 'users', + index_position=1, + key_type='name', + lower_bound='telegram', + upper_bound='telegram' + ) + assert len(user_row) == 1 + yield cleos finally: diff --git a/tests/contracts/telos.gpu/telos.gpu.abi b/tests/contracts/telos.gpu/telos.gpu.abi index 75240ee..406c268 100644 --- a/tests/contracts/telos.gpu/telos.gpu.abi +++ b/tests/contracts/telos.gpu/telos.gpu.abi @@ -3,6 +3,47 @@ "version": "eosio::abi/1.2", "types": [], "structs": [ + { + "name": "account", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "balance", + "type": "asset" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + }, + { + "name": "clean", + "base": "", + "fields": [] + }, + { + "name": "config", + "base": "", + "fields": [ + { + "name": "verification_amount", + "type": "uint8" + }, + { + "name": "token_contract", + "type": "name" + }, + { + "name": "token_symbol", + "type": "symbol" + } + ] + }, { "name": "dequeue", "base": "", @@ -32,6 +73,28 @@ { "name": "binary_data", "type": "bytes" + }, + { + "name": "reward", + "type": "asset" + } + ] + }, + { + "name": "global_configuration_struct", + "base": "", + "fields": [ + { + "name": "verification_amount", + "type": "uint8" + }, + { + "name": "token_contract", + "type": "name" + }, + { + "name": "token_symbol", + "type": "symbol" } ] }, @@ -47,6 +110,10 @@ "name": "request_id", "type": "uint64" }, + { + "name": "request_hash", + "type": "checksum256" + }, { "name": "result_hash", "type": "checksum256" @@ -57,6 +124,20 @@ } ] }, + { + "name": "withdraw", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, { "name": "work_request_struct", "base": "", @@ -69,6 +150,10 @@ "name": "user", "type": "name" }, + { + "name": "reward", + "type": "asset" + }, { "name": "body", "type": "string" @@ -142,6 +227,10 @@ { "name": "request_id", "type": "uint64" + }, + { + "name": "reason", + "type": "string" } ] }, @@ -165,6 +254,16 @@ } ], "actions": [ + { + "name": "clean", + "type": "clean", + "ricardian_contract": "" + }, + { + "name": "config", + "type": "config", + "ricardian_contract": "" + }, { "name": "dequeue", "type": "dequeue", @@ -180,6 +279,11 @@ "type": "submit", "ricardian_contract": "" }, + { + "name": "withdraw", + "type": "withdraw", + "ricardian_contract": "" + }, { "name": "workbegin", "type": "workbegin", @@ -192,6 +296,13 @@ } ], "tables": [ + { + "name": "config", + "type": "global_configuration_struct", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, { "name": "queue", "type": "work_request_struct", @@ -212,6 +323,13 @@ "index_type": "i64", "key_names": [], "key_types": [] + }, + { + "name": "users", + "type": "account", + "index_type": "i64", + "key_names": [], + "key_types": [] } ], "ricardian_clauses": [], diff --git a/tests/contracts/telos.gpu/telos.gpu.wasm b/tests/contracts/telos.gpu/telos.gpu.wasm index de8c6072459cd11de3bed0bb970b8e529be3394a..18dc265b2fdcba8074df182b4c7190f7a0936659 100755 GIT binary patch literal 44297 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ΝWA?KgS$BT`o1XgvG*T9wq6;|c$n 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 HSeX9@n1wEE literal 21761 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�r+2yon?)zDB)%q!%o*1OO_#dUi{17? z=W;$FYFy53_t?^^VfQ`m8e_fg$MzqYp?|vAUOIM|pFqs{S)a!B2d3w=(gEfKg!%oQ z_JQtFIy0Acr}rP`!CmPz?+>*X(*yH!-9>&0kddEfzyHZ-3CtQaasD z`FY0qnK>pt!Y?zXhi6!wp({_g#+B{G#rZ`5nLao(cXtX@i_`oPAaB`;XuMoMp}5rL z?GdKwwvQ|bY6zwN5DD4j8PsBID16lHpgAtieFw7KIKAFYJ1K> z_aiW8W{Mx8Y@c8)zN%Iy=QtIwE5n&QSS>lTWXD%Uv~rr%Ak$w ojv;#s2r6{+q6FRUnmNG_TJFEM+g_5WB0$Fuc%fqK*fq}m7wF7U%m4rY diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 17e1a15..3598788 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -15,8 +15,7 @@ from leap.sugar import collect_stdout def test_enqueue_work(cleos): - - user = cleos.new_account() + user = 'telegram' req = json.dumps({ 'method': 'diffuse', 'params': { @@ -33,7 +32,7 @@ def test_enqueue_work(cleos): binary = '' ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [user, req, binary], f'{user}@active' + 'telos.gpu', 'enqueue', [user, req, binary, '20.0000 GPU'], f'{user}@active' ) assert ec == 0 @@ -48,44 +47,23 @@ def test_enqueue_work(cleos): assert req_on_chain['body'] == req assert req_on_chain['binary_data'] == binary - ipfs_hash = None - sha_hash = None - for i in range(1, 4): - trio.run( - partial( - open_dgpu_node, - f'testworker{i}', - 'active', - cleos, - initial_algos=['midj'] - ) + trio.run( + partial( + open_dgpu_node, + f'testworker1', + 'active', + cleos, + initial_algos=['midj'] ) - - if ipfs_hash == None: - result = cleos.get_table( - 'telos.gpu', 'telos.gpu', 'results', - index_position=4, - key_type='name', - lower_bound=f'testworker{i}', - upper_bound=f'testworker{i}' - ) - assert len(result) == 1 - ipfs_hash = result[0]['ipfs_hash'] - sha_hash = result[0]['result_hash'] + ) queue = cleos.get_table('telos.gpu', 'telos.gpu', 'queue') assert len(queue) == 0 - resp = requests.get(f'https://ipfs.io/ipfs/{ipfs_hash}/image.png') - assert resp.status_code == 200 - - assert sha_hash == sha256(resp.content).hexdigest() - def test_enqueue_dequeue(cleos): - - user = cleos.new_account() + user = 'telegram' req = json.dumps({ 'method': 'diffuse', 'params': { @@ -102,7 +80,7 @@ def test_enqueue_dequeue(cleos): binary = '' ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [user, req, binary], f'{user}@active' + 'telos.gpu', 'enqueue', [user, req, binary, '20.0000 GPU'], f'{user}@active' ) assert ec == 0 From 5e017ffac01ac64a9b4c15465861d8516280e997 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 28 May 2023 18:23:51 -0300 Subject: [PATCH 05/88] Telegram frontend fixes and create pinner --- requirements.txt | 2 +- skynet/cli.py | 70 +++++++++- skynet/config.py | 4 +- skynet/db/functions.py | 65 ++------- skynet/dgpu.py | 4 +- skynet/frontend/telegram.py | 272 +++++++++++++++++++++--------------- skynet/ipfs.py | 9 ++ 7 files changed, 253 insertions(+), 173 deletions(-) diff --git a/requirements.txt b/requirements.txt index b6f2e30..7a8ec39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,4 @@ aiohttp psycopg2-binary pyTelegramBotAPI -py-leap@git+https://github.com/guilledk/py-leap.git@v0.1a11 +py-leap@git+https://github.com/guilledk/py-leap.git@v0.1a13 diff --git a/skynet/cli.py b/skynet/cli.py index 4e956f2..9c30c15 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -6,6 +6,7 @@ import logging import random from typing import Optional +from datetime import datetime, timedelta from functools import partial import trio @@ -16,8 +17,10 @@ import requests from leap.cleos import CLEOS, default_nodeos_image from leap.sugar import get_container, collect_stdout +from leap.hyperion import HyperionAPI from .db import open_new_database +from .ipfs import IPFSDocker from .config import * from .nodeos import open_cleos, open_nodeos from .constants import ALGOS @@ -91,7 +94,7 @@ def download(): @click.option( '--account', '-A', default=None) @click.option( - '--permission', '-p', default=None) + '--permission', '-P', default=None) @click.option( '--key', '-k', default=None) @click.option( @@ -266,7 +269,7 @@ def db(): @run.command() def nodeos(): - logging.basicConfig(level=logging.INFO) + logging.basicConfig(filename='skynet-nodeos.log', level=logging.INFO) with open_nodeos(cleanup=False): ... @@ -280,6 +283,8 @@ def nodeos(): '--key', '-k', default=None) @click.option( '--node-url', '-n', default='http://skynet.ancap.tech') +@click.option( + '--ipfs-url', '-n', default='/ip4/169.197.142.4/tcp/4001/p2p/12D3KooWKHKPFuqJPeqYgtUJtfZTHvEArRX2qvThYBrjuTuPg2Nx') @click.option( '--algos', '-A', default=json.dumps(['midj'])) def dgpu( @@ -288,6 +293,7 @@ def dgpu( permission: str, key: str | None, node_url: str, + ipfs_url: str, algos: list[str] ): from .dgpu import open_dgpu_node @@ -312,7 +318,9 @@ def dgpu( partial( open_dgpu_node, account, permission, - cleos, key=key, initial_algos=json.loads(algos) + cleos, + ipfs_url, + key=key, initial_algos=json.loads(algos) )) finally: @@ -323,11 +331,13 @@ def dgpu( @run.command() @click.option('--loglevel', '-l', default='warning', help='logging level') @click.option( - '--account', '-a', default='telegram1') + '--account', '-a', default='telegram') @click.option( '--permission', '-p', default='active') @click.option( '--key', '-k', default=None) +@click.option( + '--hyperion-url', '-n', default='http://test1.us.telos.net:42001') @click.option( '--node-url', '-n', default='http://skynet.ancap.tech') @click.option( @@ -342,6 +352,7 @@ def telegram( permission: str, key: str | None, node_url: str, + hyperion_url: str, db_host: str, db_user: str, db_pass: str @@ -357,6 +368,57 @@ def telegram( account, permission, node_url, + hyperion_url, db_host, db_user, db_pass, key=key )) + + +@run.command() +@click.option('--loglevel', '-l', default='warning', help='logging level') +@click.option( + '--container', '-c', default='ipfs_host') +@click.option( + '--hyperion-url', '-n', default='http://127.0.0.1:42001') +def pinner(loglevel, container): + dclient = docker.from_env() + + container = dclient.containers.get(conatiner) + ipfs_node = IPFSDocker(container) + + last_pinned: dict[str, datetime] = {} + + def cleanup_pinned(now: datetime): + for cid in last_pinned.keys(): + ts = last_pinned[cid] + if now - ts > timedelta(minutes=1): + del last_pinned[cid] + + try: + while True: + # get all submits in the last minute + now = dateimte.now() + half_min_ago = now - timedelta(seconds=30) + submits = hyperion.get_actions( + account='telos.gpu', + filter='telos.gpu:submit', + sort='desc', + after=half_min_ago.isoformat() + ) + + # filter for the ones not already pinned + actions = [ + action + for action in submits['actions'] + if action['act']['data']['ipfs_hash'] + not in last_pinned + ] + + # pin and remember + for action in actions: + cid = action['act']['data']['ipfs_hash'] + last_pinned[cid] = now + + ipfs_node.pin(cid) + + cleanup_pinned(now) diff --git a/skynet/config.py b/skynet/config.py index 95bdddf..ace1dd0 100644 --- a/skynet/config.py +++ b/skynet/config.py @@ -40,7 +40,7 @@ def init_env_from_config( def load_account_info( - key, account, permission + key, account, permission, file_path=DEFAULT_CONFIG_PATH ): _, _, _, config = init_env_from_config() @@ -54,4 +54,4 @@ def load_account_info( if not permission: permission = config['skynet.account']['permission'] - return + return key, account, permission diff --git a/skynet/db/functions.py b/skynet/db/functions.py index 4cea259..da35ae0 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -177,25 +177,10 @@ async def open_database_connection( yield _db_call -async def get_user(conn, uid: str): - if isinstance(uid, str): - proto, uid = try_decode_uid(uid) - - match proto: - case 'tg': - stmt = await conn.prepare( - 'SELECT * FROM skynet.user WHERE tg_id = $1') - user = await stmt.fetchval(uid) - - case _: - user = None - - return user - - else: # asumme is our uid - stmt = await conn.prepare( - 'SELECT * FROM skynet.user WHERE id = $1') - return await stmt.fetchval(uid) +async def get_user(conn, uid: int): + stmt = await conn.prepare( + 'SELECT * FROM skynet.user WHERE id = $1') + return await stmt.fetchval(uid) async def get_user_config(conn, user: int): @@ -210,44 +195,24 @@ async def get_last_prompt_of(conn, user: int): return await stmt.fetchval(user) -async def new_user(conn, uid: str): +async def new_user(conn, uid: int): if await get_user(conn, uid): raise ValueError('User already present on db') logging.info(f'new user! {uid}') date = datetime.utcnow() - - proto, pid = try_decode_uid(uid) - async with conn.transaction(): - match proto: - case 'tg': - tg_id = pid - stmt = await conn.prepare(''' - INSERT INTO skynet.user( - tg_id, generated, joined, last_prompt, role) + stmt = await conn.prepare(''' + INSERT INTO skynet.user( + id, generated, joined, last_prompt, role) - VALUES($1, $2, $3, $4, $5) - ON CONFLICT DO NOTHING - ''') - await stmt.fetch( - tg_id, 0, date, None, DEFAULT_ROLE - ) - new_uid = await get_user(conn, uid) - - case None: - stmt = await conn.prepare(''' - INSERT INTO skynet.user( - id, generated, joined, last_prompt, role) - - VALUES($1, $2, $3, $4, $5) - ON CONFLICT DO NOTHING - ''') - await stmt.fetch( - pid, 0, date, None, DEFAULT_ROLE - ) - new_uid = pid + VALUES($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING + ''') + await stmt.fetch( + uid, 0, date, None, DEFAULT_ROLE + ) stmt = await conn.prepare(''' INSERT INTO skynet.user_config( @@ -268,8 +233,6 @@ async def new_user(conn, uid: str): DEFAULT_UPSCALER ) - return new_uid - async def get_or_create_user(conn, uid: str): user = await get_user(conn, uid) diff --git a/skynet/dgpu.py b/skynet/dgpu.py index 37820b4..32c3675 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -57,8 +57,9 @@ async def open_dgpu_node( account: str, permission: str, cleos: CLEOS, + remote_ipfs_node: str, key: str = None, - initial_algos: Optional[List[str]] = None + initial_algos: Optional[List[str]] = None, ): logging.basicConfig(level=logging.INFO) @@ -293,6 +294,7 @@ async def open_dgpu_node( config = await get_global_config() with open_ipfs_node() as ipfs_node: + ipfs_node.connect(remote_ipfs_node) try: while True: maybe_withdraw_all() diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index f3bef2f..0fcf67a 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -5,6 +5,7 @@ import zlib import logging import asyncio +from hashlib import sha256 from datetime import datetime import docker @@ -18,6 +19,7 @@ from telebot.types import ( InputFile, InputMediaPhoto, InlineKeyboardButton, InlineKeyboardMarkup ) from telebot.async_telebot import AsyncTeleBot +from telebot.formatting import hlink from ..db import open_new_database, open_database_connection from ..constants import * @@ -44,25 +46,143 @@ def prepare_metainfo_caption(tguser, meta: dict) -> str: else: user = f'{tguser.first_name} id: {tguser.id}' - meta_str = f'by {user}\n' - meta_str += f'prompt: \"{prompt}\"\n' - meta_str += f'seed: {meta["seed"]}\n' - meta_str += f'step: {meta["step"]}\n' - meta_str += f'guidance: {meta["guidance"]}\n' + meta_str = f'by {user}\n' + + meta_str += f'prompt: {prompt}\n' + meta_str += f'seed: {meta["seed"]}\n' + meta_str += f'step: {meta["step"]}\n' + meta_str += f'guidance: {meta["guidance"]}\n' if meta['strength']: - meta_str += f'strength: {meta["strength"]}\n' - meta_str += f'algo: \"{meta["algo"]}\"\n' + meta_str += f'strength: {meta["strength"]}\n' + meta_str += f'algo: {meta["algo"]}\n' if meta['upscaler']: - meta_str += f'upscaler: \"{meta["upscaler"]}\"\n' - meta_str += f'skynet v{VERSION}' + meta_str += f'upscaler: {meta["upscaler"]}\n' + + meta_str += f'Made with Skynet {VERSION}\n' + meta_str += f'JOIN THE SWARM: @skynetgpu' return meta_str +def generate_reply_caption( + tguser, # telegram user + params: dict, + ipfs_hash: str, + tx_hash: str +): + ipfs_link = hlink( + 'Get your image on IPFS', + f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png' + ) + explorer_link = hlink( + 'SKYNET Transaction Explorer', + f'http://test1.us.telos.net:42001/v2/explore/transaction/{tx_hash}' + ) + + meta_info = prepare_metainfo_caption(tguser, params) + + final_msg = '\n'.join([ + 'Worker finished your task!', + ipfs_link, + explorer_link, + f'PARAMETER INFO:\n{meta_info}' + ]) + + final_msg = '\n'.join([ + f'{ipfs_link}', + f'{explorer_link}', + f'{meta_info}' + ]) + + logging.info(final_msg) + + return final_msg + + +async def get_global_config(cleos): + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'config'))[0] + +async def get_user_nonce(cleos, user: str): + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'users', + index_position=1, + key_type='name', + lower_bound=user, + upper_bound=user + ))[0]['nonce'] + +async def work_request( + bot, cleos, hyperion, + message, + account: str, + permission: str, + params: dict +): + body = json.dumps({ + 'method': 'diffuse', + 'params': params + }) + user = message.from_user + chat = message.chat + request_time = datetime.now().isoformat() + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [account, body, '', '20.0000 GPU'], f'{account}@{permission}' + ) + out = collect_stdout(out) + if ec != 0: + await bot.reply_to(message, out) + return + + nonce = await get_user_nonce(cleos, account) + request_hash = sha256( + (str(nonce) + body).encode('utf-8')).hexdigest().upper() + + request_id = int(out) + logging.info(f'{request_id} enqueued.') + + config = await get_global_config(cleos) + + tx_hash = None + ipfs_hash = None + for i in range(60): + submits = await hyperion.aget_actions( + account=account, + filter='telos.gpu:submit', + sort='desc', + after=request_time + ) + actions = [ + action + for action in submits['actions'] + if action[ + 'act']['data']['request_hash'] == request_hash + ] + if len(actions) > 0: + tx_hash = actions[0]['trx_id'] + ipfs_hash = actions[0]['act']['data']['ipfs_hash'] + break + + await asyncio.sleep(1) + + if not ipfs_hash: + await bot.reply_to(message, 'timeout processing request') + return + + await bot.reply_to( + message, + generate_reply_caption( + user, params, ipfs_hash, tx_hash), + reply_markup=build_redo_menu(), + parse_mode='HTML' + ) + + async def run_skynet_telegram( tg_token: str, account: str, permission: str, node_url: str, + hyperion_url: str, db_host: str, db_user: str, db_pass: str, @@ -78,7 +198,7 @@ async def run_skynet_telegram( remove=True) cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) - hyperion = HyperionAPI(node_url) + hyperion = HyperionAPI(hyperion_url) logging.basicConfig(level=logging.INFO) @@ -88,10 +208,6 @@ async def run_skynet_telegram( bot = AsyncTeleBot(tg_token) logging.info(f'tg_token: {tg_token}') - async def get_global_config(): - return (await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'config'))[0] - async with open_database_connection( db_user, db_pass, db_host ) as db_call: @@ -117,13 +233,12 @@ async def run_skynet_telegram( @bot.message_handler(commands=['txt2img']) async def send_txt2img(message): + user = message.from_user.id chat = message.chat reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: reply_id = message.message_id - user_id = f'tg+{message.from_user.id}' - prompt = ' '.join(message.text.split(' ')[1:]) if len(prompt) == 0: @@ -131,63 +246,25 @@ async def run_skynet_telegram( return logging.info(f'mid: {message.id}') - user = await db_call('get_or_create_user', user_id) + + await db_call('get_or_create_user', user) user_config = {**(await db_call('get_user_config', user))} del user_config['id'] - req = json.dumps({ - 'method': 'diffuse', - 'params': { - 'prompt': prompt, - **user_config - } - }) + params = { + 'prompt': prompt, + **user_config + } - request_time = datetime.datetime.now().isoformat() - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, ''], f'{account}@{permission}' - ) - out = collect_stdout(out) - if ec != 0: - await bot.reply_to(message, out) - return + await db_call('update_user_stats', user, last_prompt=prompt) - request_id = int(out) - logging.info(f'{request_id} enqueued.') - - config = await get_global_config() - - ipfs_hash = None - sha_hash = None - for i in range(60): - submits = await hyperion.aget_actions( - account=account, - filter='telos.gpu:submit', - sort='desc', - after=request_Time - ) - actions = submits['actions'] - if len(actions) > 0: - ipfs_hash = results[0]['ipfs_hash'] - sha_hash = results[0]['result_hash'] - break - else: - await asyncio.sleep(1) - - if not ipfs_hash: - await bot.reply_to(message, 'timeout processing request') - return - - ipfs_link = f'https://ipfs.io/ipfs/{ipfs_hash}/image.png' - - await bot.reply_to( - message, - ipfs_link, - reply_markup=build_redo_menu() - ) + await work_request( + bot, cleos, hyperion, + message, account, permission, params) @bot.message_handler(func=lambda message: True, content_types=['photo']) async def send_img2img(message): + user = message.from_user.id chat = message.chat reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: @@ -261,7 +338,7 @@ async def run_skynet_telegram( await bot.reply_to( message, ipfs_link + '\n' + - prepare_metainfo_caption(message.from_user, result['meta']['meta']), + prepare_metainfo_caption(user, result['meta']['meta']), reply_to_message_id=reply_id, reply_markup=build_redo_menu() ) @@ -277,6 +354,7 @@ async def run_skynet_telegram( @bot.message_handler(commands=['redo']) async def redo(message): + user = message.from_user.id chat = message.chat reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: @@ -286,65 +364,31 @@ async def run_skynet_telegram( del user_config['id'] prompt = await db_call('get_last_prompt_of', user) - req = json.dumps({ - 'method': 'diffuse', - 'params': { - 'prompt': prompt, - **user_config - } - }) - - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, ''], f'{account}@{permission}' - ) - if ec != 0: - await bot.reply_to(message, out) + if not prompt: + await bot.reply_to( + message, + 'no last prompt found, do a txt2img cmd first!' + ) return - request_id = int(out) - logging.info(f'{request_id} enqueued.') + params = { + 'prompt': prompt, + **user_config + } - ipfs_hash = None - sha_hash = None - for i in range(60): - result = cleos.get_table( - 'telos.gpu', 'telos.gpu', 'results', - index_position=2, - key_type='i64', - lower_bound=request_id, - upper_bound=request_id - ) - if len(results) > 0: - ipfs_hash = result[0]['ipfs_hash'] - sha_hash = result[0]['result_hash'] - break - else: - await asyncio.sleep(1) - - if not ipfs_hash: - await bot.reply_to(message, 'timeout processing request') - - ipfs_link = f'https://ipfs.io/ipfs/{ipfs_hash}/image.png' - - await bot.reply_to( - message, - ipfs_link + '\n' + - prepare_metainfo_caption(message.from_user, result['meta']['meta']), - reply_to_message_id=reply_id, - reply_markup=build_redo_menu() - ) - return + await work_request( + bot, cleos, hyperion, + message, account, permission, params) @bot.message_handler(commands=['config']) async def set_config(message): - rpc_params = {} + user = message.from_user.id try: attr, val, reply_txt = validate_user_config_request( message.text) logging.info(f'user config update: {attr} to {val}') - await db_call('update_user_config', - user, req.params['attr'], req.params['val']) + await db_call('update_user_config', user, attr, val) logging.info('done') except BaseException as e: diff --git a/skynet/ipfs.py b/skynet/ipfs.py index acd63c9..53a2c28 100644 --- a/skynet/ipfs.py +++ b/skynet/ipfs.py @@ -27,6 +27,15 @@ class IPFSDocker: ['ipfs', 'pin', 'add', ipfs_hash]) assert ec == 0 + def connect(self, remote_node: str): + ec, out = self._container.exec_run( + ['ipfs', 'swarm', 'connect', remote_node]) + if ec != 0: + logging.error(out) + + assert ec == 0 + + @cm def open_ipfs_node(): dclient = docker.from_env() From e63d395d5c48c58498a35fda043b825d2cd8e794 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 28 May 2023 20:17:55 -0300 Subject: [PATCH 06/88] Frontend DB fixes and starting to add img2img --- skynet/cli.py | 6 +- skynet/constants.py | 2 +- skynet/db/functions.py | 48 +++------ skynet/frontend/__init__.py | 7 +- skynet/frontend/telegram.py | 207 ++++++++++++++++++++++-------------- 5 files changed, 155 insertions(+), 115 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 9c30c15..6e4db55 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -329,7 +329,7 @@ def dgpu( @run.command() -@click.option('--loglevel', '-l', default='warning', help='logging level') +@click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option( '--account', '-a', default='telegram') @click.option( @@ -357,6 +357,7 @@ def telegram( db_user: str, db_pass: str ): + logging.basicConfig(level=loglevel) key, account, permission = load_account_info( key, account, permission) @@ -422,3 +423,6 @@ def pinner(loglevel, container): ipfs_node.pin(cid) cleanup_pinned(now) + + except KeyboardInterrupt: + ... diff --git a/skynet/constants.py b/skynet/constants.py index b590bcb..486edd3 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -102,7 +102,7 @@ MAX_WIDTH = 512 MAX_HEIGHT = 656 MAX_GUIDANCE = 20 -DEFAULT_SEED = 0 +DEFAULT_SEED = None DEFAULT_WIDTH = 512 DEFAULT_HEIGHT = 512 DEFAULT_GUIDANCE = 7.5 diff --git a/skynet/db/functions.py b/skynet/db/functions.py index da35ae0..2b8e192 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -26,27 +26,11 @@ CREATE SCHEMA IF NOT EXISTS skynet; CREATE TABLE IF NOT EXISTS skynet.user( id SERIAL PRIMARY KEY NOT NULL, - tg_id BIGINT, - wp_id VARCHAR(128), - mx_id VARCHAR(128), - ig_id VARCHAR(128), generated INT NOT NULL, - joined DATE NOT NULL, + joined TIMESTAMP NOT NULL, last_prompt TEXT, role VARCHAR(128) NOT NULL ); -ALTER TABLE skynet.user - ADD CONSTRAINT tg_unique - UNIQUE (tg_id); -ALTER TABLE skynet.user - ADD CONSTRAINT wp_unique - UNIQUE (wp_id); -ALTER TABLE skynet.user - ADD CONSTRAINT mx_unique - UNIQUE (mx_id); -ALTER TABLE skynet.user - ADD CONSTRAINT ig_unique - UNIQUE (ig_id); CREATE TABLE IF NOT EXISTS skynet.user_config( id SERIAL NOT NULL, @@ -54,7 +38,7 @@ CREATE TABLE IF NOT EXISTS skynet.user_config( step INT NOT NULL, width INT NOT NULL, height INT NOT NULL, - seed BIGINT NOT NULL, + seed BIGINT, guidance REAL NOT NULL, strength REAL NOT NULL, upscaler VARCHAR(128) @@ -177,16 +161,19 @@ async def open_database_connection( yield _db_call -async def get_user(conn, uid: int): - stmt = await conn.prepare( - 'SELECT * FROM skynet.user WHERE id = $1') - return await stmt.fetchval(uid) - - async def get_user_config(conn, user: int): stmt = await conn.prepare( 'SELECT * FROM skynet.user_config WHERE id = $1') - return (await stmt.fetch(user))[0] + conf = await stmt.fetch(user) + if len(conf) == 1: + return conf[0] + + else: + return None + + +async def get_user(conn, uid: int): + return await get_user_config(conn, uid) async def get_last_prompt_of(conn, user: int): @@ -208,7 +195,6 @@ async def new_user(conn, uid: int): id, generated, joined, last_prompt, role) VALUES($1, $2, $3, $4, $5) - ON CONFLICT DO NOTHING ''') await stmt.fetch( uid, 0, date, None, DEFAULT_ROLE @@ -216,18 +202,16 @@ async def new_user(conn, uid: int): stmt = await conn.prepare(''' INSERT INTO skynet.user_config( - id, algo, step, width, height, seed, guidance, strength, upscaler) + id, algo, step, width, height, guidance, strength, upscaler) - VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT DO NOTHING + VALUES($1, $2, $3, $4, $5, $6, $7, $8) ''') - user = await stmt.fetch( - new_uid, + resp = await stmt.fetch( + uid, DEFAULT_ALGO, DEFAULT_STEP, DEFAULT_WIDTH, DEFAULT_HEIGHT, - DEFAULT_SEED, DEFAULT_GUIDANCE, DEFAULT_STRENGTH, DEFAULT_UPSCALER diff --git a/skynet/frontend/__init__.py b/skynet/frontend/__init__.py index 19ea716..290b6b3 100644 --- a/skynet/frontend/__init__.py +++ b/skynet/frontend/__init__.py @@ -84,7 +84,12 @@ def validate_user_config_request(req: str): raise ConfigUnknownAttribute( f'\"{attr}\" not a configurable parameter') - return attr, val, f'config updated! {attr} to {val}' + display_val = val + if attr == 'seed': + if not val: + display_val = 'Random' + + return attr, val, f'config updated! {attr} to {display_val}' except ValueError: raise ValueError(f'\"{val}\" is not a number silly') diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 0fcf67a..c20de06 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -2,12 +2,15 @@ import io import zlib +import random import logging import asyncio +import traceback from hashlib import sha256 from datetime import datetime +import asks import docker from PIL import Image @@ -18,7 +21,9 @@ from trio_asyncio import aio_as_trio from telebot.types import ( InputFile, InputMediaPhoto, InlineKeyboardButton, InlineKeyboardMarkup ) -from telebot.async_telebot import AsyncTeleBot + +from telebot.types import CallbackQuery +from telebot.async_telebot import AsyncTeleBot, ExceptionHandler from telebot.formatting import hlink from ..db import open_new_database, open_database_connection @@ -27,7 +32,11 @@ from ..constants import * from . import * -PREFIX = 'tg' +class SKYExceptionHandler(ExceptionHandler): + + def handle(exception): + traceback.print_exc() + def build_redo_menu(): btn_redo = InlineKeyboardButton("Redo", callback_data=json.dumps({'method': 'redo'})) @@ -113,20 +122,36 @@ async def get_user_nonce(cleos, user: str): async def work_request( bot, cleos, hyperion, - message, + message, user, chat, account: str, permission: str, - params: dict + params: dict, + file_id: str | None = None, + file_path: str | None = None ): + if params['seed'] == None: + params['seed'] = random.randint(0, 9e18) + body = json.dumps({ 'method': 'diffuse', 'params': params }) - user = message.from_user - chat = message.chat request_time = datetime.now().isoformat() + + if file_id: + image_raw = await bot.download_file(file_path) + image = Image.open(io.BytesIO(image_raw)) + w, h = image.size + logging.info(f'user sent img of size {image.size}') + + if w > 512 or h > 512: + image.thumbnail((512, 512)) + logging.warning(f'resized it to {image.size}') + + binary = image_raw.hex() + ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, body, '', '20.0000 GPU'], f'{account}@{permission}' + 'telos.gpu', 'enqueue', [account, body, binary, '20.0000 GPU'], f'{account}@{permission}' ) out = collect_stdout(out) if ec != 0: @@ -168,13 +193,54 @@ async def work_request( await bot.reply_to(message, 'timeout processing request') return - await bot.reply_to( - message, - generate_reply_caption( - user, params, ipfs_hash, tx_hash), - reply_markup=build_redo_menu(), - parse_mode='HTML' - ) + # attempt to get the image and send it + ipfs_link = f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png' + logging.info(f'attempting to get image at {ipfs_link}') + resp = None + for i in range(10): + try: + resp = await asks.get(ipfs_link, timeout=2) + + except asks.errors.RequestTimeout: + logging.warning('timeout...') + ... + + logging.info(f'status_code: {resp.status_code}') + + caption = generate_reply_caption( + user, params, ipfs_hash, tx_hash) + + if resp.status_code != 200: + await bot.reply_to( + message, + caption, + reply_markup=build_redo_menu(), + parse_mode='HTML' + ) + + else: + if file_id: # img2img + await bot.send_media_group( + chat.id, + media=[ + InputMediaPhoto(file_id), + InputMediaPhoto( + resp.raw, + caption=caption + ) + ], + reply_markup=build_redo_menu(), + parse_mode='HTML' + ) + + else: # txt2img + await bot.send_photo( + chat.id, + caption=caption, + photo=resp.raw, + reply_markup=build_redo_menu(), + parse_mode='HTML' + ) async def run_skynet_telegram( @@ -205,7 +271,7 @@ async def run_skynet_telegram( if key: cleos.setup_wallet(key) - bot = AsyncTeleBot(tg_token) + bot = AsyncTeleBot(tg_token, exception_handler=SKYExceptionHandler) logging.info(f'tg_token: {tg_token}') async with open_database_connection( @@ -233,7 +299,7 @@ async def run_skynet_telegram( @bot.message_handler(commands=['txt2img']) async def send_txt2img(message): - user = message.from_user.id + user = message.from_user chat = message.chat reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: @@ -247,8 +313,8 @@ async def run_skynet_telegram( logging.info(f'mid: {message.id}') - await db_call('get_or_create_user', user) - user_config = {**(await db_call('get_user_config', user))} + user_row = await db_call('get_or_create_user', user.id) + user_config = {**user_row} del user_config['id'] params = { @@ -256,22 +322,22 @@ async def run_skynet_telegram( **user_config } - await db_call('update_user_stats', user, last_prompt=prompt) + await db_call('update_user_stats', user.id, last_prompt=prompt) await work_request( bot, cleos, hyperion, - message, account, permission, params) + message, user, chat, + account, permission, params + ) @bot.message_handler(func=lambda message: True, content_types=['photo']) async def send_img2img(message): - user = message.from_user.id + user = message.from_user chat = message.chat reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: reply_id = message.message_id - user_id = f'tg+{message.from_user.id}' - if not message.caption.startswith('/img2img'): await bot.reply_to( message, @@ -287,62 +353,26 @@ async def run_skynet_telegram( file_id = message.photo[-1].file_id file_path = (await bot.get_file(file_id)).file_path - file_raw = await bot.download_file(file_path) logging.info(f'mid: {message.id}') - user = await db_call('get_or_create_user', user_id) - user_config = {**(await db_call('get_user_config', user))} + user_row = await db_call('get_or_create_user', user.id) + user_config = {**user_row} del user_config['id'] - req = json.dumps({ - 'method': 'diffuse', - 'params': { - 'prompt': prompt, - **user_config - } - }) + params = { + 'prompt': prompt, + **user_config + } - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, file_raw.hex()], f'{account}@{permission}' + await db_call('update_user_stats', user.id, last_prompt=prompt) + + await work_request( + bot, cleos, hyperion, + message, user, chat, + account, permission, params, + file_id=file_id, file_path=file_path ) - if ec != 0: - await bot.reply_to(message, out) - return - - request_id = int(out) - logging.info(f'{request_id} enqueued.') - - ipfs_hash = None - sha_hash = None - for i in range(60): - result = cleos.get_table( - 'telos.gpu', 'telos.gpu', 'results', - index_position=2, - key_type='i64', - lower_bound=request_id, - upper_bound=request_id - ) - if len(results) > 0: - ipfs_hash = result[0]['ipfs_hash'] - sha_hash = result[0]['result_hash'] - break - else: - await asyncio.sleep(1) - - if not ipfs_hash: - await bot.reply_to(message, 'timeout processing request') - - ipfs_link = f'https://ipfs.io/ipfs/{ipfs_hash}/image.png' - - await bot.reply_to( - message, - ipfs_link + '\n' + - prepare_metainfo_caption(user, result['meta']['meta']), - reply_to_message_id=reply_id, - reply_markup=build_redo_menu() - ) - return @bot.message_handler(commands=['img2img']) @@ -352,17 +382,23 @@ async def run_skynet_telegram( 'seems you tried to do an img2img command without sending image' ) - @bot.message_handler(commands=['redo']) - async def redo(message): - user = message.from_user.id - chat = message.chat + async def _redo(message_or_query): + if isinstance(message_or_query, CallbackQuery): + query = message_or_query + message = query.message + user = query.from_user + chat = query.message.chat + + else: + message = message_or_query + user = message.from_user + chat = message.chat + reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: reply_id = message.message_id - user_config = {**(await db_call('get_user_config', user))} - del user_config['id'] - prompt = await db_call('get_last_prompt_of', user) + prompt = await db_call('get_last_prompt_of', user.id) if not prompt: await bot.reply_to( @@ -371,6 +407,11 @@ async def run_skynet_telegram( ) return + + user_row = await db_call('get_or_create_user', user.id) + user_config = {**user_row} + del user_config['id'] + params = { 'prompt': prompt, **user_config @@ -378,7 +419,13 @@ async def run_skynet_telegram( await work_request( bot, cleos, hyperion, - message, account, permission, params) + message, user, chat, + account, permission, params + ) + + @bot.message_handler(commands=['redo']) + async def redo(message): + await _redo(message) @bot.message_handler(commands=['config']) async def set_config(message): From a85518152a6d2f7958759ee6ae76eca901a02dc8 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 28 May 2023 20:44:47 -0300 Subject: [PATCH 07/88] Add frontend image reduction and fix ipfs sudo issue --- skynet/frontend/telegram.py | 18 ++++++++++++------ skynet/ipfs.py | 22 +++++++++++++++++++--- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index c20de06..5e579d4 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -140,16 +140,22 @@ async def work_request( if file_id: image_raw = await bot.download_file(file_path) - image = Image.open(io.BytesIO(image_raw)) - w, h = image.size - logging.info(f'user sent img of size {image.size}') + with Image.open(io.BytesIO(image_raw)) as image: + w, h = image.size - if w > 512 or h > 512: - image.thumbnail((512, 512)) - logging.warning(f'resized it to {image.size}') + if w > 512 or h > 512: + logging.warning(f'user sent img of size {image.size}') + image.thumbnail((512, 512)) + logging.warning(f'resized it to {image.size}') + img_byte_arr = io.BytesIO() + image.save(img_byte_arr, format='PNG') + image_raw = img_byte_arr.getvalue() binary = image_raw.hex() + else: + binary = '' + ec, out = cleos.push_action( 'telos.gpu', 'enqueue', [account, body, binary, '20.0000 GPU'], f'{account}@{permission}' ) diff --git a/skynet/ipfs.py b/skynet/ipfs.py index 53a2c28..1e56b52 100644 --- a/skynet/ipfs.py +++ b/skynet/ipfs.py @@ -1,5 +1,6 @@ #!/usr/bin/python +import os import logging from pathlib import Path @@ -7,6 +8,7 @@ from contextlib import contextmanager as cm import docker +from docker.types import Mount from docker.models.containers import Container @@ -48,13 +50,27 @@ def open_ipfs_node(): '4001/tcp': 4001, '5001/tcp': ('127.0.0.1', 5001) }, - volumes=[ - str(Path().resolve() / 'tmp/ipfs-docker-staging') + ':/export', - str(Path().resolve() / 'tmp/ipfs-docker-data') + ':/data/ipfs' + mounts=[ + Mount( + '/export', + str(Path().resolve() / 'tmp/ipfs-docker-staging'), + 'bind' + ), + Mount( + '/data/ipfs', + str(Path().resolve() / 'tmp/ipfs-docker-data'), + 'bind' + ) ], detach=True, remove=True ) + uid = os.getuid() + gid = os.getgid() + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', '/export']) + assert ec == 0 + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', '/data/ipfs']) + assert ec == 0 try: for log in container.logs(stream=True): From 303ed7b24f2c8be3cc5cbe1bd0c0edbc8dcb201e Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 28 May 2023 22:12:26 -0300 Subject: [PATCH 08/88] Add wait time on pinner and autocreate ipfs node directories on startup --- skynet/cli.py | 17 ++++++++++++----- skynet/ipfs.py | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 6e4db55..03b36ae 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -1,6 +1,7 @@ #!/usr/bin/python import os +import time import json import logging import random @@ -376,21 +377,23 @@ def telegram( @run.command() -@click.option('--loglevel', '-l', default='warning', help='logging level') +@click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option( '--container', '-c', default='ipfs_host') @click.option( '--hyperion-url', '-n', default='http://127.0.0.1:42001') -def pinner(loglevel, container): +def pinner(loglevel, container, hyperion_url): + logging.basicConfig(level=loglevel) dclient = docker.from_env() - container = dclient.containers.get(conatiner) + container = dclient.containers.get(container) ipfs_node = IPFSDocker(container) + hyperion = HyperionAPI(hyperion_url) last_pinned: dict[str, datetime] = {} def cleanup_pinned(now: datetime): - for cid in last_pinned.keys(): + for cid in set(last_pinned.keys()): ts = last_pinned[cid] if now - ts > timedelta(minutes=1): del last_pinned[cid] @@ -398,7 +401,7 @@ def pinner(loglevel, container): try: while True: # get all submits in the last minute - now = dateimte.now() + now = datetime.now() half_min_ago = now - timedelta(seconds=30) submits = hyperion.get_actions( account='telos.gpu', @@ -422,7 +425,11 @@ def pinner(loglevel, container): ipfs_node.pin(cid) + logging.info(f'pinned {cid}') + cleanup_pinned(now) + time.sleep(1) + except KeyboardInterrupt: ... diff --git a/skynet/ipfs.py b/skynet/ipfs.py index 1e56b52..002622c 100644 --- a/skynet/ipfs.py +++ b/skynet/ipfs.py @@ -42,6 +42,14 @@ class IPFSDocker: def open_ipfs_node(): dclient = docker.from_env() + staging_dir = (Path().resolve() / 'ipfs-docker-staging').mkdir( + parents=True, exist_ok=True) + data_dir = (Path().resolve() / 'ipfs-docker-data').mkdir( + parents=True, exist_ok=True) + + export_target = '/export' + data_target = '/data/ipfs' + container = dclient.containers.run( 'ipfs/go-ipfs:latest', name='skynet-ipfs', @@ -51,25 +59,17 @@ def open_ipfs_node(): '5001/tcp': ('127.0.0.1', 5001) }, mounts=[ - Mount( - '/export', - str(Path().resolve() / 'tmp/ipfs-docker-staging'), - 'bind' - ), - Mount( - '/data/ipfs', - str(Path().resolve() / 'tmp/ipfs-docker-data'), - 'bind' - ) + Mount(export_target, str(staging_dir), 'bind'), + Mount(data_target, str(data_dir), 'bind') ], detach=True, remove=True ) uid = os.getuid() gid = os.getgid() - ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', '/export']) + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', export_target]) assert ec == 0 - ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', '/data/ipfs']) + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', data_target]) assert ec == 0 try: From 22c403d3aee8aef514e1b2346fb2435e9eab4af3 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 00:46:47 -0300 Subject: [PATCH 09/88] Add autowithdraw switch, start storing input images on ipfs --- skynet/cli.py | 43 +++- skynet/dgpu.py | 26 ++- skynet/frontend/telegram.py | 419 ++++++++++++++++++------------------ skynet/ipfs.py | 88 +++++--- 4 files changed, 321 insertions(+), 255 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 03b36ae..a5d4601 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -282,6 +282,8 @@ def nodeos(): '--permission', '-p', default='active') @click.option( '--key', '-k', default=None) +@click.option( + '--auto-withdraw', '-w', default=True) @click.option( '--node-url', '-n', default='http://skynet.ancap.tech') @click.option( @@ -293,6 +295,7 @@ def dgpu( account: str, permission: str, key: str | None, + auto_withdraw: bool, node_url: str, ipfs_url: str, algos: list[str] @@ -321,6 +324,7 @@ def dgpu( account, permission, cleos, ipfs_url, + auto_withdraw=auto_withdraw, key=key, initial_algos=json.loads(algos) )) @@ -341,6 +345,8 @@ def dgpu( '--hyperion-url', '-n', default='http://test1.us.telos.net:42001') @click.option( '--node-url', '-n', default='http://skynet.ancap.tech') +@click.option( + '--ipfs-url', '-n', default='/ip4/169.197.142.4/tcp/4001/p2p/12D3KooWKHKPFuqJPeqYgtUJtfZTHvEArRX2qvThYBrjuTuPg2Nx') @click.option( '--db-host', '-h', default='localhost:5432') @click.option( @@ -352,8 +358,9 @@ def telegram( account: str, permission: str, key: str | None, - node_url: str, hyperion_url: str, + ipfs_url: str, + node_url: str, db_host: str, db_user: str, db_pass: str @@ -372,6 +379,7 @@ def telegram( node_url, hyperion_url, db_host, db_user, db_pass, + remote_ipfs_node=ipfs_url, key=key )) @@ -400,9 +408,19 @@ def pinner(loglevel, container, hyperion_url): try: while True: - # get all submits in the last minute now = datetime.now() half_min_ago = now - timedelta(seconds=30) + + # get all enqueues with binary data + # in the last minute + enqueues = hyperion.get_actions( + account='telos.gpu', + filter='telos.gpu:enqueue', + sort='desc', + after=half_min_ago.isoformat() + ) + + # get all submits in the last minute submits = hyperion.get_actions( account='telos.gpu', filter='telos.gpu:submit', @@ -411,16 +429,23 @@ def pinner(loglevel, container, hyperion_url): ) # filter for the ones not already pinned - actions = [ - action - for action in submits['actions'] - if action['act']['data']['ipfs_hash'] - not in last_pinned + cids = [ + *[ + action['act']['data']['binary_data'] + for action in enqueues['actions'] + if action['act']['data']['binary_data'] + not in last_pinned + ], + *[ + action['act']['data']['ipfs_hash'] + for action in submits['actions'] + if action['act']['data']['ipfs_hash'] + not in last_pinned + ] ] # pin and remember - for action in actions: - cid = action['act']['data']['ipfs_hash'] + for cid in cids: last_pinned[cid] = now ipfs_node.pin(cid) diff --git a/skynet/dgpu.py b/skynet/dgpu.py index 32c3675..e8c8490 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -27,7 +27,7 @@ from realesrgan import RealESRGANer from basicsr.archs.rrdbnet_arch import RRDBNet from diffusers.models import UNet2DConditionModel -from .ipfs import IPFSDocker, open_ipfs_node +from .ipfs import IPFSDocker, open_ipfs_node, get_ipfs_file from .utils import * from .constants import * @@ -60,6 +60,7 @@ async def open_dgpu_node( remote_ipfs_node: str, key: str = None, initial_algos: Optional[List[str]] = None, + auto_withdraw: bool = True ): logging.basicConfig(level=logging.INFO) @@ -103,7 +104,7 @@ async def open_dgpu_node( logging.info(f'resized it to {image.size}') if algo not in models: - if algo not in ALGOS: + if params['algo'] not in ALGOS: raise DGPUComputeError(f'Unknown algo \"{algo}\"') logging.info(f'{algo} not in loaded models, swapping...') @@ -266,7 +267,7 @@ async def open_dgpu_node( def publish_on_ipfs(img_sha: str, raw_img: bytes): logging.info('publish_on_ipfs') img = Image.open(io.BytesIO(raw_img)) - img.save(f'tmp/ipfs-docker-staging/image.png') + img.save(f'ipfs-docker-staging/image.png') ipfs_hash = ipfs_node.add('image.png') @@ -291,13 +292,24 @@ async def open_dgpu_node( print(collect_stdout(out)) assert ec == 0 + async def get_input_data(ipfs_hash: str) -> bytes: + if ipfs_hash == '': + return b'' + + resp = await get_ipfs_file(f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png') + if resp.status_code != 200: + raise DGPUComputeError('Couldn\'t gather input data from ipfs') + + return resp.raw + config = await get_global_config() with open_ipfs_node() as ipfs_node: ipfs_node.connect(remote_ipfs_node) try: while True: - maybe_withdraw_all() + if auto_withdraw: + maybe_withdraw_all() queue = await get_work_requests_last_hour() @@ -314,11 +326,15 @@ async def open_dgpu_node( # parse request body = json.loads(req['body']) - binary = bytes.fromhex(req['binary_data']) + + binary = await get_input_data(req['binary_data']) + hash_str = ( str(await get_user_nonce(req['user'])) + req['body'] + + + req['binary_data'] ) logging.info(f'hashing: {hash_str}') request_hash = sha256(hash_str.encode('utf-8')).hexdigest() diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 5e579d4..193a6fd 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -27,6 +27,7 @@ from telebot.async_telebot import AsyncTeleBot, ExceptionHandler from telebot.formatting import hlink from ..db import open_new_database, open_database_connection +from ..ipfs import open_ipfs_node, get_ipfs_file from ..constants import * from . import * @@ -45,7 +46,7 @@ def build_redo_menu(): return inline_keyboard -def prepare_metainfo_caption(tguser, meta: dict) -> str: +def prepare_metainfo_caption(tguser, worker: str, meta: dict) -> str: prompt = meta["prompt"] if len(prompt) > 256: prompt = prompt[:256] @@ -55,7 +56,7 @@ def prepare_metainfo_caption(tguser, meta: dict) -> str: else: user = f'{tguser.first_name} id: {tguser.id}' - meta_str = f'by {user}\n' + meta_str = f'by {user} performed by {worker}\n' meta_str += f'prompt: {prompt}\n' meta_str += f'seed: {meta["seed"]}\n' @@ -76,7 +77,8 @@ def generate_reply_caption( tguser, # telegram user params: dict, ipfs_hash: str, - tx_hash: str + tx_hash: str, + worker: str ): ipfs_link = hlink( 'Get your image on IPFS', @@ -87,7 +89,7 @@ def generate_reply_caption( f'http://test1.us.telos.net:42001/v2/explore/transaction/{tx_hash}' ) - meta_info = prepare_metainfo_caption(tguser, params) + meta_info = prepare_metainfo_caption(tguser, worker, params) final_msg = '\n'.join([ 'Worker finished your task!', @@ -126,6 +128,7 @@ async def work_request( account: str, permission: str, params: dict, + ipfs_node, file_id: str | None = None, file_path: str | None = None ): @@ -147,11 +150,15 @@ async def work_request( logging.warning(f'user sent img of size {image.size}') image.thumbnail((512, 512)) logging.warning(f'resized it to {image.size}') - img_byte_arr = io.BytesIO() - image.save(img_byte_arr, format='PNG') - image_raw = img_byte_arr.getvalue() - binary = image_raw.hex() + image.save(f'ipfs-docker-staging/image.png', format='PNG') + + ipfs_hash = ipfs_node.add('image.png') + ipfs_node.pin(ipfs_hash) + + logging.info(f'published input image {ipfs_hash} on ipfs') + + binary = ipfs_hash else: binary = '' @@ -166,7 +173,7 @@ async def work_request( nonce = await get_user_nonce(cleos, account) request_hash = sha256( - (str(nonce) + body).encode('utf-8')).hexdigest().upper() + (str(nonce) + body + binary).encode('utf-8')).hexdigest().upper() request_id = int(out) logging.info(f'{request_id} enqueued.') @@ -190,7 +197,9 @@ async def work_request( ] if len(actions) > 0: tx_hash = actions[0]['trx_id'] - ipfs_hash = actions[0]['act']['data']['ipfs_hash'] + data = actions[0]['act']['data'] + ipfs_hash = data['ipfs_hash'] + worker = data['worker'] break await asyncio.sleep(1) @@ -200,23 +209,14 @@ async def work_request( return # attempt to get the image and send it - ipfs_link = f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png' - logging.info(f'attempting to get image at {ipfs_link}') - resp = None - for i in range(10): - try: - resp = await asks.get(ipfs_link, timeout=2) - - except asks.errors.RequestTimeout: - logging.warning('timeout...') - ... - - logging.info(f'status_code: {resp.status_code}') + resp = await get_ipfs_file( + f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png') caption = generate_reply_caption( - user, params, ipfs_hash, tx_hash) + user, params, ipfs_hash, tx_hash, worker) if resp.status_code != 200: + logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') await bot.reply_to( message, caption, @@ -225,6 +225,7 @@ async def work_request( ) else: + logging.info(f'succes! sending generated image') if file_id: # img2img await bot.send_media_group( chat.id, @@ -258,6 +259,7 @@ async def run_skynet_telegram( db_host: str, db_user: str, db_pass: str, + remote_ipfs_node: str, key: str = None ): dclient = docker.from_env() @@ -280,224 +282,229 @@ async def run_skynet_telegram( bot = AsyncTeleBot(tg_token, exception_handler=SKYExceptionHandler) logging.info(f'tg_token: {tg_token}') - async with open_database_connection( - db_user, db_pass, db_host - ) as db_call: + with open_ipfs_node() as ipfs_node: + ipfs_node.connect(remote_ipfs_node) + async with open_database_connection( + db_user, db_pass, db_host + ) as db_call: - @bot.message_handler(commands=['help']) - async def send_help(message): - splt_msg = message.text.split(' ') + @bot.message_handler(commands=['help']) + async def send_help(message): + splt_msg = message.text.split(' ') - if len(splt_msg) == 1: - await bot.reply_to(message, HELP_TEXT) - - else: - param = splt_msg[1] - if param in HELP_TOPICS: - await bot.reply_to(message, HELP_TOPICS[param]) + if len(splt_msg) == 1: + await bot.reply_to(message, HELP_TEXT) else: - await bot.reply_to(message, HELP_UNKWNOWN_PARAM) + param = splt_msg[1] + if param in HELP_TOPICS: + await bot.reply_to(message, HELP_TOPICS[param]) - @bot.message_handler(commands=['cool']) - async def send_cool_words(message): - await bot.reply_to(message, '\n'.join(COOL_WORDS)) + else: + await bot.reply_to(message, HELP_UNKWNOWN_PARAM) - @bot.message_handler(commands=['txt2img']) - async def send_txt2img(message): - user = message.from_user - chat = message.chat - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id + @bot.message_handler(commands=['cool']) + async def send_cool_words(message): + await bot.reply_to(message, '\n'.join(COOL_WORDS)) - prompt = ' '.join(message.text.split(' ')[1:]) - - if len(prompt) == 0: - await bot.reply_to(message, 'Empty text prompt ignored.') - return - - logging.info(f'mid: {message.id}') - - user_row = await db_call('get_or_create_user', user.id) - user_config = {**user_row} - del user_config['id'] - - params = { - 'prompt': prompt, - **user_config - } - - await db_call('update_user_stats', user.id, last_prompt=prompt) - - await work_request( - bot, cleos, hyperion, - message, user, chat, - account, permission, params - ) - - @bot.message_handler(func=lambda message: True, content_types=['photo']) - async def send_img2img(message): - user = message.from_user - chat = message.chat - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id - - if not message.caption.startswith('/img2img'): - await bot.reply_to( - message, - 'For image to image you need to add /img2img to the beggining of your caption' - ) - return - - prompt = ' '.join(message.caption.split(' ')[1:]) - - if len(prompt) == 0: - await bot.reply_to(message, 'Empty text prompt ignored.') - return - - file_id = message.photo[-1].file_id - file_path = (await bot.get_file(file_id)).file_path - - logging.info(f'mid: {message.id}') - - user_row = await db_call('get_or_create_user', user.id) - user_config = {**user_row} - del user_config['id'] - - params = { - 'prompt': prompt, - **user_config - } - - await db_call('update_user_stats', user.id, last_prompt=prompt) - - await work_request( - bot, cleos, hyperion, - message, user, chat, - account, permission, params, - file_id=file_id, file_path=file_path - ) - - - @bot.message_handler(commands=['img2img']) - async def img2img_missing_image(message): - await bot.reply_to( - message, - 'seems you tried to do an img2img command without sending image' - ) - - async def _redo(message_or_query): - if isinstance(message_or_query, CallbackQuery): - query = message_or_query - message = query.message - user = query.from_user - chat = query.message.chat - - else: - message = message_or_query + @bot.message_handler(commands=['txt2img']) + async def send_txt2img(message): user = message.from_user chat = message.chat + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id + prompt = ' '.join(message.text.split(' ')[1:]) - prompt = await db_call('get_last_prompt_of', user.id) + if len(prompt) == 0: + await bot.reply_to(message, 'Empty text prompt ignored.') + return - if not prompt: + logging.info(f'mid: {message.id}') + + user_row = await db_call('get_or_create_user', user.id) + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await db_call('update_user_stats', user.id, last_prompt=prompt) + + await work_request( + bot, cleos, hyperion, + message, user, chat, + account, permission, params, + ipfs_node + ) + + @bot.message_handler(func=lambda message: True, content_types=['photo']) + async def send_img2img(message): + user = message.from_user + chat = message.chat + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id + + if not message.caption.startswith('/img2img'): + await bot.reply_to( + message, + 'For image to image you need to add /img2img to the beggining of your caption' + ) + return + + prompt = ' '.join(message.caption.split(' ')[1:]) + + if len(prompt) == 0: + await bot.reply_to(message, 'Empty text prompt ignored.') + return + + file_id = message.photo[-1].file_id + file_path = (await bot.get_file(file_id)).file_path + + logging.info(f'mid: {message.id}') + + user_row = await db_call('get_or_create_user', user.id) + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await db_call('update_user_stats', user.id, last_prompt=prompt) + + await work_request( + bot, cleos, hyperion, + message, user, chat, + account, permission, params, + ipfs_node, + file_id=file_id, file_path=file_path + ) + + + @bot.message_handler(commands=['img2img']) + async def img2img_missing_image(message): await bot.reply_to( message, - 'no last prompt found, do a txt2img cmd first!' + 'seems you tried to do an img2img command without sending image' ) - return + + async def _redo(message_or_query): + if isinstance(message_or_query, CallbackQuery): + query = message_or_query + message = query.message + user = query.from_user + chat = query.message.chat + + else: + message = message_or_query + user = message.from_user + chat = message.chat + + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id + + prompt = await db_call('get_last_prompt_of', user.id) + + if not prompt: + await bot.reply_to( + message, + 'no last prompt found, do a txt2img cmd first!' + ) + return - user_row = await db_call('get_or_create_user', user.id) - user_config = {**user_row} - del user_config['id'] + user_row = await db_call('get_or_create_user', user.id) + user_config = {**user_row} + del user_config['id'] - params = { - 'prompt': prompt, - **user_config - } + params = { + 'prompt': prompt, + **user_config + } - await work_request( - bot, cleos, hyperion, - message, user, chat, - account, permission, params - ) + await work_request( + bot, cleos, hyperion, + message, user, chat, + account, permission, params, + ipfs_node + ) - @bot.message_handler(commands=['redo']) - async def redo(message): - await _redo(message) + @bot.message_handler(commands=['redo']) + async def redo(message): + await _redo(message) - @bot.message_handler(commands=['config']) - async def set_config(message): - user = message.from_user.id - try: - attr, val, reply_txt = validate_user_config_request( - message.text) + @bot.message_handler(commands=['config']) + async def set_config(message): + user = message.from_user.id + try: + attr, val, reply_txt = validate_user_config_request( + message.text) - logging.info(f'user config update: {attr} to {val}') - await db_call('update_user_config', user, attr, val) - logging.info('done') + logging.info(f'user config update: {attr} to {val}') + await db_call('update_user_config', user, attr, val) + logging.info('done') - except BaseException as e: - reply_txt = str(e) + except BaseException as e: + reply_txt = str(e) - finally: - await bot.reply_to(message, reply_txt) + finally: + await bot.reply_to(message, reply_txt) - @bot.message_handler(commands=['stats']) - async def user_stats(message): - user = message.from_user.id + @bot.message_handler(commands=['stats']) + async def user_stats(message): + user = message.from_user.id - generated, joined, role = await db_call('get_user_stats', user) + generated, joined, role = await db_call('get_user_stats', user) - stats_str = f'generated: {generated}\n' - stats_str += f'joined: {joined}\n' - stats_str += f'role: {role}\n' + stats_str = f'generated: {generated}\n' + stats_str += f'joined: {joined}\n' + stats_str += f'role: {role}\n' - await bot.reply_to( - message, stats_str) + await bot.reply_to( + message, stats_str) - @bot.message_handler(commands=['donate']) - async def donation_info(message): - await bot.reply_to( - message, DONATION_INFO) + @bot.message_handler(commands=['donate']) + async def donation_info(message): + await bot.reply_to( + message, DONATION_INFO) - @bot.message_handler(commands=['say']) - async def say(message): - chat = message.chat - user = message.from_user + @bot.message_handler(commands=['say']) + async def say(message): + chat = message.chat + user = message.from_user - if (chat.type == 'group') or (user.id != 383385940): - return + if (chat.type == 'group') or (user.id != 383385940): + return - await bot.send_message(GROUP_ID, message.text[4:]) + await bot.send_message(GROUP_ID, message.text[4:]) - @bot.message_handler(func=lambda message: True) - async def echo_message(message): - if message.text[0] == '/': - await bot.reply_to(message, UNKNOWN_CMD_TEXT) + @bot.message_handler(func=lambda message: True) + async def echo_message(message): + if message.text[0] == '/': + await bot.reply_to(message, UNKNOWN_CMD_TEXT) - @bot.callback_query_handler(func=lambda call: True) - async def callback_query(call): - msg = json.loads(call.data) - logging.info(call.data) - method = msg.get('method') - match method: - case 'redo': - await _redo(call) + @bot.callback_query_handler(func=lambda call: True) + async def callback_query(call): + msg = json.loads(call.data) + logging.info(call.data) + method = msg.get('method') + match method: + case 'redo': + await _redo(call) - try: - await bot.infinity_polling() + try: + await bot.infinity_polling() - except KeyboardInterrupt: - ... + except KeyboardInterrupt: + ... - finally: - vtestnet.stop() + finally: + vtestnet.stop() diff --git a/skynet/ipfs.py b/skynet/ipfs.py index 002622c..79322be 100644 --- a/skynet/ipfs.py +++ b/skynet/ipfs.py @@ -6,12 +6,31 @@ import logging from pathlib import Path from contextlib import contextmanager as cm +import asks import docker +from asks.errors import RequestTimeout from docker.types import Mount from docker.models.containers import Container +async def get_ipfs_file(ipfs_link: str): + logging.info(f'attempting to get image at {ipfs_link}') + resp = None + for i in range(10): + try: + resp = await asks.get(ipfs_link, timeout=3) + + except asks.errors.RequestTimeout: + logging.warning('timeout...') + + if resp: + logging.info(f'status_code: {resp.status_code}') + else: + logging.error(f'timeout') + return resp + + class IPFSDocker: def __init__(self, container: Container): @@ -39,39 +58,42 @@ class IPFSDocker: @cm -def open_ipfs_node(): +def open_ipfs_node(name='skynet-ipfs'): dclient = docker.from_env() - staging_dir = (Path().resolve() / 'ipfs-docker-staging').mkdir( - parents=True, exist_ok=True) - data_dir = (Path().resolve() / 'ipfs-docker-data').mkdir( - parents=True, exist_ok=True) - - export_target = '/export' - data_target = '/data/ipfs' - - container = dclient.containers.run( - 'ipfs/go-ipfs:latest', - name='skynet-ipfs', - ports={ - '8080/tcp': 8080, - '4001/tcp': 4001, - '5001/tcp': ('127.0.0.1', 5001) - }, - mounts=[ - Mount(export_target, str(staging_dir), 'bind'), - Mount(data_target, str(data_dir), 'bind') - ], - detach=True, - remove=True - ) - uid = os.getuid() - gid = os.getgid() - ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', export_target]) - assert ec == 0 - ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', data_target]) - assert ec == 0 try: + container = dclient.containers.get(name) + + except docker.errors.NotFound: + staging_dir = Path().resolve() / 'ipfs-docker-staging' + staging_dir.mkdir(parents=True, exist_ok=True) + + data_dir = Path().resolve() / 'ipfs-docker-data' + data_dir.mkdir(parents=True, exist_ok=True) + + export_target = '/export' + data_target = '/data/ipfs' + + container = dclient.containers.run( + 'ipfs/go-ipfs:latest', + name='skynet-ipfs', + ports={ + '8080/tcp': 8080, + '4001/tcp': 4001, + '5001/tcp': ('127.0.0.1', 5001) + }, + mounts=[ + Mount(export_target, str(staging_dir), 'bind'), + Mount(data_target, str(data_dir), 'bind') + ], + detach=True + ) + uid = os.getuid() + gid = os.getgid() + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', export_target]) + assert ec == 0 + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', data_target]) + assert ec == 0 for log in container.logs(stream=True): log = log.decode().rstrip() @@ -79,9 +101,5 @@ def open_ipfs_node(): if 'Daemon is ready' in log: break - yield IPFSDocker(container) - - finally: - if container: - container.stop() + yield IPFSDocker(container) From 2b18fa376be3a81671baaedce221bae4b4497c07 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 12:42:55 -0300 Subject: [PATCH 10/88] Add redo support to img2img also switch pinner to use http api --- skynet/cli.py | 97 ++++++++++++++----------- skynet/db/functions.py | 60 ++++++++++++---- skynet/frontend/telegram.py | 140 ++++++++++++++++++++++-------------- 3 files changed, 189 insertions(+), 108 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index a5d4601..1d19ae3 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -384,22 +384,66 @@ def telegram( )) +class IPFSHTTP: + + def __init__(self, endpoint: str): + self.endpoint = endpoint + + def pin(self, cid: str): + return requests.post( + f'{self.endpoint}/api/v0/pin/add', + params={'arg': cid} + ) + + @run.command() @click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option( - '--container', '-c', default='ipfs_host') + '--ipfs-rpc', '-i', default='http://127.0.0.1:5001') @click.option( '--hyperion-url', '-n', default='http://127.0.0.1:42001') -def pinner(loglevel, container, hyperion_url): +def pinner(loglevel, ipfs_rpc, hyperion_url): logging.basicConfig(level=loglevel) - dclient = docker.from_env() - - container = dclient.containers.get(container) - ipfs_node = IPFSDocker(container) + ipfs_node = IPFSHTTP(ipfs_rpc) hyperion = HyperionAPI(hyperion_url) last_pinned: dict[str, datetime] = {} + def capture_enqueues(half_min_ago: datetime): + # get all enqueues with binary data + # in the last minute + enqueues = hyperion.get_actions( + account='telos.gpu', + filter='telos.gpu:enqueue', + sort='desc', + after=half_min_ago.isoformat() + ) + + cids = [] + for action in enqueues['actions']: + cid = action['act']['data']['binary_data'] + if cid and cid not in last_pinned: + cids.append(cid) + + return cids + + def capture_submits(half_min_ago: datetime): + # get all submits in the last minute + submits = hyperion.get_actions( + account='telos.gpu', + filter='telos.gpu:submit', + sort='desc', + after=half_min_ago.isoformat() + ) + + cids = [] + for action in submits['actions']: + cid = action['act']['data']['ipfs_hash'] + if cid and cid not in last_pinned: + cids.append(cid) + + return cids + def cleanup_pinned(now: datetime): for cid in set(last_pinned.keys()): ts = last_pinned[cid] @@ -411,50 +455,23 @@ def pinner(loglevel, container, hyperion_url): now = datetime.now() half_min_ago = now - timedelta(seconds=30) - # get all enqueues with binary data - # in the last minute - enqueues = hyperion.get_actions( - account='telos.gpu', - filter='telos.gpu:enqueue', - sort='desc', - after=half_min_ago.isoformat() - ) - - # get all submits in the last minute - submits = hyperion.get_actions( - account='telos.gpu', - filter='telos.gpu:submit', - sort='desc', - after=half_min_ago.isoformat() - ) - # filter for the ones not already pinned - cids = [ - *[ - action['act']['data']['binary_data'] - for action in enqueues['actions'] - if action['act']['data']['binary_data'] - not in last_pinned - ], - *[ - action['act']['data']['ipfs_hash'] - for action in submits['actions'] - if action['act']['data']['ipfs_hash'] - not in last_pinned - ] - ] + cids = [*capture_enqueues(half_min_ago), *capture_submits(half_min_ago)] # pin and remember for cid in cids: last_pinned[cid] = now - ipfs_node.pin(cid) + resp = ipfs_node.pin(cid) + if resp.status_code != 200: + logging.error(f'error pinning {cid}:\n{resp.text}') - logging.info(f'pinned {cid}') + else: + logging.info(f'pinned {cid}') cleanup_pinned(now) - time.sleep(1) + time.sleep(0.1) except KeyboardInterrupt: ... diff --git a/skynet/db/functions.py b/skynet/db/functions.py index 2b8e192..08067ae 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -25,11 +25,14 @@ DB_INIT_SQL = ''' CREATE SCHEMA IF NOT EXISTS skynet; CREATE TABLE IF NOT EXISTS skynet.user( - id SERIAL PRIMARY KEY NOT NULL, - generated INT NOT NULL, - joined TIMESTAMP NOT NULL, - last_prompt TEXT, - role VARCHAR(128) NOT NULL + id SERIAL PRIMARY KEY NOT NULL, + generated INT NOT NULL, + joined TIMESTAMP NOT NULL, + last_method TEXT, + last_prompt TEXT, + last_file TEXT, + last_binary TEXT, + role VARCHAR(128) NOT NULL ); CREATE TABLE IF NOT EXISTS skynet.user_config( @@ -175,12 +178,26 @@ async def get_user_config(conn, user: int): async def get_user(conn, uid: int): return await get_user_config(conn, uid) +async def get_last_method_of(conn, user: int): + stmt = await conn.prepare( + 'SELECT last_method FROM skynet.user WHERE id = $1') + return await stmt.fetchval(user) async def get_last_prompt_of(conn, user: int): stmt = await conn.prepare( 'SELECT last_prompt FROM skynet.user WHERE id = $1') return await stmt.fetchval(user) +async def get_last_file_of(conn, user: int): + stmt = await conn.prepare( + 'SELECT last_file FROM skynet.user WHERE id = $1') + return await stmt.fetchval(user) + +async def get_last_binary_of(conn, user: int): + stmt = await conn.prepare( + 'SELECT last_binary FROM skynet.user WHERE id = $1') + return await stmt.fetchval(user) + async def new_user(conn, uid: int): if await get_user(conn, uid): @@ -192,12 +209,15 @@ async def new_user(conn, uid: int): async with conn.transaction(): stmt = await conn.prepare(''' INSERT INTO skynet.user( - id, generated, joined, last_prompt, role) + id, generated, joined, + last_method, last_prompt, last_file, last_binary, + role + ) - VALUES($1, $2, $3, $4, $5) + VALUES($1, $2, $3, $4, $5, $6, $7, $8) ''') await stmt.fetch( - uid, 0, date, None, DEFAULT_ROLE + uid, 0, date, 'txt2img', None, None, None, DEFAULT_ROLE ) stmt = await conn.prepare(''' @@ -222,7 +242,8 @@ async def get_or_create_user(conn, uid: str): user = await get_user(conn, uid) if not user: - user = await new_user(conn, uid) + await new_user(conn, uid) + user = await get_user(conn, uid) return user @@ -253,11 +274,7 @@ async def get_user_stats(conn, user: int): record = records[0] return record -async def update_user_stats( - conn, - user: int, - last_prompt: Optional[str] = None -): +async def increment_generated(conn, user: int): stmt = await conn.prepare(''' UPDATE skynet.user SET generated = generated + 1 @@ -265,5 +282,20 @@ async def update_user_stats( ''') await stmt.fetch(user) +async def update_user_stats( + conn, + user: int, + method: str, + last_prompt: str | None = None, + last_file: str | None = None, + last_binary: str | None = None +): + await update_user(conn, user, 'last_method', method) if last_prompt: await update_user(conn, user, 'last_prompt', last_prompt) + if last_file: + await update_user(conn, user, 'last_file', last_file) + if last_binary: + await update_user(conn, user, 'last_binary', last_binary) + + logging.info((method, last_prompt, last_binary)) diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 193a6fd..3a9c964 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -128,9 +128,8 @@ async def work_request( account: str, permission: str, params: dict, - ipfs_node, file_id: str | None = None, - file_path: str | None = None + binary_data: str = '' ): if params['seed'] == None: params['seed'] = random.randint(0, 9e18) @@ -141,30 +140,8 @@ async def work_request( }) request_time = datetime.now().isoformat() - if file_id: - image_raw = await bot.download_file(file_path) - with Image.open(io.BytesIO(image_raw)) as image: - w, h = image.size - - if w > 512 or h > 512: - logging.warning(f'user sent img of size {image.size}') - image.thumbnail((512, 512)) - logging.warning(f'resized it to {image.size}') - - image.save(f'ipfs-docker-staging/image.png', format='PNG') - - ipfs_hash = ipfs_node.add('image.png') - ipfs_node.pin(ipfs_hash) - - logging.info(f'published input image {ipfs_hash} on ipfs') - - binary = ipfs_hash - - else: - binary = '' - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, body, binary, '20.0000 GPU'], f'{account}@{permission}' + 'telos.gpu', 'enqueue', [account, body, binary_data, '20.0000 GPU'], f'{account}@{permission}' ) out = collect_stdout(out) if ec != 0: @@ -173,7 +150,7 @@ async def work_request( nonce = await get_user_nonce(cleos, account) request_hash = sha256( - (str(nonce) + body + binary).encode('utf-8')).hexdigest().upper() + (str(nonce) + body + binary_data).encode('utf-8')).hexdigest().upper() request_id = int(out) logging.info(f'{request_id} enqueued.') @@ -209,13 +186,13 @@ async def work_request( return # attempt to get the image and send it - resp = await get_ipfs_file( - f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png') + ipfs_link = f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png' + resp = await get_ipfs_file(ipfs_link) caption = generate_reply_caption( user, params, ipfs_hash, tx_hash, worker) - if resp.status_code != 200: + if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') await bot.reply_to( message, @@ -233,11 +210,10 @@ async def work_request( InputMediaPhoto(file_id), InputMediaPhoto( resp.raw, - caption=caption + caption=caption, + parse_mode='HTML' ) ], - reply_markup=build_redo_menu(), - parse_mode='HTML' ) else: # txt2img @@ -307,10 +283,18 @@ async def run_skynet_telegram( async def send_cool_words(message): await bot.reply_to(message, '\n'.join(COOL_WORDS)) - @bot.message_handler(commands=['txt2img']) - async def send_txt2img(message): - user = message.from_user - chat = message.chat + async def _generic_txt2img(message_or_query): + if isinstance(message_or_query, CallbackQuery): + query = message_or_query + message = query.message + user = query.from_user + chat = query.message.chat + + else: + message = message_or_query + user = message.from_user + chat = message.chat + reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: reply_id = message.message_id @@ -332,19 +316,30 @@ async def run_skynet_telegram( **user_config } - await db_call('update_user_stats', user.id, last_prompt=prompt) + await db_call( + 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) - await work_request( + ec = await work_request( bot, cleos, hyperion, message, user, chat, - account, permission, params, - ipfs_node + account, permission, params ) - @bot.message_handler(func=lambda message: True, content_types=['photo']) - async def send_img2img(message): - user = message.from_user - chat = message.chat + if ec == 0: + await db_call('increment_generated', user.id) + + async def _generic_img2img(message_or_query): + if isinstance(message_or_query, CallbackQuery): + query = message_or_query + message = query.message + user = query.from_user + chat = query.message.chat + + else: + message = message_or_query + user = message.from_user + chat = message.chat + reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: reply_id = message.message_id @@ -364,6 +359,22 @@ async def run_skynet_telegram( file_id = message.photo[-1].file_id file_path = (await bot.get_file(file_id)).file_path + image_raw = await bot.download_file(file_path) + + with Image.open(io.BytesIO(image_raw)) as image: + w, h = image.size + + if w > 512 or h > 512: + logging.warning(f'user sent img of size {image.size}') + image.thumbnail((512, 512)) + logging.warning(f'resized it to {image.size}') + + image.save(f'ipfs-docker-staging/image.png', format='PNG') + + ipfs_hash = ipfs_node.add('image.png') + ipfs_node.pin(ipfs_hash) + + logging.info(f'published input image {ipfs_hash} on ipfs') logging.info(f'mid: {message.id}') @@ -376,16 +387,34 @@ async def run_skynet_telegram( **user_config } - await db_call('update_user_stats', user.id, last_prompt=prompt) + await db_call( + 'update_user_stats', + user.id, + 'img2img', + last_file=file_id, + last_prompt=prompt, + last_binary=ipfs_hash + ) - await work_request( + ec = await work_request( bot, cleos, hyperion, message, user, chat, account, permission, params, - ipfs_node, - file_id=file_id, file_path=file_path + file_id=file_id, + binary_data=ipfs_hash ) + if ec == 0: + await db_call('increment_generated', user.id) + + @bot.message_handler(commands=['txt2img']) + async def send_txt2img(message): + await _generic_txt2img(message) + + @bot.message_handler(func=lambda message: True, content_types=[ + 'photo', 'document']) + async def send_img2img(message): + await _generic_img2img(message) @bot.message_handler(commands=['img2img']) async def img2img_missing_image(message): @@ -406,12 +435,15 @@ async def run_skynet_telegram( user = message.from_user chat = message.chat - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id - + method = await db_call('get_last_method_of', user.id) prompt = await db_call('get_last_prompt_of', user.id) + file_id = None + binary = '' + if method == 'img2img': + file_id = await db_call('get_last_file_of', user.id) + binary = await db_call('get_last_binary_of', user.id) + if not prompt: await bot.reply_to( message, @@ -433,7 +465,8 @@ async def run_skynet_telegram( bot, cleos, hyperion, message, user, chat, account, permission, params, - ipfs_node + file_id=file_id, + binary_data=binary ) @bot.message_handler(commands=['redo']) @@ -485,7 +518,6 @@ async def run_skynet_telegram( await bot.send_message(GROUP_ID, message.text[4:]) - @bot.message_handler(func=lambda message: True) async def echo_message(message): if message.text[0] == '/': From 25c86b5eafcd79ab58a469f1fc7abc7ac3ce9b16 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 13:43:03 -0300 Subject: [PATCH 11/88] Rework gpu worker logic to work better in parallel with other workers --- skynet/dgpu.py | 71 ++++++++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/skynet/dgpu.py b/skynet/dgpu.py index e8c8490..a4303e4 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -218,13 +218,12 @@ async def open_dgpu_node( def begin_work(request_id: int): logging.info('begin_work') - ec, out = cleos.push_action( + return cleos.push_action( 'telos.gpu', 'workbegin', [account, request_id], f'{account}@{permission}' ) - assert ec == 0 def cancel_work(request_id: int, reason: str): logging.info('cancel_work') @@ -234,7 +233,6 @@ async def open_dgpu_node( [account, request_id, reason], f'{account}@{permission}' ) - assert ec == 0 def maybe_withdraw_all(): logging.info('maybe_withdraw_all') @@ -251,7 +249,6 @@ async def open_dgpu_node( f'{account}@{permission}' ) logging.info(collect_stdout(out)) - assert ec == 0 async def find_my_results(): logging.info('find_my_results') @@ -289,8 +286,8 @@ async def open_dgpu_node( f'{account}@{permission}' ) - print(collect_stdout(out)) - assert ec == 0 + if ec != 0: + print(collect_stdout(out)) async def get_input_data(ipfs_hash: str) -> bytes: if ipfs_hash == '': @@ -317,51 +314,51 @@ async def open_dgpu_node( rid = req['id'] my_results = [res['id'] for res in (await find_my_results())] - if rid in my_results: - continue + if rid not in my_results: + statuses = await get_status_by_request_id(rid) - statuses = await get_status_by_request_id(rid) + if len(statuses) < config['verification_amount']: - if len(statuses) < config['verification_amount']: + # parse request + body = json.loads(req['body']) - # parse request - body = json.loads(req['body']) + binary = await get_input_data(req['binary_data']) - binary = await get_input_data(req['binary_data']) + hash_str = ( + str(await get_user_nonce(req['user'])) + + + req['body'] + + + req['binary_data'] + ) + logging.info(f'hashing: {hash_str}') + request_hash = sha256(hash_str.encode('utf-8')).hexdigest() - hash_str = ( - str(await get_user_nonce(req['user'])) - + - req['body'] - + - req['binary_data'] - ) - logging.info(f'hashing: {hash_str}') - request_hash = sha256(hash_str.encode('utf-8')).hexdigest() + # TODO: validate request - # TODO: validate request + # perform work + logging.info(f'working on {body}') - # perform work - logging.info(f'working on {body}') + ec, _ = begin_work(rid) + if ec != 0: + logging.info(f'probably beign worked on already... skip.') - begin_work(rid) + else: + try: + img_sha, raw_img = gpu_compute_one( + body['method'], body['params'], binext=binary) - try: - img_sha, raw_img = gpu_compute_one( - body['method'], body['params'], binext=binary) + ipfs_hash = publish_on_ipfs(img_sha, raw_img) - ipfs_hash = publish_on_ipfs(img_sha, raw_img) + submit_work(rid, request_hash, img_sha, ipfs_hash) + break - submit_work(rid, request_hash, img_sha, ipfs_hash) - - break - - except BaseException as e: - cancel_work(rid, str(e)) + except BaseException as e: + cancel_work(rid, str(e)) + break else: logging.info(f'request {rid} already beign worked on, skip...') - continue await trio.sleep(1) From c26b4fc4688e1f397676014480332294b3a87104 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 14:41:27 -0300 Subject: [PATCH 12/88] Changes to frontend schema for better accuracy on configs New fix for nonce / request hashing system which had a bug with multi request per user case Add queue command to frontend Better meta info captions --- skynet/db/functions.py | 6 ++-- skynet/dgpu.py | 2 +- skynet/frontend/telegram.py | 56 +++++++++++++++++++++++++++---------- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/skynet/db/functions.py b/skynet/db/functions.py index 08067ae..3a8b760 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -41,9 +41,9 @@ CREATE TABLE IF NOT EXISTS skynet.user_config( step INT NOT NULL, width INT NOT NULL, height INT NOT NULL, - seed BIGINT, - guidance REAL NOT NULL, - strength REAL NOT NULL, + seed NUMERIC, + guidance DECIMAL NOT NULL, + strength DECIMAL NOT NULL, upscaler VARCHAR(128) ); ALTER TABLE skynet.user_config diff --git a/skynet/dgpu.py b/skynet/dgpu.py index a4303e4..ce29b27 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -325,7 +325,7 @@ async def open_dgpu_node( binary = await get_input_data(req['binary_data']) hash_str = ( - str(await get_user_nonce(req['user'])) + str(req['nonce']) + req['body'] + diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 3a9c964..320f49f 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -7,8 +7,9 @@ import logging import asyncio import traceback +from decimal import Decimal from hashlib import sha256 -from datetime import datetime +from datetime import datetime, timedelta import asks import docker @@ -46,7 +47,7 @@ def build_redo_menu(): return inline_keyboard -def prepare_metainfo_caption(tguser, worker: str, meta: dict) -> str: +def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> str: prompt = meta["prompt"] if len(prompt) > 256: prompt = prompt[:256] @@ -56,7 +57,9 @@ def prepare_metainfo_caption(tguser, worker: str, meta: dict) -> str: else: user = f'{tguser.first_name} id: {tguser.id}' - meta_str = f'by {user} performed by {worker}\n' + meta_str = f'by {user}' + meta_str += f'performed by {worker}\n' + meta_str += f'reward: {reward}\n' meta_str += f'prompt: {prompt}\n' meta_str += f'seed: {meta["seed"]}\n' @@ -68,7 +71,7 @@ def prepare_metainfo_caption(tguser, worker: str, meta: dict) -> str: if meta['upscaler']: meta_str += f'upscaler: {meta["upscaler"]}\n' - meta_str += f'Made with Skynet {VERSION}\n' + meta_str += f'Made with Skynet v{VERSION}\n' meta_str += f'JOIN THE SWARM: @skynetgpu' return meta_str @@ -78,7 +81,8 @@ def generate_reply_caption( params: dict, ipfs_hash: str, tx_hash: str, - worker: str + worker: str, + reward: str ): ipfs_link = hlink( 'Get your image on IPFS', @@ -89,7 +93,7 @@ def generate_reply_caption( f'http://test1.us.telos.net:42001/v2/explore/transaction/{tx_hash}' ) - meta_info = prepare_metainfo_caption(tguser, worker, params) + meta_info = prepare_metainfo_caption(tguser, worker, reward, params) final_msg = '\n'.join([ 'Worker finished your task!', @@ -132,27 +136,36 @@ async def work_request( binary_data: str = '' ): if params['seed'] == None: - params['seed'] = random.randint(0, 9e18) + params['seed'] = random.randint(0, 0xFFFFFFFF) + + sanitized_params = {} + for key, val in params.items(): + if isinstance(val, Decimal): + val = int(val) + + sanitized_params[key] = val body = json.dumps({ 'method': 'diffuse', - 'params': params + 'params': sanitized_params }) request_time = datetime.now().isoformat() + reward = '20.0000 GPU' ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, body, binary_data, '20.0000 GPU'], f'{account}@{permission}' + 'telos.gpu', 'enqueue', [account, body, binary_data, reward], f'{account}@{permission}' ) out = collect_stdout(out) if ec != 0: await bot.reply_to(message, out) return - nonce = await get_user_nonce(cleos, account) - request_hash = sha256( - (str(nonce) + body + binary_data).encode('utf-8')).hexdigest().upper() + request_id, nonce = out.split(':') - request_id = int(out) + request_hash = sha256( + (nonce + body + binary_data).encode('utf-8')).hexdigest().upper() + + request_id = int(request_id) logging.info(f'{request_id} enqueued.') config = await get_global_config(cleos) @@ -177,6 +190,7 @@ async def work_request( data = actions[0]['act']['data'] ipfs_hash = data['ipfs_hash'] worker = data['worker'] + logging.info('Found matching submit!') break await asyncio.sleep(1) @@ -190,7 +204,7 @@ async def work_request( resp = await get_ipfs_file(ipfs_link) caption = generate_reply_caption( - user, params, ipfs_hash, tx_hash, worker) + user, params, ipfs_hash, tx_hash, worker, reward) if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') @@ -469,6 +483,19 @@ async def run_skynet_telegram( binary_data=binary ) + @bot.message_handler(commands=['queue']) + async def queue(message): + an_hour_ago = datetime.now() - timedelta(hours=1) + queue = await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'queue', + index_position=2, + key_type='i64', + sort='desc', + lower_bound=int(an_hour_ago.timestamp()) + ) + await bot.reply_to( + message, f'Total requests on skynet queue: {len(queue)}') + @bot.message_handler(commands=['redo']) async def redo(message): await _redo(message) @@ -494,6 +521,7 @@ async def run_skynet_telegram( async def user_stats(message): user = message.from_user.id + await db_call('get_or_create_user', user.id) generated, joined, role = await db_call('get_user_stats', user) stats_str = f'generated: {generated}\n' From 1494e47b34eee700725bd1a33f2d0dc60f0a80c5 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 18:48:46 -0300 Subject: [PATCH 13/88] Snappier dgpu, fix captioning & gitignores --- .gitignore | 4 ++++ skynet/cli.py | 9 ++++++++- skynet/dgpu.py | 9 ++++++--- skynet/frontend/telegram.py | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index e98d124..4a75d24 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ secrets *.egg-info **/*.key **/*.cert +docs +ipfs-docker-data +ipfs-docker-staging +weights diff --git a/skynet/cli.py b/skynet/cli.py index 1d19ae3..d2dab73 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -21,7 +21,7 @@ from leap.sugar import get_container, collect_stdout from leap.hyperion import HyperionAPI from .db import open_new_database -from .ipfs import IPFSDocker +from .ipfs import open_ipfs_node from .config import * from .nodeos import open_cleos, open_nodeos from .constants import ALGOS @@ -396,6 +396,13 @@ class IPFSHTTP: ) +@run.command() +@click.option('--loglevel', '-l', default='INFO', help='logging level') +@click.option('--name', '-n', default='skynet-ipfs', help='container name') +def ipfs(loglevel, name): + with open_ipfs_node(name=name): + ... + @run.command() @click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option( diff --git a/skynet/dgpu.py b/skynet/dgpu.py index ce29b27..8f5bc12 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -222,7 +222,8 @@ async def open_dgpu_node( 'telos.gpu', 'workbegin', [account, request_id], - f'{account}@{permission}' + f'{account}@{permission}', + retry=0 ) def cancel_work(request_id: int, reason: str): @@ -231,7 +232,8 @@ async def open_dgpu_node( 'telos.gpu', 'workcancel', [account, request_id, reason], - f'{account}@{permission}' + f'{account}@{permission}', + retry=2 ) def maybe_withdraw_all(): @@ -283,7 +285,8 @@ async def open_dgpu_node( 'telos.gpu', 'submit', [account, request_id, request_hash, result_hash, ipfs_hash], - f'{account}@{permission}' + f'{account}@{permission}', + retry=0 ) if ec != 0: diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 320f49f..c7e8d43 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -57,7 +57,7 @@ def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> st else: user = f'{tguser.first_name} id: {tguser.id}' - meta_str = f'by {user}' + meta_str = f'by {user}\n' meta_str += f'performed by {worker}\n' meta_str += f'reward: {reward}\n' From 33d2ca281b8673c5cb4fbf6b4e770efb64e23b80 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 19:22:56 -0300 Subject: [PATCH 14/88] Pin all the things --- skynet/cli.py | 123 ++++++++++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index d2dab73..7248c6e 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -11,6 +11,7 @@ from datetime import datetime, timedelta from functools import partial import trio +import asks import click import docker import asyncio @@ -287,7 +288,7 @@ def nodeos(): @click.option( '--node-url', '-n', default='http://skynet.ancap.tech') @click.option( - '--ipfs-url', '-n', default='/ip4/169.197.142.4/tcp/4001/p2p/12D3KooWKHKPFuqJPeqYgtUJtfZTHvEArRX2qvThYBrjuTuPg2Nx') + '--ipfs-url', '-n', default='/ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKHKPFuqJPeqYgtUJtfZTHvEArRX2qvThYBrjuTuPg2Nx') @click.option( '--algos', '-A', default=json.dumps(['midj'])) def dgpu( @@ -395,6 +396,12 @@ class IPFSHTTP: params={'arg': cid} ) + async def a_pin(self, cid: str): + return await asks.post( + f'{self.endpoint}/api/v0/pin/add', + params={'arg': cid} + ) + @run.command() @click.option('--loglevel', '-l', default='INFO', help='logging level') @@ -414,71 +421,79 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): ipfs_node = IPFSHTTP(ipfs_rpc) hyperion = HyperionAPI(hyperion_url) - last_pinned: dict[str, datetime] = {} + already_pinned: set[str] = set() - def capture_enqueues(half_min_ago: datetime): - # get all enqueues with binary data - # in the last minute - enqueues = hyperion.get_actions( - account='telos.gpu', - filter='telos.gpu:enqueue', - sort='desc', - after=half_min_ago.isoformat() - ) + async def _async_main(): - cids = [] - for action in enqueues['actions']: - cid = action['act']['data']['binary_data'] - if cid and cid not in last_pinned: - cids.append(cid) + async def capture_enqueues(last_hour: datetime): + # get all enqueuesin the last hour + enqueues = await hyperion.aget_actions( + account='telos.gpu', + filter='telos.gpu:enqueue', + sort='desc', + after=last_hour.isoformat(), + limit=1000 + ) - return cids + logging.info(f'got {len(enqueues)} enqueue actions.') - def capture_submits(half_min_ago: datetime): - # get all submits in the last minute - submits = hyperion.get_actions( - account='telos.gpu', - filter='telos.gpu:submit', - sort='desc', - after=half_min_ago.isoformat() - ) + cids = [] + for action in enqueues['actions']: + cid = action['act']['data']['binary_data'] + if cid and cid not in already_pinned: + cids.append(cid) - cids = [] - for action in submits['actions']: - cid = action['act']['data']['ipfs_hash'] - if cid and cid not in last_pinned: - cids.append(cid) + return cids - return cids + async def capture_submits(last_hour: datetime): + # get all submits in the last hour + submits = await hyperion.aget_actions( + account='telos.gpu', + filter='telos.gpu:submit', + sort='desc', + after=last_hour.isoformat(), + limit=1000 + ) - def cleanup_pinned(now: datetime): - for cid in set(last_pinned.keys()): - ts = last_pinned[cid] - if now - ts > timedelta(minutes=1): - del last_pinned[cid] + logging.info(f'got {len(submits)} submits actions.') - try: - while True: - now = datetime.now() - half_min_ago = now - timedelta(seconds=30) + cids = [] + for action in submits['actions']: + cid = action['act']['data']['ipfs_hash'] + if cid and cid not in already_pinned: + cids.append(cid) - # filter for the ones not already pinned - cids = [*capture_enqueues(half_min_ago), *capture_submits(half_min_ago)] + return cids - # pin and remember - for cid in cids: - last_pinned[cid] = now + async def task_pin(cid: str): + already_pinned.add(cid) - resp = ipfs_node.pin(cid) - if resp.status_code != 200: - logging.error(f'error pinning {cid}:\n{resp.text}') + resp = await ipfs_node.a_pin(cid) + if resp.status_code != 200: + logging.error(f'error pinning {cid}:\n{resp.text}') - else: - logging.info(f'pinned {cid}') + else: + logging.info(f'pinned {cid}') - cleanup_pinned(now) + try: + async with trio.open_nursery() as n: + while True: + now = datetime.now() + last_hour = now - timedelta(hours=1) - time.sleep(0.1) + # filter for the ones not already pinned + cids = [ + *(await capture_enqueues(last_hour)), + *(await capture_submits(last_hour)) + ] - except KeyboardInterrupt: - ... + # pin and remember (in parallel) + for cid in cids: + n.start_soon(task_pin, cid) + + await trio.sleep(1) + + except KeyboardInterrupt: + ... + + trio.run(_async_main) From 731a64494f0296b922a5898b9d3db5047ed5f741 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 21:00:47 -0300 Subject: [PATCH 15/88] Update url defaults --- skynet/cli.py | 24 ++++++++++++------------ skynet/frontend/telegram.py | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 7248c6e..f249d38 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -100,7 +100,7 @@ def download(): @click.option( '--key', '-k', default=None) @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') @click.option( '--reward', '-r', default='20.0000 GPU') @click.option('--algo', '-a', default='midj') @@ -143,7 +143,7 @@ def enqueue( @skynet.command() @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') def queue(node_url: str): resp = requests.post( f'{node_url}/v1/chain/get_table_rows', @@ -158,7 +158,7 @@ def queue(node_url: str): @skynet.command() @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') @click.argument('request-id') def status(node_url: str, request_id: int): resp = requests.post( @@ -180,7 +180,7 @@ def status(node_url: str, request_id: int): @click.option( '--key', '-k', default=None) @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') @click.argument('request-id') def dequeue( account: str, @@ -207,7 +207,7 @@ def dequeue( @click.option( '--key', '-k', default=None) @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') @click.option( '--verifications', '-v', default=1) @click.option( @@ -241,7 +241,7 @@ def config( @click.option( '--key', '-k', default=None) @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') @click.argument('quantity') def deposit( account: str, @@ -286,11 +286,11 @@ def nodeos(): @click.option( '--auto-withdraw', '-w', default=True) @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') @click.option( - '--ipfs-url', '-n', default='/ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKHKPFuqJPeqYgtUJtfZTHvEArRX2qvThYBrjuTuPg2Nx') + '--ipfs-url', '-n', default='/ip4/169.197.140.154/udp/4001/quic/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv') @click.option( - '--algos', '-A', default=json.dumps(['midj'])) + '--algos', '-A', default=json.dumps(['midj', 'ink'])) def dgpu( loglevel: str, account: str, @@ -343,11 +343,11 @@ def dgpu( @click.option( '--key', '-k', default=None) @click.option( - '--hyperion-url', '-n', default='http://test1.us.telos.net:42001') + '--hyperion-url', '-n', default='https://skynet.ancap.tech') @click.option( - '--node-url', '-n', default='http://skynet.ancap.tech') + '--node-url', '-n', default='https://skynet.ancap.tech') @click.option( - '--ipfs-url', '-n', default='/ip4/169.197.142.4/tcp/4001/p2p/12D3KooWKHKPFuqJPeqYgtUJtfZTHvEArRX2qvThYBrjuTuPg2Nx') + '--ipfs-url', '-n', default='/ip4/169.197.140.154/udp/4001/quic/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv') @click.option( '--db-host', '-h', default='localhost:5432') @click.option( diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index c7e8d43..a556124 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -86,11 +86,11 @@ def generate_reply_caption( ): ipfs_link = hlink( 'Get your image on IPFS', - f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png' + f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' ) explorer_link = hlink( 'SKYNET Transaction Explorer', - f'http://test1.us.telos.net:42001/v2/explore/transaction/{tx_hash}' + f'https://skynet.ancap.tech/v2/explore/transaction/{tx_hash}' ) meta_info = prepare_metainfo_caption(tguser, worker, reward, params) @@ -521,7 +521,7 @@ async def run_skynet_telegram( async def user_stats(message): user = message.from_user.id - await db_call('get_or_create_user', user.id) + await db_call('get_or_create_user', user) generated, joined, role = await db_call('get_user_stats', user) stats_str = f'generated: {generated}\n' From 25413a68cc0b557dac12152106420c9176ec4da2 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 29 May 2023 23:29:42 -0300 Subject: [PATCH 16/88] Fix logging bug on pinner --- skynet/cli.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index f249d38..b8cfab8 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -140,6 +140,34 @@ def enqueue( print(collect_stdout(out)) assert ec == 0 +@skynet.command() +@click.option('--loglevel', '-l', default='INFO', help='Logging level') +@click.option( + '--account', '-A', default='telos.gpu') +@click.option( + '--permission', '-P', default='active') +@click.option( + '--key', '-k', default=None) +@click.option( + '--node-url', '-n', default='https://skynet.ancap.tech') +def clean( + loglevel: str, + account: str, + permission: str, + key: str | None, + node_url: str, +): + logging.basicConfig(level=loglevel) + cleos = CLEOS(None, None, url=node_url, remote=node_url) + trio.run( + partial( + cleos.a_push_action, + 'telos.gpu', + 'clean', + {}, + account, key, permission=permission + ) + ) @skynet.command() @click.option( @@ -435,7 +463,7 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): limit=1000 ) - logging.info(f'got {len(enqueues)} enqueue actions.') + logging.info(f'got {len(enqueues["actions"])} enqueue actions.') cids = [] for action in enqueues['actions']: @@ -455,7 +483,7 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): limit=1000 ) - logging.info(f'got {len(submits)} submits actions.') + logging.info(f'got {len(submits["actions"])} submits actions.') cids = [] for action in submits['actions']: From 40ba84c109f36b9befd026472522804494c63f59 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Tue, 30 May 2023 00:32:17 -0300 Subject: [PATCH 17/88] Drop cleos docker container usage for push action in frontend and dgpu, also make pinner way more agresive --- skynet/cli.py | 25 +++++------- skynet/dgpu.py | 78 +++++++++++++++++++++---------------- skynet/frontend/telegram.py | 20 +++++++--- 3 files changed, 68 insertions(+), 55 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index b8cfab8..06bc1bd 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -449,17 +449,14 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): ipfs_node = IPFSHTTP(ipfs_rpc) hyperion = HyperionAPI(hyperion_url) - already_pinned: set[str] = set() - async def _async_main(): - async def capture_enqueues(last_hour: datetime): - # get all enqueuesin the last hour + async def capture_enqueues(after: datetime): enqueues = await hyperion.aget_actions( account='telos.gpu', filter='telos.gpu:enqueue', sort='desc', - after=last_hour.isoformat(), + after=after.isoformat(), limit=1000 ) @@ -468,18 +465,17 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): cids = [] for action in enqueues['actions']: cid = action['act']['data']['binary_data'] - if cid and cid not in already_pinned: + if cid: cids.append(cid) return cids - async def capture_submits(last_hour: datetime): - # get all submits in the last hour + async def capture_submits(after: datetime): submits = await hyperion.aget_actions( account='telos.gpu', filter='telos.gpu:submit', sort='desc', - after=last_hour.isoformat(), + after=after.isoformat(), limit=1000 ) @@ -488,14 +484,11 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): cids = [] for action in submits['actions']: cid = action['act']['data']['ipfs_hash'] - if cid and cid not in already_pinned: - cids.append(cid) + cids.append(cid) return cids async def task_pin(cid: str): - already_pinned.add(cid) - resp = await ipfs_node.a_pin(cid) if resp.status_code != 200: logging.error(f'error pinning {cid}:\n{resp.text}') @@ -507,12 +500,12 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): async with trio.open_nursery() as n: while True: now = datetime.now() - last_hour = now - timedelta(hours=1) + prev_second = now - timedelta(seconds=1) # filter for the ones not already pinned cids = [ - *(await capture_enqueues(last_hour)), - *(await capture_submits(last_hour)) + *(await capture_enqueues(prev_second)), + *(await capture_submits(prev_second)) ] # pin and remember (in parallel) diff --git a/skynet/dgpu.py b/skynet/dgpu.py index 8f5bc12..67ef251 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -16,7 +16,7 @@ import asks import torch from leap.cleos import CLEOS, default_nodeos_image -from leap.sugar import get_container, collect_stdout +from leap.sugar import * from diffusers import ( StableDiffusionPipeline, @@ -67,9 +67,6 @@ async def open_dgpu_node( logging.info(f'starting dgpu node!') logging.info(f'launching toolchain container!') - if key: - cleos.setup_wallet(key) - logging.info(f'loading models...') upscaler = init_upscaler() @@ -192,9 +189,9 @@ async def open_dgpu_node( return (await cleos.aget_table( 'telos.gpu', 'telos.gpu', 'config'))[0] - def get_worker_balance(): + async def get_worker_balance(): logging.info('get_worker_balance') - rows = cleos.get_table( + rows = await cleos.aget_table( 'telos.gpu', 'telos.gpu', 'users', index_position=1, key_type='name', @@ -216,27 +213,34 @@ async def open_dgpu_node( upper_bound=user ))[0]['nonce'] - def begin_work(request_id: int): + async def begin_work(request_id: int): logging.info('begin_work') - return cleos.push_action( + return await cleos.a_push_action( 'telos.gpu', 'workbegin', - [account, request_id], - f'{account}@{permission}', - retry=0 + { + 'worker': Name(account), + 'request_id': request_id + }, + account, key, + permission=permission ) - def cancel_work(request_id: int, reason: str): + async def cancel_work(request_id: int, reason: str): logging.info('cancel_work') - ec, out = cleos.push_action( + return await cleos.a_push_action( 'telos.gpu', 'workcancel', - [account, request_id, reason], - f'{account}@{permission}', - retry=2 + { + 'worker': Name(account), + 'request_id': request_id, + 'reason': reason + }, + account, key, + permission=permission ) - def maybe_withdraw_all(): + async def maybe_withdraw_all(): logging.info('maybe_withdraw_all') balance = get_worker_balance() if not balance: @@ -244,13 +248,16 @@ async def open_dgpu_node( balance_amount = float(balance.split(' ')[0]) if balance_amount > 0: - ec, out = cleos.push_action( + await cleos.a_push_action( 'telos.gpu', 'withdraw', - [account, balance], - f'{account}@{permission}' + { + 'user': Name(account), + 'quantity': asset_from_str(balance) + }, + account, key, + permission=permission ) - logging.info(collect_stdout(out)) async def find_my_results(): logging.info('find_my_results') @@ -274,29 +281,32 @@ async def open_dgpu_node( return ipfs_hash - def submit_work( + async def submit_work( request_id: int, request_hash: str, result_hash: str, ipfs_hash: str ): logging.info('submit_work') - ec, out = cleos.push_action( + await cleos.a_push_action( 'telos.gpu', 'submit', - [account, request_id, request_hash, result_hash, ipfs_hash], - f'{account}@{permission}', - retry=0 + { + 'worker': Name(account), + 'request_id': request_id, + 'request_hash': Checksum256(request_hash), + 'result_hash': Checksum256(result_hash), + 'ipfs_hash': ipfs_hash + }, + account, key, + permission=permission ) - if ec != 0: - print(collect_stdout(out)) - async def get_input_data(ipfs_hash: str) -> bytes: if ipfs_hash == '': return b'' - resp = await get_ipfs_file(f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png') + resp = await get_ipfs_file(f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png') if resp.status_code != 200: raise DGPUComputeError('Couldn\'t gather input data from ipfs') @@ -342,8 +352,8 @@ async def open_dgpu_node( # perform work logging.info(f'working on {body}') - ec, _ = begin_work(rid) - if ec != 0: + resp = await begin_work(rid) + if 'code' in resp: logging.info(f'probably beign worked on already... skip.') else: @@ -353,11 +363,11 @@ async def open_dgpu_node( ipfs_hash = publish_on_ipfs(img_sha, raw_img) - submit_work(rid, request_hash, img_sha, ipfs_hash) + await submit_work(rid, request_hash, img_sha, ipfs_hash) break except BaseException as e: - cancel_work(rid, str(e)) + await cancel_work(rid, str(e)) break else: diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index a556124..56d1c92 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -152,14 +152,24 @@ async def work_request( request_time = datetime.now().isoformat() reward = '20.0000 GPU' - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, body, binary_data, reward], f'{account}@{permission}' + res = await cleos.s_push_action( + 'telos.gpu', + 'enqueue', + { + 'user': Name(account), + 'request_body': body, + 'binary_data': binary_data, + 'reward': asset_from_str(reward) + }, + account, key, permission=permission ) - out = collect_stdout(out) - if ec != 0: - await bot.reply_to(message, out) + + if 'code' in res: + await bot.reply_to(message, json.dumps(res, indent=4)) return + out = collect_stdout(res) + request_id, nonce = out.split(':') request_hash = sha256( From e6e3dc2e6369afcf7dcdb80039a46609193576a1 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Tue, 30 May 2023 00:41:32 -0300 Subject: [PATCH 18/88] Update README.md --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 483bdbd..a875d5a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ # skynet ### decentralized compute platform + +To launch a worker: +``` +# create and edit config from template +cp skynet.ini.example skynet.ini + +# create python virtual envoirment 3.10+ +python3 -m venv venv + +# enable envoirment +source venv/bin/activate + +# install requirements +pip install -r requirements.txt +pip install -r requirements.cuda.0.txt +pip install -r requirements.cuda.1.txt +pip install -r requirements.cuda.2.txt + +# install skynet +pip install -e . + +# test you can run this command +skynet --help + +# to launch worker +skynet run dgpu + +``` From 320f13260ca0c9e9a0b04c9abb866e18aab54bb3 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Thu, 1 Jun 2023 19:02:59 -0300 Subject: [PATCH 19/88] Update nodeos genesis init --- requirements.txt | 1 + skynet/cli.py | 35 +++++++++-------------------------- skynet/frontend/telegram.py | 34 ++++++++++------------------------ skynet/nodeos.py | 3 ++- 4 files changed, 22 insertions(+), 51 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7a8ec39..3543e62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +pytz trio asks numpy diff --git a/skynet/cli.py b/skynet/cli.py index 06bc1bd..09bb781 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -334,32 +334,15 @@ def dgpu( key, account, permission = load_account_info( key, account, permission) - vtestnet = None - try: - dclient = docker.from_env() - vtestnet = get_container( - dclient, - default_nodeos_image(), - force_unique=True, - detach=True, - network='host', - remove=True) - - cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) - - trio.run( - partial( - open_dgpu_node, - account, permission, - cleos, - ipfs_url, - auto_withdraw=auto_withdraw, - key=key, initial_algos=json.loads(algos) - )) - - finally: - if vtestnet: - vtestnet.stop() + trio.run( + partial( + open_dgpu_node, + account, permission, + CLEOS(None, None, url=node_url, remote=node_url), + ipfs_url, + auto_withdraw=auto_withdraw, + key=key, initial_algos=json.loads(algos) + )) @run.command() diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py index 56d1c92..e6e8b76 100644 --- a/skynet/frontend/telegram.py +++ b/skynet/frontend/telegram.py @@ -15,8 +15,8 @@ import asks import docker from PIL import Image -from leap.cleos import CLEOS, default_nodeos_image -from leap.sugar import get_container, collect_stdout +from leap.cleos import CLEOS +from leap.sugar import * from leap.hyperion import HyperionAPI from trio_asyncio import aio_as_trio from telebot.types import ( @@ -131,6 +131,7 @@ async def work_request( message, user, chat, account: str, permission: str, + private_key: str, params: dict, file_id: str | None = None, binary_data: str = '' @@ -152,7 +153,7 @@ async def work_request( request_time = datetime.now().isoformat() reward = '20.0000 GPU' - res = await cleos.s_push_action( + res = await cleos.a_push_action( 'telos.gpu', 'enqueue', { @@ -161,7 +162,7 @@ async def work_request( 'binary_data': binary_data, 'reward': asset_from_str(reward) }, - account, key, permission=permission + account, private_key, permission=permission ) if 'code' in res: @@ -210,7 +211,7 @@ async def work_request( return # attempt to get the image and send it - ipfs_link = f'http://test1.us.telos.net:8080/ipfs/{ipfs_hash}/image.png' + ipfs_link = f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' resp = await get_ipfs_file(ipfs_link) caption = generate_reply_caption( @@ -262,23 +263,11 @@ async def run_skynet_telegram( remote_ipfs_node: str, key: str = None ): - dclient = docker.from_env() - vtestnet = get_container( - dclient, - default_nodeos_image(), - force_unique=True, - detach=True, - network='host', - remove=True) - - cleos = CLEOS(dclient, vtestnet, url=node_url, remote=node_url) + cleos = CLEOS(None, None, url=node_url, remote=node_url) hyperion = HyperionAPI(hyperion_url) logging.basicConfig(level=logging.INFO) - if key: - cleos.setup_wallet(key) - bot = AsyncTeleBot(tg_token, exception_handler=SKYExceptionHandler) logging.info(f'tg_token: {tg_token}') @@ -346,7 +335,7 @@ async def run_skynet_telegram( ec = await work_request( bot, cleos, hyperion, message, user, chat, - account, permission, params + account, permission, key, params ) if ec == 0: @@ -423,7 +412,7 @@ async def run_skynet_telegram( ec = await work_request( bot, cleos, hyperion, message, user, chat, - account, permission, params, + account, permission, key, params, file_id=file_id, binary_data=ipfs_hash ) @@ -488,7 +477,7 @@ async def run_skynet_telegram( await work_request( bot, cleos, hyperion, message, user, chat, - account, permission, params, + account, permission, key, params, file_id=file_id, binary_data=binary ) @@ -575,6 +564,3 @@ async def run_skynet_telegram( except KeyboardInterrupt: ... - - finally: - vtestnet.stop() diff --git a/skynet/nodeos.py b/skynet/nodeos.py index af1ce00..b853208 100644 --- a/skynet/nodeos.py +++ b/skynet/nodeos.py @@ -9,6 +9,7 @@ from contextlib import contextmanager as cm import docker +from pytz import timezone from leap.cleos import CLEOS, default_nodeos_image from leap.sugar import get_container, Symbol, random_string @@ -69,7 +70,7 @@ def open_nodeos(cleanup: bool = True): cleos.setup_wallet(priv) genesis = json.dumps({ - "initial_timestamp": datetime.now().isoformat(), + "initial_timestamp": '2017-08-29T02:14:00.000', "initial_key": pub, "initial_configuration": { "max_block_net_usage": 1048576, From 13c6e85ac96fe7f90f44b94143f755c99bbcc147 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 3 Jun 2023 12:22:24 -0300 Subject: [PATCH 20/88] Make pinner less spammy lool, and gpu more resiliant --- skynet/cli.py | 12 +++++++++--- skynet/dgpu.py | 16 ++++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 09bb781..8270b20 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -418,6 +418,7 @@ class IPFSHTTP: @click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option('--name', '-n', default='skynet-ipfs', help='container name') def ipfs(loglevel, name): + logging.basicConfig(level=loglevel) with open_ipfs_node(name=name): ... @@ -432,6 +433,7 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): ipfs_node = IPFSHTTP(ipfs_rpc) hyperion = HyperionAPI(hyperion_url) + pinned = set() async def _async_main(): async def capture_enqueues(after: datetime): @@ -448,7 +450,8 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): cids = [] for action in enqueues['actions']: cid = action['act']['data']['binary_data'] - if cid: + if cid and cid not in pinned: + pinned.add(cid) cids.append(cid) return cids @@ -467,11 +470,14 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): cids = [] for action in submits['actions']: cid = action['act']['data']['ipfs_hash'] - cids.append(cid) + if cid and cid not in pinned: + pinned.add(cid) + cids.append(cid) return cids async def task_pin(cid: str): + logging.info(f'pinning {cid}...') resp = await ipfs_node.a_pin(cid) if resp.status_code != 200: logging.error(f'error pinning {cid}:\n{resp.text}') @@ -483,7 +489,7 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): async with trio.open_nursery() as n: while True: now = datetime.now() - prev_second = now - timedelta(seconds=1) + prev_second = now - timedelta(seconds=10) # filter for the ones not already pinned cids = [ diff --git a/skynet/dgpu.py b/skynet/dgpu.py index 67ef251..e3b882d 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -172,12 +172,16 @@ async def open_dgpu_node( async def get_work_requests_last_hour(): logging.info('get_work_requests_last_hour') - return await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'queue', - index_position=2, - key_type='i64', - lower_bound=int(time.time()) - 3600 - ) + try: + return await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'queue', + index_position=2, + key_type='i64', + lower_bound=int(time.time()) - 3600 + ) + + except asks.errors.RequestTimeout: + return [] async def get_status_by_request_id(request_id: int): logging.info('get_status_by_request_id') From 006d15137c3758cb19c67108d106f8faf9b93e57 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 3 Jun 2023 12:44:22 -0300 Subject: [PATCH 21/88] Updated ipfs remote and ipfs launch logic --- skynet/cli.py | 8 ++++---- skynet/constants.py | 2 ++ skynet/ipfs.py | 6 +++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 8270b20..19bda68 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -316,7 +316,7 @@ def nodeos(): @click.option( '--node-url', '-n', default='https://skynet.ancap.tech') @click.option( - '--ipfs-url', '-n', default='/ip4/169.197.140.154/udp/4001/quic/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv') + '--ipfs-url', '-n', default=DEFAULT_IPFS_REMOTE) @click.option( '--algos', '-A', default=json.dumps(['midj', 'ink'])) def dgpu( @@ -354,11 +354,11 @@ def dgpu( @click.option( '--key', '-k', default=None) @click.option( - '--hyperion-url', '-n', default='https://skynet.ancap.tech') + '--hyperion-url', '-y', default='https://skynet.ancap.tech') @click.option( '--node-url', '-n', default='https://skynet.ancap.tech') @click.option( - '--ipfs-url', '-n', default='/ip4/169.197.140.154/udp/4001/quic/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv') + '--ipfs-url', '-i', default=DEFAULT_IPFS_REMOTE) @click.option( '--db-host', '-h', default='localhost:5432') @click.option( @@ -427,7 +427,7 @@ def ipfs(loglevel, name): @click.option( '--ipfs-rpc', '-i', default='http://127.0.0.1:5001') @click.option( - '--hyperion-url', '-n', default='http://127.0.0.1:42001') + '--hyperion-url', '-y', default='http://127.0.0.1:42001') def pinner(loglevel, ipfs_rpc, hyperion_url): logging.basicConfig(level=loglevel) ipfs_node = IPFSHTTP(ipfs_rpc) diff --git a/skynet/constants.py b/skynet/constants.py index 486edd3..74fed5d 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -130,3 +130,5 @@ CONFIG_ATTRS = [ 'strength', 'upscaler' ] + +DEFAULT_IPFS_REMOTE = '/ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv' diff --git a/skynet/ipfs.py b/skynet/ipfs.py index 79322be..1c2bd87 100644 --- a/skynet/ipfs.py +++ b/skynet/ipfs.py @@ -86,13 +86,17 @@ def open_ipfs_node(name='skynet-ipfs'): Mount(export_target, str(staging_dir), 'bind'), Mount(data_target, str(data_dir), 'bind') ], - detach=True + detach=True, + remove=True ) + uid = os.getuid() gid = os.getgid() ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', export_target]) + logging.info(out) assert ec == 0 ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', data_target]) + logging.info(out) assert ec == 0 for log in container.logs(stream=True): From 31cca4f487cec13e99a7566d17539565ebf02221 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 3 Jun 2023 12:46:21 -0300 Subject: [PATCH 22/88] Woops fixed import --- skynet/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/cli.py b/skynet/cli.py index 19bda68..9c1e6f4 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -25,7 +25,7 @@ from .db import open_new_database from .ipfs import open_ipfs_node from .config import * from .nodeos import open_cleos, open_nodeos -from .constants import ALGOS +from .constants import * from .frontend.telegram import run_skynet_telegram From 27fe05c3e7e85bfcb058f6350f4dc6ba75f78f64 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 3 Jun 2023 20:17:56 -0300 Subject: [PATCH 23/88] Vast improvement to telegram frontedn --- skynet/cli.py | 17 +- skynet/db/functions.py | 60 ++- skynet/dgpu.py | 21 +- skynet/frontend/__init__.py | 6 - skynet/frontend/telegram.py | 566 --------------------------- skynet/frontend/telegram/__init__.py | 274 +++++++++++++ skynet/frontend/telegram/handlers.py | 345 ++++++++++++++++ skynet/frontend/telegram/utils.py | 113 ++++++ 8 files changed, 810 insertions(+), 592 deletions(-) delete mode 100644 skynet/frontend/telegram.py create mode 100644 skynet/frontend/telegram/__init__.py create mode 100644 skynet/frontend/telegram/handlers.py create mode 100644 skynet/frontend/telegram/utils.py diff --git a/skynet/cli.py b/skynet/cli.py index 9c1e6f4..8a2d8ac 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -26,7 +26,7 @@ from .ipfs import open_ipfs_node from .config import * from .nodeos import open_cleos, open_nodeos from .constants import * -from .frontend.telegram import run_skynet_telegram +from .frontend.telegram import SkynetTelegramFrontend @click.group() @@ -318,7 +318,7 @@ def nodeos(): @click.option( '--ipfs-url', '-n', default=DEFAULT_IPFS_REMOTE) @click.option( - '--algos', '-A', default=json.dumps(['midj', 'ink'])) + '--algos', '-A', default=json.dumps(['midj'])) def dgpu( loglevel: str, account: str, @@ -383,8 +383,9 @@ def telegram( key, account, permission) _, _, tg_token, cfg = init_env_from_config() - asyncio.run( - run_skynet_telegram( + + async def _async_main(): + frontend = SkynetTelegramFrontend( tg_token, account, permission, @@ -393,7 +394,13 @@ def telegram( db_host, db_user, db_pass, remote_ipfs_node=ipfs_url, key=key - )) + ) + + async with frontend.open(): + await frontend.bot.infinity_polling() + + + asyncio.run(_async_main()) class IPFSHTTP: diff --git a/skynet/db/functions.py b/skynet/db/functions.py index 3a8b760..8a56484 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -6,7 +6,6 @@ import string import logging import importlib -from typing import Optional from datetime import datetime from contextlib import contextmanager as cm from contextlib import asynccontextmanager as acm @@ -15,7 +14,6 @@ import docker import asyncpg import psycopg2 -from asyncpg.exceptions import UndefinedColumnError from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT from ..constants import * @@ -49,6 +47,17 @@ CREATE TABLE IF NOT EXISTS skynet.user_config( ALTER TABLE skynet.user_config ADD FOREIGN KEY(id) REFERENCES skynet.user(id); + +CREATE TABLE IF NOT EXISTS skynet.user_requests( + id SERIAL NOT NULL, + user_id SERIAL NOT NULL, + sent TIMESTAMP NOT NULL, + status TEXT NOT NULL, + status_msg SERIAL PRIMARY KEY NOT NULL +); +ALTER TABLE skynet.user_requests + ADD FOREIGN KEY(user_id) + REFERENCES skynet.user(id); ''' @@ -199,6 +208,53 @@ async def get_last_binary_of(conn, user: int): return await stmt.fetchval(user) +async def get_user_request(conn, mid: int): + stmt = await conn.prepare( + 'SELECT * FROM skynet.user_requests WHERE id = $1') + return await stmt.fetch(mid) + +async def get_user_request_by_sid(conn, sid: int): + stmt = await conn.prepare( + 'SELECT * FROM skynet.user_requests WHERE status_msg = $1') + return (await stmt.fetch(sid))[0] + +async def new_user_request( + conn, user: int, mid: int, + status_msg: int, + status: str = 'started processing request...' +): + date = datetime.utcnow() + async with conn.transaction(): + stmt = await conn.prepare(''' + INSERT INTO skynet.user_requests( + id, user_id, sent, status, status_msg + ) + + VALUES($1, $2, $3, $4, $5) + ''') + await stmt.fetch(mid, user, date, status, status_msg) + +async def update_user_request( + conn, mid: int, status: str +): + stmt = await conn.prepare(f''' + UPDATE skynet.user_requests + SET status = $2 + WHERE id = $1 + ''') + await stmt.fetch(mid, status) + +async def update_user_request_by_sid( + conn, sid: int, status: str +): + stmt = await conn.prepare(f''' + UPDATE skynet.user_requests + SET status = $2 + WHERE status_msg = $1 + ''') + await stmt.fetch(sid, status) + + async def new_user(conn, uid: int): if await get_user(conn, uid): raise ValueError('User already present on db') diff --git a/skynet/dgpu.py b/skynet/dgpu.py index e3b882d..763fdd1 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -4,8 +4,8 @@ import gc import io import json import time -import random import logging +import traceback from PIL import Image from typing import List, Optional @@ -15,19 +15,13 @@ import trio import asks import torch -from leap.cleos import CLEOS, default_nodeos_image +from leap.cleos import CLEOS from leap.sugar import * -from diffusers import ( - StableDiffusionPipeline, - StableDiffusionImg2ImgPipeline, - EulerAncestralDiscreteScheduler -) from realesrgan import RealESRGANer from basicsr.archs.rrdbnet_arch import RRDBNet -from diffusers.models import UNet2DConditionModel -from .ipfs import IPFSDocker, open_ipfs_node, get_ipfs_file +from .ipfs import open_ipfs_node, get_ipfs_file from .utils import * from .constants import * @@ -125,7 +119,7 @@ async def open_dgpu_node( logging.info(f'binext: {len(binext) if binext else 0} bytes') if binext: _params['image'] = image - _params['strength'] = params['strength'] + _params['strength'] = float(Decimal(params['strength'])) else: _params['width'] = int(params['width']) @@ -135,7 +129,7 @@ async def open_dgpu_node( image = models[algo]['pipe']( params['prompt'], **_params, - guidance_scale=params['guidance'], + guidance_scale=float(Decimal(params['guidance'])), num_inference_steps=int(params['step']), generator=torch.manual_seed(int(params['seed'])) ).images[0] @@ -246,7 +240,7 @@ async def open_dgpu_node( async def maybe_withdraw_all(): logging.info('maybe_withdraw_all') - balance = get_worker_balance() + balance = await get_worker_balance() if not balance: return @@ -323,7 +317,7 @@ async def open_dgpu_node( try: while True: if auto_withdraw: - maybe_withdraw_all() + await maybe_withdraw_all() queue = await get_work_requests_last_hour() @@ -371,6 +365,7 @@ async def open_dgpu_node( break except BaseException as e: + traceback.print_exc() await cancel_work(rid, str(e)) break diff --git a/skynet/frontend/__init__.py b/skynet/frontend/__init__.py index 290b6b3..6a2557e 100644 --- a/skynet/frontend/__init__.py +++ b/skynet/frontend/__init__.py @@ -1,11 +1,5 @@ #!/usr/bin/python -import json - -from typing import Union, Optional -from pathlib import Path -from contextlib import contextmanager as cm - from ..constants import * diff --git a/skynet/frontend/telegram.py b/skynet/frontend/telegram.py deleted file mode 100644 index e6e8b76..0000000 --- a/skynet/frontend/telegram.py +++ /dev/null @@ -1,566 +0,0 @@ -#!/usr/bin/python - -import io -import zlib -import random -import logging -import asyncio -import traceback - -from decimal import Decimal -from hashlib import sha256 -from datetime import datetime, timedelta - -import asks -import docker - -from PIL import Image -from leap.cleos import CLEOS -from leap.sugar import * -from leap.hyperion import HyperionAPI -from trio_asyncio import aio_as_trio -from telebot.types import ( - InputFile, InputMediaPhoto, InlineKeyboardButton, InlineKeyboardMarkup -) - -from telebot.types import CallbackQuery -from telebot.async_telebot import AsyncTeleBot, ExceptionHandler -from telebot.formatting import hlink - -from ..db import open_new_database, open_database_connection -from ..ipfs import open_ipfs_node, get_ipfs_file -from ..constants import * - -from . import * - - -class SKYExceptionHandler(ExceptionHandler): - - def handle(exception): - traceback.print_exc() - - -def build_redo_menu(): - btn_redo = InlineKeyboardButton("Redo", callback_data=json.dumps({'method': 'redo'})) - inline_keyboard = InlineKeyboardMarkup() - inline_keyboard.add(btn_redo) - return inline_keyboard - - -def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> str: - prompt = meta["prompt"] - if len(prompt) > 256: - prompt = prompt[:256] - - if tguser.username: - user = f'@{tguser.username}' - else: - user = f'{tguser.first_name} id: {tguser.id}' - - meta_str = f'by {user}\n' - meta_str += f'performed by {worker}\n' - meta_str += f'reward: {reward}\n' - - meta_str += f'prompt: {prompt}\n' - meta_str += f'seed: {meta["seed"]}\n' - meta_str += f'step: {meta["step"]}\n' - meta_str += f'guidance: {meta["guidance"]}\n' - if meta['strength']: - meta_str += f'strength: {meta["strength"]}\n' - meta_str += f'algo: {meta["algo"]}\n' - if meta['upscaler']: - meta_str += f'upscaler: {meta["upscaler"]}\n' - - meta_str += f'Made with Skynet v{VERSION}\n' - meta_str += f'JOIN THE SWARM: @skynetgpu' - return meta_str - - -def generate_reply_caption( - tguser, # telegram user - params: dict, - ipfs_hash: str, - tx_hash: str, - worker: str, - reward: str -): - ipfs_link = hlink( - 'Get your image on IPFS', - f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' - ) - explorer_link = hlink( - 'SKYNET Transaction Explorer', - f'https://skynet.ancap.tech/v2/explore/transaction/{tx_hash}' - ) - - meta_info = prepare_metainfo_caption(tguser, worker, reward, params) - - final_msg = '\n'.join([ - 'Worker finished your task!', - ipfs_link, - explorer_link, - f'PARAMETER INFO:\n{meta_info}' - ]) - - final_msg = '\n'.join([ - f'{ipfs_link}', - f'{explorer_link}', - f'{meta_info}' - ]) - - logging.info(final_msg) - - return final_msg - - -async def get_global_config(cleos): - return (await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'config'))[0] - -async def get_user_nonce(cleos, user: str): - return (await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'users', - index_position=1, - key_type='name', - lower_bound=user, - upper_bound=user - ))[0]['nonce'] - -async def work_request( - bot, cleos, hyperion, - message, user, chat, - account: str, - permission: str, - private_key: str, - params: dict, - file_id: str | None = None, - binary_data: str = '' -): - if params['seed'] == None: - params['seed'] = random.randint(0, 0xFFFFFFFF) - - sanitized_params = {} - for key, val in params.items(): - if isinstance(val, Decimal): - val = int(val) - - sanitized_params[key] = val - - body = json.dumps({ - 'method': 'diffuse', - 'params': sanitized_params - }) - request_time = datetime.now().isoformat() - - reward = '20.0000 GPU' - res = await cleos.a_push_action( - 'telos.gpu', - 'enqueue', - { - 'user': Name(account), - 'request_body': body, - 'binary_data': binary_data, - 'reward': asset_from_str(reward) - }, - account, private_key, permission=permission - ) - - if 'code' in res: - await bot.reply_to(message, json.dumps(res, indent=4)) - return - - out = collect_stdout(res) - - request_id, nonce = out.split(':') - - request_hash = sha256( - (nonce + body + binary_data).encode('utf-8')).hexdigest().upper() - - request_id = int(request_id) - logging.info(f'{request_id} enqueued.') - - config = await get_global_config(cleos) - - tx_hash = None - ipfs_hash = None - for i in range(60): - submits = await hyperion.aget_actions( - account=account, - filter='telos.gpu:submit', - sort='desc', - after=request_time - ) - actions = [ - action - for action in submits['actions'] - if action[ - 'act']['data']['request_hash'] == request_hash - ] - if len(actions) > 0: - tx_hash = actions[0]['trx_id'] - data = actions[0]['act']['data'] - ipfs_hash = data['ipfs_hash'] - worker = data['worker'] - logging.info('Found matching submit!') - break - - await asyncio.sleep(1) - - if not ipfs_hash: - await bot.reply_to(message, 'timeout processing request') - return - - # attempt to get the image and send it - ipfs_link = f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' - resp = await get_ipfs_file(ipfs_link) - - caption = generate_reply_caption( - user, params, ipfs_hash, tx_hash, worker, reward) - - if not resp or resp.status_code != 200: - logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') - await bot.reply_to( - message, - caption, - reply_markup=build_redo_menu(), - parse_mode='HTML' - ) - - else: - logging.info(f'succes! sending generated image') - if file_id: # img2img - await bot.send_media_group( - chat.id, - media=[ - InputMediaPhoto(file_id), - InputMediaPhoto( - resp.raw, - caption=caption, - parse_mode='HTML' - ) - ], - ) - - else: # txt2img - await bot.send_photo( - chat.id, - caption=caption, - photo=resp.raw, - reply_markup=build_redo_menu(), - parse_mode='HTML' - ) - - -async def run_skynet_telegram( - tg_token: str, - account: str, - permission: str, - node_url: str, - hyperion_url: str, - db_host: str, - db_user: str, - db_pass: str, - remote_ipfs_node: str, - key: str = None -): - cleos = CLEOS(None, None, url=node_url, remote=node_url) - hyperion = HyperionAPI(hyperion_url) - - logging.basicConfig(level=logging.INFO) - - bot = AsyncTeleBot(tg_token, exception_handler=SKYExceptionHandler) - logging.info(f'tg_token: {tg_token}') - - with open_ipfs_node() as ipfs_node: - ipfs_node.connect(remote_ipfs_node) - async with open_database_connection( - db_user, db_pass, db_host - ) as db_call: - - @bot.message_handler(commands=['help']) - async def send_help(message): - splt_msg = message.text.split(' ') - - if len(splt_msg) == 1: - await bot.reply_to(message, HELP_TEXT) - - else: - param = splt_msg[1] - if param in HELP_TOPICS: - await bot.reply_to(message, HELP_TOPICS[param]) - - else: - await bot.reply_to(message, HELP_UNKWNOWN_PARAM) - - @bot.message_handler(commands=['cool']) - async def send_cool_words(message): - await bot.reply_to(message, '\n'.join(COOL_WORDS)) - - async def _generic_txt2img(message_or_query): - if isinstance(message_or_query, CallbackQuery): - query = message_or_query - message = query.message - user = query.from_user - chat = query.message.chat - - else: - message = message_or_query - user = message.from_user - chat = message.chat - - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id - - prompt = ' '.join(message.text.split(' ')[1:]) - - if len(prompt) == 0: - await bot.reply_to(message, 'Empty text prompt ignored.') - return - - logging.info(f'mid: {message.id}') - - user_row = await db_call('get_or_create_user', user.id) - user_config = {**user_row} - del user_config['id'] - - params = { - 'prompt': prompt, - **user_config - } - - await db_call( - 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) - - ec = await work_request( - bot, cleos, hyperion, - message, user, chat, - account, permission, key, params - ) - - if ec == 0: - await db_call('increment_generated', user.id) - - async def _generic_img2img(message_or_query): - if isinstance(message_or_query, CallbackQuery): - query = message_or_query - message = query.message - user = query.from_user - chat = query.message.chat - - else: - message = message_or_query - user = message.from_user - chat = message.chat - - reply_id = None - if chat.type == 'group' and chat.id == GROUP_ID: - reply_id = message.message_id - - if not message.caption.startswith('/img2img'): - await bot.reply_to( - message, - 'For image to image you need to add /img2img to the beggining of your caption' - ) - return - - prompt = ' '.join(message.caption.split(' ')[1:]) - - if len(prompt) == 0: - await bot.reply_to(message, 'Empty text prompt ignored.') - return - - file_id = message.photo[-1].file_id - file_path = (await bot.get_file(file_id)).file_path - image_raw = await bot.download_file(file_path) - - with Image.open(io.BytesIO(image_raw)) as image: - w, h = image.size - - if w > 512 or h > 512: - logging.warning(f'user sent img of size {image.size}') - image.thumbnail((512, 512)) - logging.warning(f'resized it to {image.size}') - - image.save(f'ipfs-docker-staging/image.png', format='PNG') - - ipfs_hash = ipfs_node.add('image.png') - ipfs_node.pin(ipfs_hash) - - logging.info(f'published input image {ipfs_hash} on ipfs') - - logging.info(f'mid: {message.id}') - - user_row = await db_call('get_or_create_user', user.id) - user_config = {**user_row} - del user_config['id'] - - params = { - 'prompt': prompt, - **user_config - } - - await db_call( - 'update_user_stats', - user.id, - 'img2img', - last_file=file_id, - last_prompt=prompt, - last_binary=ipfs_hash - ) - - ec = await work_request( - bot, cleos, hyperion, - message, user, chat, - account, permission, key, params, - file_id=file_id, - binary_data=ipfs_hash - ) - - if ec == 0: - await db_call('increment_generated', user.id) - - @bot.message_handler(commands=['txt2img']) - async def send_txt2img(message): - await _generic_txt2img(message) - - @bot.message_handler(func=lambda message: True, content_types=[ - 'photo', 'document']) - async def send_img2img(message): - await _generic_img2img(message) - - @bot.message_handler(commands=['img2img']) - async def img2img_missing_image(message): - await bot.reply_to( - message, - 'seems you tried to do an img2img command without sending image' - ) - - async def _redo(message_or_query): - if isinstance(message_or_query, CallbackQuery): - query = message_or_query - message = query.message - user = query.from_user - chat = query.message.chat - - else: - message = message_or_query - user = message.from_user - chat = message.chat - - method = await db_call('get_last_method_of', user.id) - prompt = await db_call('get_last_prompt_of', user.id) - - file_id = None - binary = '' - if method == 'img2img': - file_id = await db_call('get_last_file_of', user.id) - binary = await db_call('get_last_binary_of', user.id) - - if not prompt: - await bot.reply_to( - message, - 'no last prompt found, do a txt2img cmd first!' - ) - return - - - user_row = await db_call('get_or_create_user', user.id) - user_config = {**user_row} - del user_config['id'] - - params = { - 'prompt': prompt, - **user_config - } - - await work_request( - bot, cleos, hyperion, - message, user, chat, - account, permission, key, params, - file_id=file_id, - binary_data=binary - ) - - @bot.message_handler(commands=['queue']) - async def queue(message): - an_hour_ago = datetime.now() - timedelta(hours=1) - queue = await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'queue', - index_position=2, - key_type='i64', - sort='desc', - lower_bound=int(an_hour_ago.timestamp()) - ) - await bot.reply_to( - message, f'Total requests on skynet queue: {len(queue)}') - - @bot.message_handler(commands=['redo']) - async def redo(message): - await _redo(message) - - @bot.message_handler(commands=['config']) - async def set_config(message): - user = message.from_user.id - try: - attr, val, reply_txt = validate_user_config_request( - message.text) - - logging.info(f'user config update: {attr} to {val}') - await db_call('update_user_config', user, attr, val) - logging.info('done') - - except BaseException as e: - reply_txt = str(e) - - finally: - await bot.reply_to(message, reply_txt) - - @bot.message_handler(commands=['stats']) - async def user_stats(message): - user = message.from_user.id - - await db_call('get_or_create_user', user) - generated, joined, role = await db_call('get_user_stats', user) - - stats_str = f'generated: {generated}\n' - stats_str += f'joined: {joined}\n' - stats_str += f'role: {role}\n' - - await bot.reply_to( - message, stats_str) - - @bot.message_handler(commands=['donate']) - async def donation_info(message): - await bot.reply_to( - message, DONATION_INFO) - - @bot.message_handler(commands=['say']) - async def say(message): - chat = message.chat - user = message.from_user - - if (chat.type == 'group') or (user.id != 383385940): - return - - await bot.send_message(GROUP_ID, message.text[4:]) - - @bot.message_handler(func=lambda message: True) - async def echo_message(message): - if message.text[0] == '/': - await bot.reply_to(message, UNKNOWN_CMD_TEXT) - - @bot.callback_query_handler(func=lambda call: True) - async def callback_query(call): - msg = json.loads(call.data) - logging.info(call.data) - method = msg.get('method') - match method: - case 'redo': - await _redo(call) - - try: - await bot.infinity_polling() - - except KeyboardInterrupt: - ... diff --git a/skynet/frontend/telegram/__init__.py b/skynet/frontend/telegram/__init__.py new file mode 100644 index 0000000..ecd66fd --- /dev/null +++ b/skynet/frontend/telegram/__init__.py @@ -0,0 +1,274 @@ +#!/usr/bin/python + +import random +import logging +import asyncio + +from decimal import Decimal +from hashlib import sha256 +from datetime import datetime +from contextlib import ExitStack, AsyncExitStack +from contextlib import asynccontextmanager as acm + +from leap.cleos import CLEOS +from leap.sugar import Name, asset_from_str, collect_stdout +from leap.hyperion import HyperionAPI +from telebot.asyncio_helper import ApiTelegramException +from telebot.types import InputMediaPhoto + +from telebot.types import CallbackQuery +from telebot.async_telebot import AsyncTeleBot + +from skynet.db import open_new_database, open_database_connection +from skynet.ipfs import open_ipfs_node, get_ipfs_file +from skynet.constants import * + +from . import * + +from .utils import * +from .handlers import create_handler_context + + +class SkynetTelegramFrontend: + + def __init__( + self, + token: str, + account: str, + permission: str, + node_url: str, + hyperion_url: str, + db_host: str, + db_user: str, + db_pass: str, + remote_ipfs_node: str, + key: str + ): + self.token = token + self.account = account + self.permission = permission + self.node_url = node_url + self.hyperion_url = hyperion_url + self.db_host = db_host + self.db_user = db_user + self.db_pass = db_pass + self.remote_ipfs_node = remote_ipfs_node + self.key = key + + self.bot = AsyncTeleBot(token, exception_handler=SKYExceptionHandler) + self.cleos = CLEOS(None, None, url=node_url, remote=node_url) + self.hyperion = HyperionAPI(hyperion_url) + + self._exit_stack = ExitStack() + self._async_exit_stack = AsyncExitStack() + + async def start(self): + self.ipfs_node = self._exit_stack.enter_context( + open_ipfs_node()) + + self.ipfs_node.connect(self.remote_ipfs_node) + logging.info( + f'connected to remote ipfs node: {self.remote_ipfs_node}') + + self.db_call = await self._async_exit_stack.enter_async_context( + open_database_connection( + self.db_user, self.db_pass, self.db_host)) + + create_handler_context(self) + + async def stop(self): + await self._async_exit_stack.aclose() + self._exit_stack.close() + + @acm + async def open(self): + await self.start() + yield self + await self.stop() + + async def update_status_message( + self, status_msg, new_text: str, **kwargs + ): + await self.db_call( + 'update_user_request_by_sid', status_msg.id, new_text) + return await self.bot.edit_message_text( + new_text, + chat_id=status_msg.chat.id, + message_id=status_msg.id, + **kwargs + ) + + async def append_status_message( + self, status_msg, add_text: str, **kwargs + ): + request = await self.db_call('get_user_request_by_sid', status_msg.id) + await self.update_status_message( + status_msg, + request['status'] + add_text, + **kwargs + ) + + async def work_request( + self, + user, + status_msg, + method: str, + params: dict, + file_id: str | None = None, + binary_data: str = '' + ): + if params['seed'] == None: + params['seed'] = random.randint(0, 0xFFFFFFFF) + + sanitized_params = {} + for key, val in params.items(): + if isinstance(val, Decimal): + val = str(val) + + sanitized_params[key] = val + + body = json.dumps({ + 'method': 'diffuse', + 'params': sanitized_params + }) + request_time = datetime.now().isoformat() + + await self.update_status_message( + status_msg, + f'processing a \'{method}\' request by {tg_user_pretty(user)}\n' + f'[{timestamp_pretty()}] broadcasting transaction to chain...', + parse_mode='HTML' + ) + + reward = '20.0000 GPU' + res = await self.cleos.a_push_action( + 'telos.gpu', + 'enqueue', + { + 'user': Name(self.account), + 'request_body': body, + 'binary_data': binary_data, + 'reward': asset_from_str(reward) + }, + self.account, self.key, permission=self.permission + ) + + if 'code' in res or 'statusCode' in res: + logging.error(json.dumps(res, indent=4)) + await self.update_status_message( + status_msg, + 'skynet has suffered an internal error trying to fill this request') + return + + enqueue_tx_id = res['transaction_id'] + enqueue_tx_link = hlink( + 'Your request on Skynet Explorer', + f'https://skynet.ancap.tech/v2/explore/transaction/{enqueue_tx_id}' + ) + + await self.append_status_message( + status_msg, + f' broadcasted!\n' + f'{enqueue_tx_link}\n' + f'[{timestamp_pretty()}] workers are processing request...', + parse_mode='HTML' + ) + + out = collect_stdout(res) + + request_id, nonce = out.split(':') + + request_hash = sha256( + (nonce + body + binary_data).encode('utf-8')).hexdigest().upper() + + request_id = int(request_id) + + logging.info(f'{request_id} enqueued.') + + tx_hash = None + ipfs_hash = None + for i in range(60): + submits = await self.hyperion.aget_actions( + account=self.account, + filter='telos.gpu:submit', + sort='desc', + after=request_time + ) + actions = [ + action + for action in submits['actions'] + if action[ + 'act']['data']['request_hash'] == request_hash + ] + if len(actions) > 0: + tx_hash = actions[0]['trx_id'] + data = actions[0]['act']['data'] + ipfs_hash = data['ipfs_hash'] + worker = data['worker'] + logging.info('Found matching submit!') + break + + await asyncio.sleep(1) + + if not ipfs_hash: + await self.update_status_message( + status_msg, + '\n[{timestamp_pretty()}] timeout processing request', + parse_mode='HTML' + ) + return + + tx_link = hlink( + 'Your result on Skynet Explorer', + f'https://skynet.ancap.tech/v2/explore/transaction/{tx_hash}' + ) + + await self.append_status_message( + status_msg, + f' request processed!\n' + f'{tx_link}\n' + f'[{timestamp_pretty()}] trying to download image...\n', + parse_mode='HTML' + ) + + # attempt to get the image and send it + ipfs_link = f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' + resp = await get_ipfs_file(ipfs_link) + + caption = generate_reply_caption( + user, params, ipfs_hash, tx_hash, worker, reward) + + if not resp or resp.status_code != 200: + logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') + await self.update_status_message( + status_msg, + caption, + reply_markup=build_redo_menu(), + parse_mode='HTML' + ) + + else: + logging.info(f'success! sending generated image') + await self.bot.delete_message( + chat_id=status_msg.chat.id, message_id=status_msg.id) + if file_id: # img2img + await self.bot.send_media_group( + status_msg.chat.id, + media=[ + InputMediaPhoto(file_id), + InputMediaPhoto( + resp.raw, + caption=caption, + parse_mode='HTML' + ) + ], + ) + + else: # txt2img + await self.bot.send_photo( + status_msg.chat.id, + caption=caption, + photo=resp.raw, + reply_markup=build_redo_menu(), + parse_mode='HTML' + ) diff --git a/skynet/frontend/telegram/handlers.py b/skynet/frontend/telegram/handlers.py new file mode 100644 index 0000000..7e77880 --- /dev/null +++ b/skynet/frontend/telegram/handlers.py @@ -0,0 +1,345 @@ +#!/usr/bin/python + +import io +import json +import logging + +from datetime import datetime, timedelta + +from PIL import Image +from telebot.types import CallbackQuery, Message + +from skynet.frontend import validate_user_config_request +from skynet.constants import * + + +def create_handler_context(frontend: 'SkynetTelegramFrontend'): + + bot = frontend.bot + cleos = frontend.cleos + db_call = frontend.db_call + work_request = frontend.work_request + + ipfs_node = frontend.ipfs_node + + # generic / simple handlers + + @bot.message_handler(commands=['help']) + async def send_help(message): + splt_msg = message.text.split(' ') + + if len(splt_msg) == 1: + await bot.reply_to(message, HELP_TEXT) + + else: + param = splt_msg[1] + if param in HELP_TOPICS: + await bot.reply_to(message, HELP_TOPICS[param]) + + else: + await bot.reply_to(message, HELP_UNKWNOWN_PARAM) + + @bot.message_handler(commands=['cool']) + async def send_cool_words(message): + await bot.reply_to(message, '\n'.join(COOL_WORDS)) + + @bot.message_handler(commands=['queue']) + async def queue(message): + an_hour_ago = datetime.now() - timedelta(hours=1) + queue = await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'queue', + index_position=2, + key_type='i64', + sort='desc', + lower_bound=int(an_hour_ago.timestamp()) + ) + await bot.reply_to( + message, f'Total requests on skynet queue: {len(queue)}') + + + @bot.message_handler(commands=['config']) + async def set_config(message): + user = message.from_user.id + try: + attr, val, reply_txt = validate_user_config_request( + message.text) + + logging.info(f'user config update: {attr} to {val}') + await db_call('update_user_config', user, attr, val) + logging.info('done') + + except BaseException as e: + reply_txt = str(e) + + finally: + await bot.reply_to(message, reply_txt) + + @bot.message_handler(commands=['stats']) + async def user_stats(message): + user = message.from_user.id + + await db_call('get_or_create_user', user) + generated, joined, role = await db_call('get_user_stats', user) + + stats_str = f'generated: {generated}\n' + stats_str += f'joined: {joined}\n' + stats_str += f'role: {role}\n' + + await bot.reply_to( + message, stats_str) + + @bot.message_handler(commands=['donate']) + async def donation_info(message): + await bot.reply_to( + message, DONATION_INFO) + + @bot.message_handler(commands=['say']) + async def say(message): + chat = message.chat + user = message.from_user + + if (chat.type == 'group') or (user.id != 383385940): + return + + await bot.send_message(GROUP_ID, message.text[4:]) + + + # generic txt2img handler + + async def _generic_txt2img(message_or_query): + if isinstance(message_or_query, CallbackQuery): + query = message_or_query + message = query.message + user = query.from_user + chat = query.message.chat + + else: + message = message_or_query + user = message.from_user + chat = message.chat + + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id + + user_row = await db_call('get_or_create_user', user.id) + + # init new msg + init_msg = 'started processing txt2img request...' + status_msg = await bot.reply_to(message, init_msg) + await db_call( + 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + + prompt = ' '.join(message.text.split(' ')[1:]) + + if len(prompt) == 0: + await bot.edit_message_text( + 'Empty text prompt ignored.', + chat_id=status_msg.chat.id, + message_id=status_msg.id + ) + await db_call('update_user_request', status_msg.id, 'Empty text prompt ignored.') + return + + logging.info(f'mid: {message.id}') + + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await db_call( + 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) + + ec = await work_request(user, status_msg, 'txt2img', params) + + if ec == 0: + await db_call('increment_generated', user.id) + + + # generic img2img handler + + async def _generic_img2img(message_or_query): + if isinstance(message_or_query, CallbackQuery): + query = message_or_query + message = query.message + user = query.from_user + chat = query.message.chat + + else: + message = message_or_query + user = message.from_user + chat = message.chat + + reply_id = None + if chat.type == 'group' and chat.id == GROUP_ID: + reply_id = message.message_id + + user_row = await db_call('get_or_create_user', user.id) + + # init new msg + init_msg = 'started processing txt2img request...' + status_msg = await bot.reply_to(message, init_msg) + await db_call( + 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + + if not message.caption.startswith('/img2img'): + await bot.reply_to( + message, + 'For image to image you need to add /img2img to the beggining of your caption' + ) + return + + prompt = ' '.join(message.caption.split(' ')[1:]) + + if len(prompt) == 0: + await bot.reply_to(message, 'Empty text prompt ignored.') + return + + file_id = message.photo[-1].file_id + file_path = (await bot.get_file(file_id)).file_path + image_raw = await bot.download_file(file_path) + + with Image.open(io.BytesIO(image_raw)) as image: + w, h = image.size + + if w > 512 or h > 512: + logging.warning(f'user sent img of size {image.size}') + image.thumbnail((512, 512)) + logging.warning(f'resized it to {image.size}') + + image.save(f'ipfs-docker-staging/image.png', format='PNG') + + ipfs_hash = ipfs_node.add('image.png') + ipfs_node.pin(ipfs_hash) + + logging.info(f'published input image {ipfs_hash} on ipfs') + + logging.info(f'mid: {message.id}') + + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await db_call( + 'update_user_stats', + user.id, + 'img2img', + last_file=file_id, + last_prompt=prompt, + last_binary=ipfs_hash + ) + + ec = await work_request( + user, status_msg, 'img2img', params, + file_id=file_id, + binary_data=ipfs_hash + ) + + if ec == 0: + await db_call('increment_generated', user.id) + + + # generic redo handler + + async def _redo(message_or_query): + is_query = False + if isinstance(message_or_query, CallbackQuery): + is_query = True + query = message_or_query + message = query.message + user = query.from_user + chat = query.message.chat + + elif isinstance(message_or_query, Message): + message = message_or_query + user = message.from_user + chat = message.chat + + init_msg = 'started processing redo request...' + if is_query: + status_msg = await bot.send_message(chat.id, init_msg) + + else: + status_msg = await bot.reply_to(message, init_msg) + + method = await db_call('get_last_method_of', user.id) + prompt = await db_call('get_last_prompt_of', user.id) + + file_id = None + binary = '' + if method == 'img2img': + file_id = await db_call('get_last_file_of', user.id) + binary = await db_call('get_last_binary_of', user.id) + + if not prompt: + await bot.reply_to( + message, + 'no last prompt found, do a txt2img cmd first!' + ) + return + + + user_row = await db_call('get_or_create_user', user.id) + await db_call( + 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await work_request( + user, status_msg, 'redo', params, + file_id=file_id, + binary_data=binary + ) + + + # "proxy" handlers just request routers + + @bot.message_handler(commands=['txt2img']) + async def send_txt2img(message): + await _generic_txt2img(message) + + @bot.message_handler(func=lambda message: True, content_types=[ + 'photo', 'document']) + async def send_img2img(message): + await _generic_img2img(message) + + @bot.message_handler(commands=['img2img']) + async def img2img_missing_image(message): + await bot.reply_to( + message, + 'seems you tried to do an img2img command without sending image' + ) + + @bot.message_handler(commands=['redo']) + async def redo(message): + await _redo(message) + + @bot.callback_query_handler(func=lambda call: True) + async def callback_query(call): + msg = json.loads(call.data) + logging.info(call.data) + method = msg.get('method') + match method: + case 'redo': + await _redo(call) + + + # catch all handler for things we dont support + + @bot.message_handler(func=lambda message: True) + async def echo_message(message): + if message.text[0] == '/': + await bot.reply_to(message, UNKNOWN_CMD_TEXT) diff --git a/skynet/frontend/telegram/utils.py b/skynet/frontend/telegram/utils.py new file mode 100644 index 0000000..682cd02 --- /dev/null +++ b/skynet/frontend/telegram/utils.py @@ -0,0 +1,113 @@ +#!/usr/bin/python + +import json +import logging +import traceback + +from datetime import datetime, timezone + +from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup +from telebot.async_telebot import ExceptionHandler +from telebot.formatting import hlink + +from skynet.constants import * + + +def timestamp_pretty(): + return datetime.now(timezone.utc).strftime('%H:%M:%S') + + +def tg_user_pretty(tguser): + if tguser.username: + return f'@{tguser.username}' + else: + return f'{tguser.first_name} id: {tguser.id}' + + +class SKYExceptionHandler(ExceptionHandler): + + def handle(exception): + traceback.print_exc() + + +def build_redo_menu(): + btn_redo = InlineKeyboardButton("Redo", callback_data=json.dumps({'method': 'redo'})) + inline_keyboard = InlineKeyboardMarkup() + inline_keyboard.add(btn_redo) + return inline_keyboard + + +def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> str: + prompt = meta["prompt"] + if len(prompt) > 256: + prompt = prompt[:256] + + + meta_str = f'by {tg_user_pretty(tguser)}\n' + meta_str += f'performed by {worker}\n' + meta_str += f'reward: {reward}\n' + + meta_str += f'prompt: {prompt}\n' + meta_str += f'seed: {meta["seed"]}\n' + meta_str += f'step: {meta["step"]}\n' + meta_str += f'guidance: {meta["guidance"]}\n' + if meta['strength']: + meta_str += f'strength: {meta["strength"]}\n' + meta_str += f'algo: {meta["algo"]}\n' + if meta['upscaler']: + meta_str += f'upscaler: {meta["upscaler"]}\n' + + meta_str += f'Made with Skynet v{VERSION}\n' + meta_str += f'JOIN THE SWARM: @skynetgpu' + return meta_str + + +def generate_reply_caption( + tguser, # telegram user + params: dict, + ipfs_hash: str, + tx_hash: str, + worker: str, + reward: str +): + ipfs_link = hlink( + 'Get your image on IPFS', + f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' + ) + explorer_link = hlink( + 'SKYNET Transaction Explorer', + f'https://skynet.ancap.tech/v2/explore/transaction/{tx_hash}' + ) + + meta_info = prepare_metainfo_caption(tguser, worker, reward, params) + + final_msg = '\n'.join([ + 'Worker finished your task!', + ipfs_link, + explorer_link, + f'PARAMETER INFO:\n{meta_info}' + ]) + + final_msg = '\n'.join([ + f'{ipfs_link}', + f'{explorer_link}', + f'{meta_info}' + ]) + + logging.info(final_msg) + + return final_msg + + +async def get_global_config(cleos): + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'config'))[0] + +async def get_user_nonce(cleos, user: str): + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'users', + index_position=1, + key_type='name', + lower_bound=user, + upper_bound=user + ))[0]['nonce'] From 64a15a0ab9bc807c7228f7c4c5a4b9c991cf237a Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 4 Jun 2023 01:11:03 -0300 Subject: [PATCH 24/88] Fix db schema for users outside int32 range --- skynet/db/functions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skynet/db/functions.py b/skynet/db/functions.py index 8a56484..c97bcf5 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -23,7 +23,7 @@ DB_INIT_SQL = ''' CREATE SCHEMA IF NOT EXISTS skynet; CREATE TABLE IF NOT EXISTS skynet.user( - id SERIAL PRIMARY KEY NOT NULL, + id BIGSERIAL PRIMARY KEY NOT NULL, generated INT NOT NULL, joined TIMESTAMP NOT NULL, last_method TEXT, @@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS skynet.user( ); CREATE TABLE IF NOT EXISTS skynet.user_config( - id SERIAL NOT NULL, + id BIGSERIAL NOT NULL, algo VARCHAR(128) NOT NULL, step INT NOT NULL, width INT NOT NULL, @@ -49,11 +49,11 @@ ALTER TABLE skynet.user_config REFERENCES skynet.user(id); CREATE TABLE IF NOT EXISTS skynet.user_requests( - id SERIAL NOT NULL, - user_id SERIAL NOT NULL, + id BIGSERIAL NOT NULL, + user_id BIGSERIAL NOT NULL, sent TIMESTAMP NOT NULL, status TEXT NOT NULL, - status_msg SERIAL PRIMARY KEY NOT NULL + status_msg BIGSERIAL PRIMARY KEY NOT NULL ); ALTER TABLE skynet.user_requests ADD FOREIGN KEY(user_id) From 0cb2565d65134ed913123953c78fb864abe052d2 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 4 Jun 2023 01:11:32 -0300 Subject: [PATCH 25/88] Update py-leap to 1a14 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3543e62..a30a623 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ aiohttp psycopg2-binary pyTelegramBotAPI -py-leap@git+https://github.com/guilledk/py-leap.git@v0.1a13 +py-leap@git+https://github.com/guilledk/py-leap.git@v0.1a14 From d5b04a673ce783f8dcd7570c33d580a656a82dfc Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 4 Jun 2023 14:06:41 -0300 Subject: [PATCH 26/88] Update smart contract --- skynet/dgpu.py | 20 ++--- skynet/frontend/telegram/__init__.py | 3 +- skynet/nodeos.py | 2 +- tests/contracts/telos.gpu/telos.gpu.abi | 98 ++++++++++++++++++++--- tests/contracts/telos.gpu/telos.gpu.wasm | Bin 44297 -> 46064 bytes 5 files changed, 98 insertions(+), 25 deletions(-) diff --git a/skynet/dgpu.py b/skynet/dgpu.py index 763fdd1..209dbc3 100644 --- a/skynet/dgpu.py +++ b/skynet/dgpu.py @@ -174,7 +174,10 @@ async def open_dgpu_node( lower_bound=int(time.time()) - 3600 ) - except asks.errors.RequestTimeout: + except ( + asks.errors.RequestTimeout, + json.JSONDecodeError + ): return [] async def get_status_by_request_id(request_id: int): @@ -201,16 +204,6 @@ async def open_dgpu_node( else: return None - async def get_user_nonce(user: str): - logging.info('get_user_nonce') - return (await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'users', - index_position=1, - key_type='name', - lower_bound=user, - upper_bound=user - ))[0]['nonce'] - async def begin_work(request_id: int): logging.info('begin_work') return await cleos.a_push_action( @@ -218,7 +211,8 @@ async def open_dgpu_node( 'workbegin', { 'worker': Name(account), - 'request_id': request_id + 'request_id': request_id, + 'max_workers': 2 }, account, key, permission=permission @@ -328,7 +322,7 @@ async def open_dgpu_node( if rid not in my_results: statuses = await get_status_by_request_id(rid) - if len(statuses) < config['verification_amount']: + if len(statuses) < req['min_verification']: # parse request body = json.loads(req['body']) diff --git a/skynet/frontend/telegram/__init__.py b/skynet/frontend/telegram/__init__.py index ecd66fd..f417842 100644 --- a/skynet/frontend/telegram/__init__.py +++ b/skynet/frontend/telegram/__init__.py @@ -148,7 +148,8 @@ class SkynetTelegramFrontend: 'user': Name(self.account), 'request_body': body, 'binary_data': binary_data, - 'reward': asset_from_str(reward) + 'reward': asset_from_str(reward), + 'min_verification': 1 }, self.account, self.key, permission=self.permission ) diff --git a/skynet/nodeos.py b/skynet/nodeos.py index b853208..dddc935 100644 --- a/skynet/nodeos.py +++ b/skynet/nodeos.py @@ -145,7 +145,7 @@ def open_nodeos(cleanup: bool = True): ec, out = cleos.push_action( 'telos.gpu', 'config', - [1, 'eosio.token', '4,GPU'], + ['eosio.token', '4,GPU'], f'telos.gpu@active' ) assert ec == 0 diff --git a/tests/contracts/telos.gpu/telos.gpu.abi b/tests/contracts/telos.gpu/telos.gpu.abi index 406c268..f3708bf 100644 --- a/tests/contracts/telos.gpu/telos.gpu.abi +++ b/tests/contracts/telos.gpu/telos.gpu.abi @@ -21,6 +21,40 @@ } ] }, + { + "name": "card", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "owner", + "type": "name" + }, + { + "name": "card_name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "total_memory", + "type": "uint64" + }, + { + "name": "mp_count", + "type": "uint32" + }, + { + "name": "extra", + "type": "string" + } + ] + }, { "name": "clean", "base": "", @@ -30,10 +64,6 @@ "name": "config", "base": "", "fields": [ - { - "name": "verification_amount", - "type": "uint8" - }, { "name": "token_contract", "type": "name" @@ -72,11 +102,15 @@ }, { "name": "binary_data", - "type": "bytes" + "type": "string" }, { "name": "reward", "type": "asset" + }, + { + "name": "min_verification", + "type": "uint32" } ] }, @@ -84,10 +118,6 @@ "name": "global_configuration_struct", "base": "", "fields": [ - { - "name": "verification_amount", - "type": "uint8" - }, { "name": "token_contract", "type": "name" @@ -154,13 +184,21 @@ "name": "reward", "type": "asset" }, + { + "name": "min_verification", + "type": "uint32" + }, + { + "name": "nonce", + "type": "uint64" + }, { "name": "body", "type": "string" }, { "name": "binary_data", - "type": "bytes" + "type": "string" }, { "name": "timestamp", @@ -213,6 +251,10 @@ { "name": "request_id", "type": "uint64" + }, + { + "name": "max_workers", + "type": "uint32" } ] }, @@ -234,6 +276,28 @@ } ] }, + { + "name": "worker", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "joined", + "type": "time_point_sec" + }, + { + "name": "left", + "type": "time_point_sec" + }, + { + "name": "url", + "type": "string" + } + ] + }, { "name": "worker_status_struct", "base": "", @@ -296,6 +360,13 @@ } ], "tables": [ + { + "name": "cards", + "type": "card", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, { "name": "config", "type": "global_configuration_struct", @@ -330,6 +401,13 @@ "index_type": "i64", "key_names": [], "key_types": [] + }, + { + "name": "workers", + "type": "worker", + "index_type": "i64", + "key_names": [], + "key_types": [] } ], "ricardian_clauses": [], diff --git a/tests/contracts/telos.gpu/telos.gpu.wasm b/tests/contracts/telos.gpu/telos.gpu.wasm index 18dc265b2fdcba8074df182b4c7190f7a0936659..688185263477a3e2acbe25ab4f9eed5839ca1eaa 100755 GIT binary patch literal 46064 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 A;Q#;t literal 44297 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ΝWA?KgS$BT`o1XgvG*T9wq6;|c$n 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 HSeX9@n1wEE From fc513b89afda4c3bab035e6b8d38536b16fdb62a Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 4 Jun 2023 14:37:10 -0300 Subject: [PATCH 27/88] Retry pin --- skynet/cli.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 8a2d8ac..e1ed879 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -485,12 +485,21 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): async def task_pin(cid: str): logging.info(f'pinning {cid}...') - resp = await ipfs_node.a_pin(cid) - if resp.status_code != 200: - logging.error(f'error pinning {cid}:\n{resp.text}') + for i in range(6): + try: + with trio.move_on_after(5): + resp = await ipfs_node.a_pin(cid) + if resp.status_code != 200: + logging.error(f'error pinning {cid}:\n{resp.text}') - else: - logging.info(f'pinned {cid}') + else: + logging.info(f'pinned {cid}') + return + + except trio.TooSlowError: + logging.error(f'timed out pinning {cid}') + + logging.error(f'gave up pinning {cid}') try: async with trio.open_nursery() as n: From bbc5751837e54b01c2eb9de40bb14935b51d7d05 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 4 Jun 2023 17:51:43 -0300 Subject: [PATCH 28/88] Separate worker into submodules --- skynet.ini.example | 23 +- skynet/cli.py | 99 +++---- skynet/config.py | 78 ++++-- skynet/constants.py | 35 ++- skynet/dgpu.py | 375 --------------------------- skynet/dgpu/__init__.py | 16 ++ skynet/dgpu/compute.py | 165 ++++++++++++ skynet/dgpu/daemon.py | 92 +++++++ skynet/dgpu/errors.py | 5 + skynet/dgpu/network.py | 191 ++++++++++++++ skynet/frontend/telegram/__init__.py | 2 +- skynet/frontend/telegram/handlers.py | 7 + skynet/frontend/telegram/utils.py | 2 +- skynet/nodeos.py | 2 + skynet/utils.py | 67 +++-- tests/test_deploy.py | 10 +- 16 files changed, 684 insertions(+), 485 deletions(-) delete mode 100644 skynet/dgpu.py create mode 100644 skynet/dgpu/__init__.py create mode 100644 skynet/dgpu/compute.py create mode 100644 skynet/dgpu/daemon.py create mode 100644 skynet/dgpu/errors.py create mode 100644 skynet/dgpu/network.py diff --git a/skynet.ini.example b/skynet.ini.example index 1faf8b8..b498dd8 100644 --- a/skynet.ini.example +++ b/skynet.ini.example @@ -1,11 +1,24 @@ -[skynet.account] -name = xxxxxxxxxxxx -permission = active -key = EOSXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - [skynet.dgpu] +account = testworkerX +permission = active +key = 5Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +node_url = https://skynet.ancap.tech +hyperion_url = https://skynet.ancap.tech +ipfs_url = /ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv + hf_home = hf_home hf_token = hf_XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx +auto_withdraw = True + [skynet.telegram] +account = telegram +permission = active +key = 5Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +node_url = https://skynet.ancap.tech +hyperion_url = https://skynet.ancap.tech +ipfs_url = /ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv + token = XXXXXXXXXX:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/skynet/cli.py b/skynet/cli.py index e1ed879..a9f240a 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -17,8 +17,8 @@ import docker import asyncio import requests -from leap.cleos import CLEOS, default_nodeos_image -from leap.sugar import get_container, collect_stdout +from leap.cleos import CLEOS +from leap.sugar import collect_stdout from leap.hyperion import HyperionAPI from .db import open_new_database @@ -46,7 +46,7 @@ def skynet(*args, **kwargs): @click.option('--seed', '-S', default=None) def txt2img(*args, **kwargs): from . import utils - _, hf_token, _, cfg = init_env_from_config() + _, hf_token, _ = init_env_from_config() utils.txt2img(hf_token, **kwargs) @click.command() @@ -61,7 +61,7 @@ def txt2img(*args, **kwargs): @click.option('--seed', '-S', default=None) def img2img(model, prompt, input, output, strength, guidance, steps, seed): from . import utils - _, hf_token, _, cfg = init_env_from_config() + _, hf_token, _ = init_env_from_config() utils.img2img( hf_token, model=model, @@ -89,7 +89,7 @@ def upscale(input, output, model): @skynet.command() def download(): from . import utils - _, hf_token, _, cfg = init_env_from_config() + _, hf_token, _ = init_env_from_config() utils.download_all_models(hf_token) @skynet.command() @@ -122,7 +122,11 @@ def enqueue( **kwargs ): key, account, permission = load_account_info( - key, account, permission) + 'user', key, account, permission) + + node_url, _, _ = load_endpoint_info( + 'user', node_url, None, None) + with open_cleos(node_url, key=key) as cleos: if not kwargs['seed']: kwargs['seed'] = random.randint(0, 10e9) @@ -157,6 +161,12 @@ def clean( key: str | None, node_url: str, ): + key, account, permission = load_account_info( + 'user', key, account, permission) + + node_url, _, _ = load_endpoint_info( + 'user', node_url, None, None) + logging.basicConfig(level=loglevel) cleos = CLEOS(None, None, url=node_url, remote=node_url) trio.run( @@ -173,6 +183,8 @@ def clean( @click.option( '--node-url', '-n', default='https://skynet.ancap.tech') def queue(node_url: str): + node_url, _, _ = load_endpoint_info( + 'user', node_url, None, None) resp = requests.post( f'{node_url}/v1/chain/get_table_rows', json={ @@ -189,6 +201,8 @@ def queue(node_url: str): '--node-url', '-n', default='https://skynet.ancap.tech') @click.argument('request-id') def status(node_url: str, request_id: int): + node_url, _, _ = load_endpoint_info( + 'user', node_url, None, None) resp = requests.post( f'{node_url}/v1/chain/get_table_rows', json={ @@ -218,7 +232,11 @@ def dequeue( request_id: int ): key, account, permission = load_account_info( - key, account, permission) + 'user', key, account, permission) + + node_url, _, _ = load_endpoint_info( + 'user', node_url, None, None) + with open_cleos(node_url, key=key) as cleos: ec, out = cleos.push_action( 'telos.gpu', 'dequeue', [account, request_id], f'{account}@{permission}' @@ -236,8 +254,6 @@ def dequeue( '--key', '-k', default=None) @click.option( '--node-url', '-n', default='https://skynet.ancap.tech') -@click.option( - '--verifications', '-v', default=1) @click.option( '--token-contract', '-c', default='eosio.token') @click.option( @@ -247,15 +263,17 @@ def config( permission: str, key: str | None, node_url: str, - verifications: int, token_contract: str, token_symbol: str ): key, account, permission = load_account_info( - key, account, permission) + 'user', key, account, permission) + + node_url, _, _ = load_endpoint_info( + 'user', node_url, None, None) with open_cleos(node_url, key=key) as cleos: ec, out = cleos.push_action( - 'telos.gpu', 'config', [verifications, token_contract, token_symbol], f'{account}@{permission}' + 'telos.gpu', 'config', [token_contract, token_symbol], f'{account}@{permission}' ) print(collect_stdout(out)) @@ -279,7 +297,10 @@ def deposit( quantity: str ): key, account, permission = load_account_info( - key, account, permission) + 'user', key, account, permission) + + node_url, _, _ = load_endpoint_info( + 'user', node_url, None, None) with open_cleos(node_url, key=key) as cleos: ec, out = cleos.transfer_token(account, 'telos.gpu', quantity) @@ -304,45 +325,22 @@ def nodeos(): ... @run.command() -@click.option('--loglevel', '-l', default='warning', help='Logging level') +@click.option('--loglevel', '-l', default='INFO', help='Logging level') @click.option( - '--account', '-a', default='testworker1') -@click.option( - '--permission', '-p', default='active') -@click.option( - '--key', '-k', default=None) -@click.option( - '--auto-withdraw', '-w', default=True) -@click.option( - '--node-url', '-n', default='https://skynet.ancap.tech') -@click.option( - '--ipfs-url', '-n', default=DEFAULT_IPFS_REMOTE) -@click.option( - '--algos', '-A', default=json.dumps(['midj'])) + '--config-path', '-c', default='skynet.ini') def dgpu( loglevel: str, - account: str, - permission: str, - key: str | None, - auto_withdraw: bool, - node_url: str, - ipfs_url: str, - algos: list[str] + config_path: str ): from .dgpu import open_dgpu_node - key, account, permission = load_account_info( - key, account, permission) + logging.basicConfig(level=loglevel) - trio.run( - partial( - open_dgpu_node, - account, permission, - CLEOS(None, None, url=node_url, remote=node_url), - ipfs_url, - auto_withdraw=auto_withdraw, - key=key, initial_algos=json.loads(algos) - )) + config = load_skynet_ini(file_path=config_path) + + assert 'skynet.dgpu' in config + + trio.run(open_dgpu_node, config['skynet.dgpu']) @run.command() @@ -379,10 +377,13 @@ def telegram( ): logging.basicConfig(level=loglevel) - key, account, permission = load_account_info( - key, account, permission) + _, _, tg_token = init_env_from_config() - _, _, tg_token, cfg = init_env_from_config() + key, account, permission = load_account_info( + 'telegram', key, account, permission) + + node_url, _, ipfs_url = load_endpoint_info( + 'telegram', node_url, None, None) async def _async_main(): frontend = SkynetTelegramFrontend( @@ -485,7 +486,7 @@ def pinner(loglevel, ipfs_rpc, hyperion_url): async def task_pin(cid: str): logging.info(f'pinning {cid}...') - for i in range(6): + for _ in range(6): try: with trio.move_on_after(5): resp = await ipfs_node.a_pin(cid) diff --git a/skynet/config.py b/skynet/config.py index ace1dd0..fc8f2d9 100644 --- a/skynet/config.py +++ b/skynet/config.py @@ -1,9 +1,11 @@ #!/usr/bin/python import os +import json from pathlib import Path from configparser import ConfigParser +from re import sub from .constants import DEFAULT_CONFIG_PATH @@ -13,45 +15,89 @@ def load_skynet_ini( ): config = ConfigParser() config.read(file_path) + return config def init_env_from_config( + hf_token: str | None = None, + hf_home: str | None = None, + tg_token: str | None = None, file_path=DEFAULT_CONFIG_PATH ): - config = load_skynet_ini() + config = load_skynet_ini(file_path=file_path) if 'HF_TOKEN' in os.environ: hf_token = os.environ['HF_TOKEN'] - else: - hf_token = config['skynet.dgpu']['hf_token'] + + elif 'skynet.dgpu' in config: + sub_config = config['skynet.dgpu'] + if 'hf_token' in sub_config: + hf_token = sub_config['hf_token'] if 'HF_HOME' in os.environ: hf_home = os.environ['HF_HOME'] - else: - hf_home = config['skynet.dgpu']['hf_home'] + + elif 'skynet.dgpu' in config: + sub_config = config['skynet.dgpu'] + if 'hf_home' in sub_config: + hf_home = sub_config['hf_home'] if 'TG_TOKEN' in os.environ: tg_token = os.environ['TG_TOKEN'] - else: - tg_token = config['skynet.telegram']['token'] + elif 'skynet.telegram' in config: + sub_config = config['skynet.telegram'] + if 'token' in sub_config: + tg_token = sub_config['token'] - return hf_home, hf_token, tg_token, config + return hf_home, hf_token, tg_token def load_account_info( - key, account, permission, + _type: str, + key: str | None = None, + account: str | None = None, + permission: str | None = None, file_path=DEFAULT_CONFIG_PATH ): - _, _, _, config = init_env_from_config() + config = load_skynet_ini(file_path=file_path) - if not key: - key = config['skynet.account']['key'] + type_key = f'skynet.{_type}' - if not account: - account = config['skynet.account']['name'] + if type_key in config: + sub_config = config[type_key] + if not key and 'key' in sub_config: + key = sub_config['key'] - if not permission: - permission = config['skynet.account']['permission'] + if not account and 'name' in sub_config: + account = sub_config['name'] + + if not permission and 'permission' in sub_config: + permission = sub_config['permission'] return key, account, permission + + +def load_endpoint_info( + _type: str, + node_url: str | None = None, + hyperion_url: str | None = None, + ipfs_url: str | None = None, + file_path=DEFAULT_CONFIG_PATH +): + config = load_skynet_ini(file_path=file_path) + + type_key = f'skynet.{_type}' + + if type_key in config: + sub_config = config[type_key] + if not node_url and 'node_url' in sub_config: + node_url = sub_config['node_url'] + + if not hyperion_url and 'hyperion_url' in sub_config: + hyperion_url = sub_config['hyperion_url'] + + if not ipfs_url and 'ipfs_url' in sub_config: + ipfs_url = sub_config['ipfs_url'] + + return node_url, hyperion_url, ipfs_url diff --git a/skynet/constants.py b/skynet/constants.py index 74fed5d..7e41d7a 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -1,21 +1,26 @@ #!/usr/bin/python -VERSION = '0.1a9' +VERSION = '0.1a10' DOCKER_RUNTIME_CUDA = 'skynet:runtime-cuda' -ALGOS = { - 'midj': 'prompthero/openjourney', - 'stable': 'runwayml/stable-diffusion-v1-5', - 'hdanime': 'Linaqruf/anything-v3.0', - 'waifu': 'hakurei/waifu-diffusion', - 'ghibli': 'nitrosocke/Ghibli-Diffusion', - 'van-gogh': 'dallinmackay/Van-Gogh-diffusion', - 'pokemon': 'lambdalabs/sd-pokemon-diffusers', - 'ink': 'Envvi/Inkpunk-Diffusion', - 'robot': 'nousr/robo-diffusion' +MODELS = { + 'prompthero/openjourney': { 'short': 'midj'}, + 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, + 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, + 'hakurei/waifu-diffusion': { 'short': 'waifu'}, + 'nitrosocke/Ghibli-Diffusion': { 'short': 'ghibli'}, + 'dallinmackay/Van-Gogh-diffusion': { 'short': 'van-gogh'}, + 'lambdalabs/sd-pokemon-diffusers': { 'short': 'pokemon'}, + 'Envvi/Inkpunk-Diffusion': { 'short': 'ink'}, + 'nousr/robo-diffusion': { 'short': 'robot'} } +def get_model_by_shortname(short: str): + for model, info in MODELS.items(): + if short == info['short']: + return model + N = '\n' HELP_TEXT = f''' test art bot v{VERSION} @@ -36,7 +41,7 @@ config is individual to each user! /config algo NAME - select AI to use one of: -{N.join(ALGOS.keys())} +{N.join(MODELS.keys())} /config step NUMBER - set amount of iterations /config seed NUMBER - set the seed, deterministic results! @@ -115,8 +120,10 @@ DEFAULT_UPSCALER = None DEFAULT_CONFIG_PATH = 'skynet.ini' -DEFAULT_DGPU_MAX_TASKS = 2 -DEFAULT_INITAL_ALGOS = ['midj', 'stable', 'ink'] +DEFAULT_INITAL_MODELS = [ + 'prompthero/openjourney', + 'runwayml/stable-diffusion-v1-5' +] DATE_FORMAT = '%B the %dth %Y, %H:%M:%S' diff --git a/skynet/dgpu.py b/skynet/dgpu.py deleted file mode 100644 index 209dbc3..0000000 --- a/skynet/dgpu.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/python - -import gc -import io -import json -import time -import logging -import traceback - -from PIL import Image -from typing import List, Optional -from hashlib import sha256 - -import trio -import asks -import torch - -from leap.cleos import CLEOS -from leap.sugar import * - -from realesrgan import RealESRGANer -from basicsr.archs.rrdbnet_arch import RRDBNet - -from .ipfs import open_ipfs_node, get_ipfs_file -from .utils import * -from .constants import * - - -def init_upscaler(model_path: str = 'weights/RealESRGAN_x4plus.pth'): - return RealESRGANer( - scale=4, - model_path=model_path, - dni_weight=None, - model=RRDBNet( - num_in_ch=3, - num_out_ch=3, - num_feat=64, - num_block=23, - num_grow_ch=32, - scale=4 - ), - half=True - ) - - -class DGPUComputeError(BaseException): - ... - - -async def open_dgpu_node( - account: str, - permission: str, - cleos: CLEOS, - remote_ipfs_node: str, - key: str = None, - initial_algos: Optional[List[str]] = None, - auto_withdraw: bool = True -): - - logging.basicConfig(level=logging.INFO) - logging.info(f'starting dgpu node!') - logging.info(f'launching toolchain container!') - - logging.info(f'loading models...') - - upscaler = init_upscaler() - initial_algos = ( - initial_algos - if initial_algos else DEFAULT_INITAL_ALGOS - ) - models = {} - for algo in initial_algos: - models[algo] = { - 'pipe': pipeline_for(algo), - 'generated': 0 - } - logging.info(f'loaded {algo}.') - - logging.info('memory summary:') - logging.info('\n' + torch.cuda.memory_summary()) - - def gpu_compute_one(method: str, params: dict, binext: Optional[bytes] = None): - match method: - case 'diffuse': - image = None - algo = params['algo'] - if binext: - algo += 'img' - image = Image.open(io.BytesIO(binext)) - w, h = image.size - logging.info(f'user sent img of size {image.size}') - - if w > 512 or h > 512: - image.thumbnail((512, 512)) - logging.info(f'resized it to {image.size}') - - if algo not in models: - if params['algo'] not in ALGOS: - raise DGPUComputeError(f'Unknown algo \"{algo}\"') - - logging.info(f'{algo} not in loaded models, swapping...') - least_used = list(models.keys())[0] - for model in models: - if models[least_used]['generated'] > models[model]['generated']: - least_used = model - - del models[least_used] - gc.collect() - - models[algo] = { - 'pipe': pipeline_for(params['algo'], image=True if binext else False), - 'generated': 0 - } - logging.info(f'swapping done.') - - _params = {} - logging.info(method) - logging.info(json.dumps(params, indent=4)) - logging.info(f'binext: {len(binext) if binext else 0} bytes') - if binext: - _params['image'] = image - _params['strength'] = float(Decimal(params['strength'])) - - else: - _params['width'] = int(params['width']) - _params['height'] = int(params['height']) - - try: - image = models[algo]['pipe']( - params['prompt'], - **_params, - guidance_scale=float(Decimal(params['guidance'])), - num_inference_steps=int(params['step']), - generator=torch.manual_seed(int(params['seed'])) - ).images[0] - - if params['upscaler'] == 'x4': - logging.info(f'size: {len(image.tobytes())}') - logging.info('performing upscale...') - input_img = image.convert('RGB') - up_img, _ = upscaler.enhance( - convert_from_image_to_cv2(input_img), outscale=4) - - image = convert_from_cv2_to_image(up_img) - logging.info('done') - - img_byte_arr = io.BytesIO() - image.save(img_byte_arr, format='PNG') - raw_img = img_byte_arr.getvalue() - img_sha = sha256(raw_img).hexdigest() - logging.info(f'final img size {len(raw_img)} bytes.') - - logging.info(params) - - return img_sha, raw_img - - except BaseException as e: - logging.error(e) - raise DGPUComputeError(str(e)) - - finally: - torch.cuda.empty_cache() - - case _: - raise DGPUComputeError('Unsupported compute method') - - async def get_work_requests_last_hour(): - logging.info('get_work_requests_last_hour') - try: - return await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'queue', - index_position=2, - key_type='i64', - lower_bound=int(time.time()) - 3600 - ) - - except ( - asks.errors.RequestTimeout, - json.JSONDecodeError - ): - return [] - - async def get_status_by_request_id(request_id: int): - logging.info('get_status_by_request_id') - return await cleos.aget_table( - 'telos.gpu', request_id, 'status') - - async def get_global_config(): - logging.info('get_global_config') - return (await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'config'))[0] - - async def get_worker_balance(): - logging.info('get_worker_balance') - rows = await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'users', - index_position=1, - key_type='name', - lower_bound=account, - upper_bound=account - ) - if len(rows) == 1: - return rows[0]['balance'] - else: - return None - - async def begin_work(request_id: int): - logging.info('begin_work') - return await cleos.a_push_action( - 'telos.gpu', - 'workbegin', - { - 'worker': Name(account), - 'request_id': request_id, - 'max_workers': 2 - }, - account, key, - permission=permission - ) - - async def cancel_work(request_id: int, reason: str): - logging.info('cancel_work') - return await cleos.a_push_action( - 'telos.gpu', - 'workcancel', - { - 'worker': Name(account), - 'request_id': request_id, - 'reason': reason - }, - account, key, - permission=permission - ) - - async def maybe_withdraw_all(): - logging.info('maybe_withdraw_all') - balance = await get_worker_balance() - if not balance: - return - - balance_amount = float(balance.split(' ')[0]) - if balance_amount > 0: - await cleos.a_push_action( - 'telos.gpu', - 'withdraw', - { - 'user': Name(account), - 'quantity': asset_from_str(balance) - }, - account, key, - permission=permission - ) - - async def find_my_results(): - logging.info('find_my_results') - return await cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'results', - index_position=4, - key_type='name', - lower_bound=account, - upper_bound=account - ) - - ipfs_node = None - def publish_on_ipfs(img_sha: str, raw_img: bytes): - logging.info('publish_on_ipfs') - img = Image.open(io.BytesIO(raw_img)) - img.save(f'ipfs-docker-staging/image.png') - - ipfs_hash = ipfs_node.add('image.png') - - ipfs_node.pin(ipfs_hash) - - return ipfs_hash - - async def submit_work( - request_id: int, - request_hash: str, - result_hash: str, - ipfs_hash: str - ): - logging.info('submit_work') - await cleos.a_push_action( - 'telos.gpu', - 'submit', - { - 'worker': Name(account), - 'request_id': request_id, - 'request_hash': Checksum256(request_hash), - 'result_hash': Checksum256(result_hash), - 'ipfs_hash': ipfs_hash - }, - account, key, - permission=permission - ) - - async def get_input_data(ipfs_hash: str) -> bytes: - if ipfs_hash == '': - return b'' - - resp = await get_ipfs_file(f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png') - if resp.status_code != 200: - raise DGPUComputeError('Couldn\'t gather input data from ipfs') - - return resp.raw - - config = await get_global_config() - - with open_ipfs_node() as ipfs_node: - ipfs_node.connect(remote_ipfs_node) - try: - while True: - if auto_withdraw: - await maybe_withdraw_all() - - queue = await get_work_requests_last_hour() - - for req in queue: - rid = req['id'] - - my_results = [res['id'] for res in (await find_my_results())] - if rid not in my_results: - statuses = await get_status_by_request_id(rid) - - if len(statuses) < req['min_verification']: - - # parse request - body = json.loads(req['body']) - - binary = await get_input_data(req['binary_data']) - - hash_str = ( - str(req['nonce']) - + - req['body'] - + - req['binary_data'] - ) - logging.info(f'hashing: {hash_str}') - request_hash = sha256(hash_str.encode('utf-8')).hexdigest() - - # TODO: validate request - - # perform work - logging.info(f'working on {body}') - - resp = await begin_work(rid) - if 'code' in resp: - logging.info(f'probably beign worked on already... skip.') - - else: - try: - img_sha, raw_img = gpu_compute_one( - body['method'], body['params'], binext=binary) - - ipfs_hash = publish_on_ipfs(img_sha, raw_img) - - await submit_work(rid, request_hash, img_sha, ipfs_hash) - break - - except BaseException as e: - traceback.print_exc() - await cancel_work(rid, str(e)) - break - - else: - logging.info(f'request {rid} already beign worked on, skip...') - - await trio.sleep(1) - - except KeyboardInterrupt: - ... - - - diff --git a/skynet/dgpu/__init__.py b/skynet/dgpu/__init__.py new file mode 100644 index 0000000..ca34499 --- /dev/null +++ b/skynet/dgpu/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/python + +import trio + +from skynet.dgpu.compute import SkynetMM +from skynet.dgpu.daemon import SkynetDGPUDaemon +from skynet.dgpu.network import SkynetGPUConnector + + +async def open_dgpu_node(config: dict): + conn = SkynetGPUConnector(config) + mm = SkynetMM(config) + + async with conn.open() as conn: + await (SkynetDGPUDaemon(mm, conn, config) + .serve_forever()) diff --git a/skynet/dgpu/compute.py b/skynet/dgpu/compute.py new file mode 100644 index 0000000..424d704 --- /dev/null +++ b/skynet/dgpu/compute.py @@ -0,0 +1,165 @@ +#!/usr/bin/python + +# Skynet Memory Manager + +import gc +from hashlib import sha256 +import json +import logging + +import torch +from skynet.constants import DEFAULT_INITAL_MODELS, MODELS +from skynet.dgpu.errors import DGPUComputeError + +from skynet.utils import convert_from_bytes_and_crop, convert_from_cv2_to_image, convert_from_image_to_cv2, convert_from_img_to_bytes, init_upscaler, pipeline_for + + +def prepare_params_for_diffuse( + params: dict, + binary: bytes | None = None +): + image = None + if binary: + image = convert_from_bytes_and_crop(binary, 512, 512) + + _params = {} + if image: + _params['image'] = image + _params['strength'] = float(params['strength']) + + else: + _params['width'] = int(params['width']) + _params['height'] = int(params['height']) + + return ( + params['prompt'], + float(params['guidance']), + int(params['step']), + torch.manual_seed(int(params['seed'])), + params['upscaler'] if 'upscaler' in params else None, + _params + ) + + +class SkynetMM: + + def __init__(self, config: dict): + self.upscaler = init_upscaler() + self.initial_models = ( + config['initial_models'] + if 'initial_models' in config else DEFAULT_INITAL_MODELS + ) + + self._models = {} + for model in self.initial_models: + self.load_model(model, False, force=True) + + def log_debug_info(self): + logging.info('memory summary:') + logging.info('\n' + torch.cuda.memory_summary()) + + def is_model_loaded(self, model_name: str, image: bool): + for model_key, model_data in self._models.items(): + if (model_key == model_name and + model_data['image'] == image): + return True + + return False + + def load_model( + self, + model_name: str, + image: bool, + force=False + ): + logging.info(f'loading model {model_name}...') + if force or len(self._models.keys()) == 0: + pipe = pipeline_for(model_name, image=image) + self._models[model_name] = { + 'pipe': pipe, + 'generated': 0, + 'image': image + } + + else: + least_used = list(self._models.keys())[0] + + for model in self._models: + if self._models[ + least_used]['generated'] > self._models[model]['generated']: + least_used = model + + del self._models[least_used] + + logging.info(f'swapping model {least_used} for {model_name}...') + + gc.collect() + torch.cuda.empty_cache() + + pipe = pipeline_for(model_name, image=image) + + self._models[model_name] = { + 'pipe': pipe, + 'generated': 0, + 'image': image + } + + logging.info(f'loaded model {model_name}') + return pipe + + def get_model(self, model_name: str, image: bool): + if model_name not in MODELS: + raise DGPUComputeError(f'Unknown model {model_name}') + + if not self.is_model_loaded(model_name, image): + pipe = self.load_model(model_name, image=image) + + else: + pipe = self._models[model_name] + + return pipe + + def compute_one( + self, + method: str, + params: dict, + binary: bytes | None = None + ): + try: + match method: + case 'diffuse': + image = None + + arguments = prepare_params_for_diffuse(params, binary) + prompt, guidance, step, seed, upscaler, extra_params = arguments + model = self.get_model(params['model'], 'image' in params) + + image = model['pipe']( + prompt, + guidance_scale=guidance, + num_inference_steps=step, + generator=seed, + **extra_params + ).images[0] + + if upscaler == 'x4': + input_img = image.convert('RGB') + up_img, _ = upscaler.enhance( + convert_from_image_to_cv2(input_img), outscale=4) + + image = convert_from_cv2_to_image(up_img) + + img_raw = convert_from_img_to_bytes(image) + img_sha = sha256(img_raw).hexdigest() + + return img_sha, img_raw + + case _: + raise DGPUComputeError('Unsupported compute method') + + except BaseException as e: + logging.error(e) + raise DGPUComputeError(str(e)) + + finally: + torch.cuda.empty_cache() diff --git a/skynet/dgpu/daemon.py b/skynet/dgpu/daemon.py new file mode 100644 index 0000000..4897f43 --- /dev/null +++ b/skynet/dgpu/daemon.py @@ -0,0 +1,92 @@ +#!/usr/bin/python + +import json +import logging +import traceback + +from hashlib import sha256 + +import trio + +from skynet.dgpu.compute import SkynetMM +from skynet.dgpu.network import SkynetGPUConnector + + +class SkynetDGPUDaemon: + + def __init__( + self, + mm: SkynetMM, + conn: SkynetGPUConnector, + config: dict + ): + self.mm = mm + self.conn = conn + self.auto_withdraw = ( + config['auto_withdraw'] + if 'auto_withdraw' in config else False + ) + + async def serve_forever(self): + try: + while True: + if self.auto_withdraw: + await self.conn.maybe_withdraw_all() + + queue = await self.conn.get_work_requests_last_hour() + + for req in queue: + rid = req['id'] + + my_results = [res['id'] for res in (await self.conn.find_my_results())] + if rid not in my_results: + statuses = await self.conn.get_status_by_request_id(rid) + + if len(statuses) < req['min_verification']: + + # parse request + body = json.loads(req['body']) + + binary = await self.conn.get_input_data(req['binary_data']) + + hash_str = ( + str(req['nonce']) + + + req['body'] + + + req['binary_data'] + ) + logging.info(f'hashing: {hash_str}') + request_hash = sha256(hash_str.encode('utf-8')).hexdigest() + + # TODO: validate request + + # perform work + logging.info(f'working on {body}') + + resp = await self.conn.begin_work(rid) + if 'code' in resp: + logging.info(f'probably beign worked on already... skip.') + + else: + try: + img_sha, img_raw = self.mm.compute_one( + body['method'], body['params'], binary=binary) + + ipfs_hash = self.conn.publish_on_ipfs( img_raw) + + await self.conn.submit_work(rid, request_hash, img_sha, ipfs_hash) + break + + except BaseException as e: + traceback.print_exc() + await self.conn.cancel_work(rid, str(e)) + break + + else: + logging.info(f'request {rid} already beign worked on, skip...') + + await trio.sleep(1) + + except KeyboardInterrupt: + ... diff --git a/skynet/dgpu/errors.py b/skynet/dgpu/errors.py new file mode 100644 index 0000000..1f08624 --- /dev/null +++ b/skynet/dgpu/errors.py @@ -0,0 +1,5 @@ +#!/usr/bin/python + + +class DGPUComputeError(BaseException): + ... diff --git a/skynet/dgpu/network.py b/skynet/dgpu/network.py new file mode 100644 index 0000000..55b8228 --- /dev/null +++ b/skynet/dgpu/network.py @@ -0,0 +1,191 @@ +#!/usr/bin/python + +import io +import json +import time +import logging + +import asks +from PIL import Image + +from contextlib import ExitStack +from contextlib import asynccontextmanager as acm + +from leap.cleos import CLEOS +from leap.sugar import Checksum256, Name, asset_from_str + +from skynet.dgpu.errors import DGPUComputeError +from skynet.ipfs import get_ipfs_file, open_ipfs_node + + +class SkynetGPUConnector: + + def __init__(self, config: dict): + self.account = Name(config['account']) + self.permission = config['permission'] + self.key = config['key'] + self.node_url = config['node_url'] + self.hyperion_url = config['hyperion_url'] + self.ipfs_url = config['ipfs_url'] + + self.cleos = CLEOS( + None, None, self.node_url, remote=self.node_url) + + self._exit_stack = ExitStack() + + def connect(self): + self.ipfs_node = self._exit_stack.enter_context( + open_ipfs_node()) + + def disconnect(self): + self._exit_stack.close() + + @acm + async def open(self): + self.connect() + yield self + self.disconnect() + + # blockchain helpers + + async def get_work_requests_last_hour(self): + logging.info('get_work_requests_last_hour') + try: + return await self.cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'queue', + index_position=2, + key_type='i64', + lower_bound=int(time.time()) - 3600 + ) + + except ( + asks.errors.RequestTimeout, + json.JSONDecodeError + ): + return [] + + async def get_status_by_request_id(self, request_id: int): + logging.info('get_status_by_request_id') + return await self.cleos.aget_table( + 'telos.gpu', request_id, 'status') + + async def get_global_config(self): + logging.info('get_global_config') + return (await self.cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'config'))[0] + + async def get_worker_balance(self): + logging.info('get_worker_balance') + rows = await self.cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'users', + index_position=1, + key_type='name', + lower_bound=self.account, + upper_bound=self.account + ) + if len(rows) == 1: + return rows[0]['balance'] + else: + return None + + async def begin_work(self, request_id: int): + logging.info('begin_work') + return await self.cleos.a_push_action( + 'telos.gpu', + 'workbegin', + { + 'worker': self.account, + 'request_id': request_id, + 'max_workers': 2 + }, + self.account, self.key, + permission=self.permission + ) + + async def cancel_work(self, request_id: int, reason: str): + logging.info('cancel_work') + return await self.cleos.a_push_action( + 'telos.gpu', + 'workcancel', + { + 'worker': self.account, + 'request_id': request_id, + 'reason': reason + }, + self.account, self.key, + permission=self.permission + ) + + async def maybe_withdraw_all(self): + logging.info('maybe_withdraw_all') + balance = await self.get_worker_balance() + if not balance: + return + + balance_amount = float(balance.split(' ')[0]) + if balance_amount > 0: + await self.cleos.a_push_action( + 'telos.gpu', + 'withdraw', + { + 'user': self.account, + 'quantity': asset_from_str(balance) + }, + self.account, self.key, + permission=self.permission + ) + + async def find_my_results(self): + logging.info('find_my_results') + return await self.cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'results', + index_position=4, + key_type='name', + lower_bound=self.account, + upper_bound=self.account + ) + + async def submit_work( + self, + request_id: int, + request_hash: str, + result_hash: str, + ipfs_hash: str + ): + logging.info('submit_work') + await self.cleos.a_push_action( + 'telos.gpu', + 'submit', + { + 'worker': self.account, + 'request_id': request_id, + 'request_hash': Checksum256(request_hash), + 'result_hash': Checksum256(result_hash), + 'ipfs_hash': ipfs_hash + }, + self.account, self.key, + permission=self.permission + ) + + # IPFS helpers + + def publish_on_ipfs(self, raw_img: bytes): + logging.info('publish_on_ipfs') + img = Image.open(io.BytesIO(raw_img)) + img.save(f'ipfs-docker-staging/image.png') + + ipfs_hash = self.ipfs_node.add('image.png') + + self.ipfs_node.pin(ipfs_hash) + + return ipfs_hash + + async def get_input_data(self, ipfs_hash: str) -> bytes: + if ipfs_hash == '': + return b'' + + resp = await get_ipfs_file(f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png') + if resp.status_code != 200: + raise DGPUComputeError('Couldn\'t gather input data from ipfs') + + return resp.raw diff --git a/skynet/frontend/telegram/__init__.py b/skynet/frontend/telegram/__init__.py index f417842..07a39c5 100644 --- a/skynet/frontend/telegram/__init__.py +++ b/skynet/frontend/telegram/__init__.py @@ -214,7 +214,7 @@ class SkynetTelegramFrontend: if not ipfs_hash: await self.update_status_message( status_msg, - '\n[{timestamp_pretty()}] timeout processing request', + f'\n[{timestamp_pretty()}] timeout processing request', parse_mode='HTML' ) return diff --git a/skynet/frontend/telegram/handlers.py b/skynet/frontend/telegram/handlers.py index 7e77880..17d3213 100644 --- a/skynet/frontend/telegram/handlers.py +++ b/skynet/frontend/telegram/handlers.py @@ -151,6 +151,9 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): **user_config } + params['model'] = get_model_by_shortname(params['algo']) + del params['algo'] + await db_call( 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) @@ -227,6 +230,8 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): 'prompt': prompt, **user_config } + params['model'] = get_model_by_shortname(params['algo']) + del params['algo'] await db_call( 'update_user_stats', @@ -297,6 +302,8 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): 'prompt': prompt, **user_config } + params['model'] = get_model_by_shortname(params['algo']) + del params['algo'] await work_request( user, status_msg, 'redo', params, diff --git a/skynet/frontend/telegram/utils.py b/skynet/frontend/telegram/utils.py index 682cd02..cebb6e5 100644 --- a/skynet/frontend/telegram/utils.py +++ b/skynet/frontend/telegram/utils.py @@ -53,7 +53,7 @@ def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> st meta_str += f'guidance: {meta["guidance"]}\n' if meta['strength']: meta_str += f'strength: {meta["strength"]}\n' - meta_str += f'algo: {meta["algo"]}\n' + meta_str += f'algo: {meta["model"]}\n' if meta['upscaler']: meta_str += f'upscaler: {meta["upscaler"]}\n' diff --git a/skynet/nodeos.py b/skynet/nodeos.py index dddc935..01524e8 100644 --- a/skynet/nodeos.py +++ b/skynet/nodeos.py @@ -116,12 +116,14 @@ def open_nodeos(cleanup: bool = True): priv, pub = cleos.create_key_pair() cleos.import_key(priv) + cleos.private_keys['telos.gpu'] = priv logging.info(f'GPU KEYS: {(priv, pub)}') cleos.new_account('telos.gpu', ram=4200000, key=pub) for i in range(1, 4): priv, pub = cleos.create_key_pair() cleos.import_key(priv) + cleos.private_keys[f'testworker{i}'] = priv logging.info(f'testworker{i} KEYS: {(priv, pub)}') cleos.create_account_staked( 'eosio', f'testworker{i}', key=pub) diff --git a/skynet/utils.py b/skynet/utils.py index 3789aa4..e4bd04b 100644 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -1,5 +1,6 @@ #!/usr/bin/python +import io import os import time import random @@ -13,6 +14,7 @@ import numpy as np from PIL import Image from basicsr.archs.rrdbnet_arch import RRDBNet from diffusers import ( + DiffusionPipeline, StableDiffusionPipeline, StableDiffusionImg2ImgPipeline, EulerAncestralDiscreteScheduler @@ -20,7 +22,7 @@ from diffusers import ( from realesrgan import RealESRGANer from huggingface_hub import login -from .constants import ALGOS +from .constants import MODELS def time_ms(): @@ -37,7 +39,24 @@ def convert_from_image_to_cv2(img: Image) -> np.ndarray: return np.asarray(img) -def pipeline_for(algo: str, mem_fraction: float = 1.0, image=False): +def convert_from_bytes_to_img(raw: bytes) -> Image: + return Image.open(io.BytesIO(raw)) + + +def convert_from_img_to_bytes(image: Image, fmt='PNG') -> bytes: + byte_arr = io.BytesIO() + image.save(byte_arr, format=fmt) + return byte_arr.getvalue() + + +def convert_from_bytes_and_crop(raw: bytes, max_w: int, max_h: int) -> Image: + image = convert_from_bytes_to_img(raw) + w, h = image.size + if w > max_w or h > max_h: + image.thumbnail((512, 512)) + + +def pipeline_for(model: str, mem_fraction: float = 1.0, image=False) -> DiffusionPipeline: assert torch.cuda.is_available() torch.cuda.empty_cache() torch.cuda.set_per_process_memory_fraction(mem_fraction) @@ -56,7 +75,7 @@ def pipeline_for(algo: str, mem_fraction: float = 1.0, image=False): 'safety_checker': None } - if algo == 'stable': + if model == 'runwayml/stable-diffusion-v1-5': params['revision'] = 'fp16' if image: @@ -65,7 +84,7 @@ def pipeline_for(algo: str, mem_fraction: float = 1.0, image=False): pipe_class = StableDiffusionPipeline pipe = pipe_class.from_pretrained( - ALGOS[algo], **params) + model, **params) pipe.scheduler = EulerAncestralDiscreteScheduler.from_config( pipe.scheduler.config) @@ -78,7 +97,7 @@ def pipeline_for(algo: str, mem_fraction: float = 1.0, image=False): def txt2img( hf_token: str, - model: str = 'midj', + model: str = 'prompthero/openjourney', prompt: str = 'a red old tractor in a sunny wheat field', output: str = 'output.png', width: int = 512, height: int = 512, @@ -110,7 +129,7 @@ def txt2img( def img2img( hf_token: str, - model: str = 'midj', + model: str = 'prompthero/openjourney', prompt: str = 'a red old tractor in a sunny wheat field', img_path: str = 'input.png', output: str = 'output.png', @@ -143,6 +162,23 @@ def img2img( image.save(output) + +def init_upscaler(model_path: str = 'weights/RealESRGAN_x4plus.pth'): + return RealESRGANer( + scale=4, + model_path=model_path, + dni_weight=None, + model=RRDBNet( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_block=23, + num_grow_ch=32, + scale=4 + ), + half=True + ) + def upscale( img_path: str = 'input.png', output: str = 'output.png', @@ -156,19 +192,7 @@ def upscale( input_img = Image.open(img_path).convert('RGB') - upscaler = RealESRGANer( - scale=4, - model_path=model_path, - dni_weight=None, - model=RRDBNet( - num_in_ch=3, - num_out_ch=3, - num_feat=64, - num_block=23, - num_grow_ch=32, - scale=4 - ), - half=True) + upscaler = init_upscaler(model_path=model_path) up_img, _ = upscaler.enhance( convert_from_image_to_cv2(input_img), outscale=4) @@ -183,7 +207,8 @@ def download_all_models(hf_token: str): assert torch.cuda.is_available() login(token=hf_token) - for model in ALGOS: + for model in MODELS: print(f'DOWNLOADING {model.upper()}') pipeline_for(model) - + print(f'DOWNLOADING IMAGE {model.upper()}') + pipeline_for(model, image=True) diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 3598788..62ef635 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -8,6 +8,7 @@ from functools import partial import trio import requests +from skynet.constants import DEFAULT_IPFS_REMOTE from skynet.dgpu import open_dgpu_node @@ -32,7 +33,7 @@ def test_enqueue_work(cleos): binary = '' ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [user, req, binary, '20.0000 GPU'], f'{user}@active' + 'telos.gpu', 'enqueue', [user, req, binary, '20.0000 GPU', 1], f'{user}@active' ) assert ec == 0 @@ -53,6 +54,8 @@ def test_enqueue_work(cleos): f'testworker1', 'active', cleos, + DEFAULT_IPFS_REMOTE, + cleos.private_keys['testworker1'], initial_algos=['midj'] ) ) @@ -80,12 +83,13 @@ def test_enqueue_dequeue(cleos): binary = '' ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [user, req, binary, '20.0000 GPU'], f'{user}@active' + 'telos.gpu', 'enqueue', [user, req, binary, '20.0000 GPU', 1], f'{user}@active' ) assert ec == 0 - request_id = int(collect_stdout(out)) + request_id, _ = collect_stdout(out).split(':') + request_id = int(request_id) queue = cleos.get_table('telos.gpu', 'telos.gpu', 'queue') From aa41c08d2ff0d0795b2824df72879d4de6eec0cc Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 5 Jun 2023 11:52:16 -0300 Subject: [PATCH 29/88] Upscaler fix & frontend model selection naming chanes --- skynet/dgpu/compute.py | 2 +- skynet/frontend/__init__.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/skynet/dgpu/compute.py b/skynet/dgpu/compute.py index 424d704..ce34910 100644 --- a/skynet/dgpu/compute.py +++ b/skynet/dgpu/compute.py @@ -144,7 +144,7 @@ class SkynetMM: if upscaler == 'x4': input_img = image.convert('RGB') - up_img, _ = upscaler.enhance( + up_img, _ = self.upscaler.enhance( convert_from_image_to_cv2(input_img), outscale=4) image = convert_from_cv2_to_image(up_img) diff --git a/skynet/frontend/__init__.py b/skynet/frontend/__init__.py index 6a2557e..46ebd9f 100644 --- a/skynet/frontend/__init__.py +++ b/skynet/frontend/__init__.py @@ -32,9 +32,12 @@ def validate_user_config_request(req: str): match attr: case 'algo': val = params[2] - if val not in ALGOS: + shorts = [model_info['short'] for model_info in MODELS.values()] + if val not in shorts: raise ConfigUnknownAlgorithm(f'no algo named {val}') + val = get_model_by_shortname(val) + case 'step': val = int(params[2]) val = max(min(val, MAX_STEP), MIN_STEP) From 91edb2aa56a718d10c884e5f635fd3d491403b6d Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Tue, 6 Jun 2023 12:27:40 -0300 Subject: [PATCH 30/88] Frontend db model name related fixes, and gpu worker fixes when swapping models --- skynet/constants.py | 10 ++++++++-- skynet/db/functions.py | 6 +++--- skynet/dgpu/compute.py | 7 ++++--- skynet/dgpu/daemon.py | 2 +- skynet/frontend/__init__.py | 5 +++-- skynet/frontend/telegram/handlers.py | 7 ------- 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/skynet/constants.py b/skynet/constants.py index 7e41d7a..a1e13f4 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -16,6 +16,11 @@ MODELS = { 'nousr/robo-diffusion': { 'short': 'robot'} } +SHORT_NAMES = [ + model_info['short'] + for model_info in MODELS.values() +] + def get_model_by_shortname(short: str): for model, info in MODELS.items(): if short == info['short']: @@ -40,8 +45,9 @@ config is individual to each user! /donate - see donation info /config algo NAME - select AI to use one of: +/config model NAME - select AI to use one of: -{N.join(MODELS.keys())} +{N.join(SHORT_NAMES)} /config step NUMBER - set amount of iterations /config seed NUMBER - set the seed, deterministic results! @@ -114,7 +120,7 @@ DEFAULT_GUIDANCE = 7.5 DEFAULT_STRENGTH = 0.5 DEFAULT_STEP = 35 DEFAULT_CREDITS = 10 -DEFAULT_ALGO = 'midj' +DEFAULT_MODEL = list(MODELS.keys())[0] DEFAULT_ROLE = 'pleb' DEFAULT_UPSCALER = None diff --git a/skynet/db/functions.py b/skynet/db/functions.py index c97bcf5..ac75a97 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS skynet.user( CREATE TABLE IF NOT EXISTS skynet.user_config( id BIGSERIAL NOT NULL, - algo VARCHAR(128) NOT NULL, + model VARCHAR(512) NOT NULL, step INT NOT NULL, width INT NOT NULL, height INT NOT NULL, @@ -278,13 +278,13 @@ async def new_user(conn, uid: int): stmt = await conn.prepare(''' INSERT INTO skynet.user_config( - id, algo, step, width, height, guidance, strength, upscaler) + id, model, step, width, height, guidance, strength, upscaler) VALUES($1, $2, $3, $4, $5, $6, $7, $8) ''') resp = await stmt.fetch( uid, - DEFAULT_ALGO, + DEFAULT_MODEL, DEFAULT_STEP, DEFAULT_WIDTH, DEFAULT_HEIGHT, diff --git a/skynet/dgpu/compute.py b/skynet/dgpu/compute.py index ce34910..a51072d 100644 --- a/skynet/dgpu/compute.py +++ b/skynet/dgpu/compute.py @@ -6,6 +6,7 @@ import gc from hashlib import sha256 import json import logging +from diffusers import DiffusionPipeline import torch from skynet.constants import DEFAULT_INITAL_MODELS, MODELS @@ -107,7 +108,7 @@ class SkynetMM: logging.info(f'loaded model {model_name}') return pipe - def get_model(self, model_name: str, image: bool): + def get_model(self, model_name: str, image: bool) -> DiffusionPipeline: if model_name not in MODELS: raise DGPUComputeError(f'Unknown model {model_name}') @@ -115,7 +116,7 @@ class SkynetMM: pipe = self.load_model(model_name, image=image) else: - pipe = self._models[model_name] + pipe = self._models[model_name]['pipe'] return pipe @@ -134,7 +135,7 @@ class SkynetMM: prompt, guidance, step, seed, upscaler, extra_params = arguments model = self.get_model(params['model'], 'image' in params) - image = model['pipe']( + image = model( prompt, guidance_scale=guidance, num_inference_steps=step, diff --git a/skynet/dgpu/daemon.py b/skynet/dgpu/daemon.py index 4897f43..216a800 100644 --- a/skynet/dgpu/daemon.py +++ b/skynet/dgpu/daemon.py @@ -42,7 +42,7 @@ class SkynetDGPUDaemon: if rid not in my_results: statuses = await self.conn.get_status_by_request_id(rid) - if len(statuses) < req['min_verification']: + if len(statuses) == 0: # parse request body = json.loads(req['body']) diff --git a/skynet/frontend/__init__.py b/skynet/frontend/__init__.py index 46ebd9f..42145a3 100644 --- a/skynet/frontend/__init__.py +++ b/skynet/frontend/__init__.py @@ -30,11 +30,12 @@ def validate_user_config_request(req: str): attr = params[1] match attr: - case 'algo': + case 'model' | 'algo': + attr = 'model' val = params[2] shorts = [model_info['short'] for model_info in MODELS.values()] if val not in shorts: - raise ConfigUnknownAlgorithm(f'no algo named {val}') + raise ConfigUnknownAlgorithm(f'no model named {val}') val = get_model_by_shortname(val) diff --git a/skynet/frontend/telegram/handlers.py b/skynet/frontend/telegram/handlers.py index 17d3213..7e77880 100644 --- a/skynet/frontend/telegram/handlers.py +++ b/skynet/frontend/telegram/handlers.py @@ -151,9 +151,6 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): **user_config } - params['model'] = get_model_by_shortname(params['algo']) - del params['algo'] - await db_call( 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) @@ -230,8 +227,6 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): 'prompt': prompt, **user_config } - params['model'] = get_model_by_shortname(params['algo']) - del params['algo'] await db_call( 'update_user_stats', @@ -302,8 +297,6 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): 'prompt': prompt, **user_config } - params['model'] = get_model_by_shortname(params['algo']) - del params['algo'] await work_request( user, status_msg, 'redo', params, From c8a0a390a67eb3725ddca4dd1521d75e43383841 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Thu, 8 Jun 2023 21:25:07 -0300 Subject: [PATCH 31/88] Fix for img2img mode on new worker system --- skynet/cli.py | 2 +- skynet/config.py | 2 ++ skynet/dgpu/compute.py | 2 +- skynet/utils.py | 6 ++++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index a9f240a..63b3d92 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -50,7 +50,7 @@ def txt2img(*args, **kwargs): utils.txt2img(hf_token, **kwargs) @click.command() -@click.option('--model', '-m', default='midj') +@click.option('--model', '-m', default=list(MODELS.keys())[0]) @click.option( '--prompt', '-p', default='a red old tractor in a sunny wheat field') @click.option('--input', '-i', default='input.png') diff --git a/skynet/config.py b/skynet/config.py index fc8f2d9..d068295 100644 --- a/skynet/config.py +++ b/skynet/config.py @@ -34,6 +34,7 @@ def init_env_from_config( sub_config = config['skynet.dgpu'] if 'hf_token' in sub_config: hf_token = sub_config['hf_token'] + os.environ['HF_TOKEN'] = hf_token if 'HF_HOME' in os.environ: hf_home = os.environ['HF_HOME'] @@ -42,6 +43,7 @@ def init_env_from_config( sub_config = config['skynet.dgpu'] if 'hf_home' in sub_config: hf_home = sub_config['hf_home'] + os.environ['HF_HOME'] = hf_home if 'TG_TOKEN' in os.environ: tg_token = os.environ['TG_TOKEN'] diff --git a/skynet/dgpu/compute.py b/skynet/dgpu/compute.py index a51072d..069af47 100644 --- a/skynet/dgpu/compute.py +++ b/skynet/dgpu/compute.py @@ -133,7 +133,7 @@ class SkynetMM: arguments = prepare_params_for_diffuse(params, binary) prompt, guidance, step, seed, upscaler, extra_params = arguments - model = self.get_model(params['model'], 'image' in params) + model = self.get_model(params['model'], 'image' in extra_params) image = model( prompt, diff --git a/skynet/utils.py b/skynet/utils.py index e4bd04b..2837118 100644 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -55,6 +55,8 @@ def convert_from_bytes_and_crop(raw: bytes, max_w: int, max_h: int) -> Image: if w > max_w or h > max_h: image.thumbnail((512, 512)) + return image.convert('RGB') + def pipeline_for(model: str, mem_fraction: float = 1.0, image=False) -> DiffusionPipeline: assert torch.cuda.is_available() @@ -147,7 +149,8 @@ def img2img( login(token=hf_token) pipe = pipeline_for(model, image=True) - input_img = Image.open(img_path).convert('RGB') + with open(img_path, 'rb') as img_file: + input_img = convert_from_bytes_and_crop(img_file.read(), 512, 512) seed = seed if seed else random.randint(0, 2 ** 64) prompt = prompt @@ -162,7 +165,6 @@ def img2img( image.save(output) - def init_upscaler(model_path: str = 'weights/RealESRGAN_x4plus.pth'): return RealESRGANer( scale=4, From 44bfc5e9e705e2e68125f524eff0ba85be406a34 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 10 Jun 2023 09:27:04 -0300 Subject: [PATCH 32/88] Make worker more resilient by using failable wrapper on network calls, modularize ipfs module and pinner code, drop ipfs links from telegram response and make explorer link easily configurable --- skynet/cli.py | 119 ++---------------- skynet/constants.py | 2 + skynet/dgpu/network.py | 174 ++++++++++++++++----------- skynet/frontend/telegram/__init__.py | 13 +- skynet/frontend/telegram/utils.py | 11 +- skynet/ipfs/__init__.py | 45 +++++++ skynet/{ipfs.py => ipfs/docker.py} | 40 ++---- skynet/ipfs/pinner.py | 127 +++++++++++++++++++ 8 files changed, 309 insertions(+), 222 deletions(-) create mode 100644 skynet/ipfs/__init__.py rename skynet/{ipfs.py => ipfs/docker.py} (70%) create mode 100644 skynet/ipfs/pinner.py diff --git a/skynet/cli.py b/skynet/cli.py index 63b3d92..767b2da 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -1,19 +1,14 @@ #!/usr/bin/python -import os -import time import json import logging import random -from typing import Optional -from datetime import datetime, timedelta from functools import partial import trio import asks import click -import docker import asyncio import requests @@ -21,8 +16,10 @@ from leap.cleos import CLEOS from leap.sugar import collect_stdout from leap.hyperion import HyperionAPI +from skynet.ipfs import IPFSHTTP + + from .db import open_new_database -from .ipfs import open_ipfs_node from .config import * from .nodeos import open_cleos, open_nodeos from .constants import * @@ -352,9 +349,9 @@ def dgpu( @click.option( '--key', '-k', default=None) @click.option( - '--hyperion-url', '-y', default='https://skynet.ancap.tech') + '--hyperion-url', '-y', default=f'https://{DEFAULT_DOMAIN}') @click.option( - '--node-url', '-n', default='https://skynet.ancap.tech') + '--node-url', '-n', default=f'https://{DEFAULT_DOMAIN}') @click.option( '--ipfs-url', '-i', default=DEFAULT_IPFS_REMOTE) @click.option( @@ -404,28 +401,12 @@ def telegram( asyncio.run(_async_main()) -class IPFSHTTP: - - def __init__(self, endpoint: str): - self.endpoint = endpoint - - def pin(self, cid: str): - return requests.post( - f'{self.endpoint}/api/v0/pin/add', - params={'arg': cid} - ) - - async def a_pin(self, cid: str): - return await asks.post( - f'{self.endpoint}/api/v0/pin/add', - params={'arg': cid} - ) - - @run.command() @click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option('--name', '-n', default='skynet-ipfs', help='container name') def ipfs(loglevel, name): + from skynet.ipfs.docker import open_ipfs_node + logging.basicConfig(level=loglevel) with open_ipfs_node(name=name): ... @@ -437,90 +418,12 @@ def ipfs(loglevel, name): @click.option( '--hyperion-url', '-y', default='http://127.0.0.1:42001') def pinner(loglevel, ipfs_rpc, hyperion_url): + from .ipfs.pinner import SkynetPinner + logging.basicConfig(level=loglevel) ipfs_node = IPFSHTTP(ipfs_rpc) hyperion = HyperionAPI(hyperion_url) - pinned = set() - async def _async_main(): + pinner = SkynetPinner(hyperion, ipfs_node) - async def capture_enqueues(after: datetime): - enqueues = await hyperion.aget_actions( - account='telos.gpu', - filter='telos.gpu:enqueue', - sort='desc', - after=after.isoformat(), - limit=1000 - ) - - logging.info(f'got {len(enqueues["actions"])} enqueue actions.') - - cids = [] - for action in enqueues['actions']: - cid = action['act']['data']['binary_data'] - if cid and cid not in pinned: - pinned.add(cid) - cids.append(cid) - - return cids - - async def capture_submits(after: datetime): - submits = await hyperion.aget_actions( - account='telos.gpu', - filter='telos.gpu:submit', - sort='desc', - after=after.isoformat(), - limit=1000 - ) - - logging.info(f'got {len(submits["actions"])} submits actions.') - - cids = [] - for action in submits['actions']: - cid = action['act']['data']['ipfs_hash'] - if cid and cid not in pinned: - pinned.add(cid) - cids.append(cid) - - return cids - - async def task_pin(cid: str): - logging.info(f'pinning {cid}...') - for _ in range(6): - try: - with trio.move_on_after(5): - resp = await ipfs_node.a_pin(cid) - if resp.status_code != 200: - logging.error(f'error pinning {cid}:\n{resp.text}') - - else: - logging.info(f'pinned {cid}') - return - - except trio.TooSlowError: - logging.error(f'timed out pinning {cid}') - - logging.error(f'gave up pinning {cid}') - - try: - async with trio.open_nursery() as n: - while True: - now = datetime.now() - prev_second = now - timedelta(seconds=10) - - # filter for the ones not already pinned - cids = [ - *(await capture_enqueues(prev_second)), - *(await capture_submits(prev_second)) - ] - - # pin and remember (in parallel) - for cid in cids: - n.start_soon(task_pin, cid) - - await trio.sleep(1) - - except KeyboardInterrupt: - ... - - trio.run(_async_main) + trio.run(pinner.pin_forever) diff --git a/skynet/constants.py b/skynet/constants.py index a1e13f4..d3e319d 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -144,4 +144,6 @@ CONFIG_ATTRS = [ 'upscaler' ] +DEFAULT_DOMAIN = 'skygpu.net' + DEFAULT_IPFS_REMOTE = '/ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv' diff --git a/skynet/dgpu/network.py b/skynet/dgpu/network.py index 55b8228..e7e0396 100644 --- a/skynet/dgpu/network.py +++ b/skynet/dgpu/network.py @@ -1,5 +1,6 @@ #!/usr/bin/python +from functools import partial import io import json import time @@ -15,7 +16,19 @@ from leap.cleos import CLEOS from leap.sugar import Checksum256, Name, asset_from_str from skynet.dgpu.errors import DGPUComputeError -from skynet.ipfs import get_ipfs_file, open_ipfs_node +from skynet.ipfs import get_ipfs_file +from skynet.ipfs.docker import open_ipfs_node + + +async def failable(fn: partial, ret_fail=None): + try: + return await fn() + + except ( + asks.errors.RequestTimeout, + json.JSONDecodeError + ): + return ret_fail class SkynetGPUConnector: @@ -46,74 +59,88 @@ class SkynetGPUConnector: yield self self.disconnect() + # blockchain helpers async def get_work_requests_last_hour(self): logging.info('get_work_requests_last_hour') - try: - return await self.cleos.aget_table( + return await failable( + partial( + self.cleos.aget_table, 'telos.gpu', 'telos.gpu', 'queue', index_position=2, key_type='i64', lower_bound=int(time.time()) - 3600 - ) - - except ( - asks.errors.RequestTimeout, - json.JSONDecodeError - ): - return [] + ), ret_fail=[]) async def get_status_by_request_id(self, request_id: int): logging.info('get_status_by_request_id') - return await self.cleos.aget_table( - 'telos.gpu', request_id, 'status') + return await failable( + partial( + self.cleos.aget_table, + 'telos.gpu', request_id, 'status'), ret_fail=[]) async def get_global_config(self): logging.info('get_global_config') - return (await self.cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'config'))[0] + rows = await failable( + partial( + self.cleos.aget_table, + 'telos.gpu', 'telos.gpu', 'config')) + + if rows: + return rows[0] + else: + return None async def get_worker_balance(self): logging.info('get_worker_balance') - rows = await self.cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'users', - index_position=1, - key_type='name', - lower_bound=self.account, - upper_bound=self.account - ) - if len(rows) == 1: + rows = await failable( + partial( + self.cleos.aget_table, + 'telos.gpu', 'telos.gpu', 'users', + index_position=1, + key_type='name', + lower_bound=self.account, + upper_bound=self.account + )) + + if rows: return rows[0]['balance'] else: return None async def begin_work(self, request_id: int): logging.info('begin_work') - return await self.cleos.a_push_action( - 'telos.gpu', - 'workbegin', - { - 'worker': self.account, - 'request_id': request_id, - 'max_workers': 2 - }, - self.account, self.key, - permission=self.permission + return await failable( + partial( + self.cleos.a_push_action, + 'telos.gpu', + 'workbegin', + { + 'worker': self.account, + 'request_id': request_id, + 'max_workers': 2 + }, + self.account, self.key, + permission=self.permission + ) ) async def cancel_work(self, request_id: int, reason: str): logging.info('cancel_work') - return await self.cleos.a_push_action( - 'telos.gpu', - 'workcancel', - { - 'worker': self.account, - 'request_id': request_id, - 'reason': reason - }, - self.account, self.key, - permission=self.permission + return await failable( + partial( + self.cleos.a_push_action, + 'telos.gpu', + 'workcancel', + { + 'worker': self.account, + 'request_id': request_id, + 'reason': reason + }, + self.account, self.key, + permission=self.permission + ) ) async def maybe_withdraw_all(self): @@ -124,25 +151,31 @@ class SkynetGPUConnector: balance_amount = float(balance.split(' ')[0]) if balance_amount > 0: - await self.cleos.a_push_action( - 'telos.gpu', - 'withdraw', - { - 'user': self.account, - 'quantity': asset_from_str(balance) - }, - self.account, self.key, - permission=self.permission + await failable( + partial( + self.cleos.a_push_action, + 'telos.gpu', + 'withdraw', + { + 'user': self.account, + 'quantity': asset_from_str(balance) + }, + self.account, self.key, + permission=self.permission + ) ) async def find_my_results(self): logging.info('find_my_results') - return await self.cleos.aget_table( - 'telos.gpu', 'telos.gpu', 'results', - index_position=4, - key_type='name', - lower_bound=self.account, - upper_bound=self.account + return await failable( + partial( + self.cleos.aget_table, + 'telos.gpu', 'telos.gpu', 'results', + index_position=4, + key_type='name', + lower_bound=self.account, + upper_bound=self.account + ) ) async def submit_work( @@ -153,18 +186,21 @@ class SkynetGPUConnector: ipfs_hash: str ): logging.info('submit_work') - await self.cleos.a_push_action( - 'telos.gpu', - 'submit', - { - 'worker': self.account, - 'request_id': request_id, - 'request_hash': Checksum256(request_hash), - 'result_hash': Checksum256(result_hash), - 'ipfs_hash': ipfs_hash - }, - self.account, self.key, - permission=self.permission + return await failable( + partial( + self.cleos.a_push_action, + 'telos.gpu', + 'submit', + { + 'worker': self.account, + 'request_id': request_id, + 'request_hash': Checksum256(request_hash), + 'result_hash': Checksum256(result_hash), + 'ipfs_hash': ipfs_hash + }, + self.account, self.key, + permission=self.permission + ) ) # IPFS helpers diff --git a/skynet/frontend/telegram/__init__.py b/skynet/frontend/telegram/__init__.py index 07a39c5..3fcbeb9 100644 --- a/skynet/frontend/telegram/__init__.py +++ b/skynet/frontend/telegram/__init__.py @@ -13,14 +13,13 @@ from contextlib import asynccontextmanager as acm from leap.cleos import CLEOS from leap.sugar import Name, asset_from_str, collect_stdout from leap.hyperion import HyperionAPI -from telebot.asyncio_helper import ApiTelegramException from telebot.types import InputMediaPhoto -from telebot.types import CallbackQuery from telebot.async_telebot import AsyncTeleBot from skynet.db import open_new_database, open_database_connection -from skynet.ipfs import open_ipfs_node, get_ipfs_file +from skynet.ipfs import get_ipfs_file +from skynet.ipfs.docker import open_ipfs_node from skynet.constants import * from . import * @@ -164,7 +163,7 @@ class SkynetTelegramFrontend: enqueue_tx_id = res['transaction_id'] enqueue_tx_link = hlink( 'Your request on Skynet Explorer', - f'https://skynet.ancap.tech/v2/explore/transaction/{enqueue_tx_id}' + f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{enqueue_tx_id}' ) await self.append_status_message( @@ -221,7 +220,7 @@ class SkynetTelegramFrontend: tx_link = hlink( 'Your result on Skynet Explorer', - f'https://skynet.ancap.tech/v2/explore/transaction/{tx_hash}' + f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}' ) await self.append_status_message( @@ -233,11 +232,11 @@ class SkynetTelegramFrontend: ) # attempt to get the image and send it - ipfs_link = f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' + ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' resp = await get_ipfs_file(ipfs_link) caption = generate_reply_caption( - user, params, ipfs_hash, tx_hash, worker, reward) + user, params, tx_hash, worker, reward) if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') diff --git a/skynet/frontend/telegram/utils.py b/skynet/frontend/telegram/utils.py index cebb6e5..ad08bba 100644 --- a/skynet/frontend/telegram/utils.py +++ b/skynet/frontend/telegram/utils.py @@ -65,32 +65,25 @@ def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> st def generate_reply_caption( tguser, # telegram user params: dict, - ipfs_hash: str, tx_hash: str, worker: str, reward: str ): - ipfs_link = hlink( - 'Get your image on IPFS', - f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png' - ) explorer_link = hlink( 'SKYNET Transaction Explorer', - f'https://skynet.ancap.tech/v2/explore/transaction/{tx_hash}' + f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}' ) meta_info = prepare_metainfo_caption(tguser, worker, reward, params) final_msg = '\n'.join([ 'Worker finished your task!', - ipfs_link, explorer_link, f'PARAMETER INFO:\n{meta_info}' ]) final_msg = '\n'.join([ - f'{ipfs_link}', - f'{explorer_link}', + f'{explorer_link}', f'{meta_info}' ]) diff --git a/skynet/ipfs/__init__.py b/skynet/ipfs/__init__.py new file mode 100644 index 0000000..bb4e0fe --- /dev/null +++ b/skynet/ipfs/__init__.py @@ -0,0 +1,45 @@ +#!/usr/bin/python + +import logging + +import asks +import requests + + +class IPFSHTTP: + + def __init__(self, endpoint: str): + self.endpoint = endpoint + + def pin(self, cid: str): + return requests.post( + f'{self.endpoint}/api/v0/pin/add', + params={'arg': cid} + ) + + async def a_pin(self, cid: str): + return await asks.post( + f'{self.endpoint}/api/v0/pin/add', + params={'arg': cid} + ) + + +async def get_ipfs_file(ipfs_link: str): + logging.info(f'attempting to get image at {ipfs_link}') + resp = None + for i in range(10): + try: + resp = await asks.get(ipfs_link, timeout=3) + + except asks.errors.RequestTimeout: + logging.warning('timeout...') + + except asks.errors.BadHttpResponse as e: + logging.error(f'ifps gateway exception: \n{e}') + + if resp: + logging.info(f'status_code: {resp.status_code}') + else: + logging.error(f'timeout') + + return resp diff --git a/skynet/ipfs.py b/skynet/ipfs/docker.py similarity index 70% rename from skynet/ipfs.py rename to skynet/ipfs/docker.py index 1c2bd87..8158d2e 100644 --- a/skynet/ipfs.py +++ b/skynet/ipfs/docker.py @@ -1,36 +1,18 @@ #!/usr/bin/python import os +import sys import logging from pathlib import Path from contextlib import contextmanager as cm -import asks import docker -from asks.errors import RequestTimeout from docker.types import Mount from docker.models.containers import Container -async def get_ipfs_file(ipfs_link: str): - logging.info(f'attempting to get image at {ipfs_link}') - resp = None - for i in range(10): - try: - resp = await asks.get(ipfs_link, timeout=3) - - except asks.errors.RequestTimeout: - logging.warning('timeout...') - - if resp: - logging.info(f'status_code: {resp.status_code}') - else: - logging.error(f'timeout') - return resp - - class IPFSDocker: def __init__(self, container: Container): @@ -44,7 +26,7 @@ class IPFSDocker: return out.decode().rstrip() def pin(self, ipfs_hash: str): - ec, out = self._container.exec_run( + ec, _ = self._container.exec_run( ['ipfs', 'pin', 'add', ipfs_hash]) assert ec == 0 @@ -90,14 +72,15 @@ def open_ipfs_node(name='skynet-ipfs'): remove=True ) - uid = os.getuid() - gid = os.getgid() - ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', export_target]) - logging.info(out) - assert ec == 0 - ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', data_target]) - logging.info(out) - assert ec == 0 + if sys.platform != 'win32': + uid = os.getuid() + gid = os.getgid() + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', export_target]) + logging.info(out) + assert ec == 0 + ec, out = container.exec_run(['chown', f'{uid}:{gid}', '-R', data_target]) + logging.info(out) + assert ec == 0 for log in container.logs(stream=True): log = log.decode().rstrip() @@ -106,4 +89,3 @@ def open_ipfs_node(name='skynet-ipfs'): break yield IPFSDocker(container) - diff --git a/skynet/ipfs/pinner.py b/skynet/ipfs/pinner.py new file mode 100644 index 0000000..ab443bf --- /dev/null +++ b/skynet/ipfs/pinner.py @@ -0,0 +1,127 @@ +#!/usr/bin/python + +import logging +import traceback + +from datetime import datetime, timedelta + +import trio + +from leap.hyperion import HyperionAPI + +from . import IPFSHTTP + + +MAX_TIME = timedelta(seconds=20) + + +class SkynetPinner: + + def __init__( + self, + hyperion: HyperionAPI, + ipfs_http: IPFSHTTP + ): + self.hyperion = hyperion + self.ipfs_http = ipfs_http + + self._pinned = {} + self._now = datetime.now() + + def is_pinned(self, cid: str): + pin_time = self._pinned.get(cid) + return pin_time + + def pin_cids(self, cids: list[str]): + for cid in cids: + self._pinned[cid] = self._now + + def cleanup_old_cids(self): + cids = list(self._pinned.keys()) + for cid in cids: + if (self._now - self._pinned[cid]) > MAX_TIME * 2: + del self._pinned[cid] + + async def capture_enqueues(self, after: datetime): + enqueues = await self.hyperion.aget_actions( + account='telos.gpu', + filter='telos.gpu:enqueue', + sort='desc', + after=after.isoformat(), + limit=1000 + ) + + logging.info(f'got {len(enqueues["actions"])} enqueue actions.') + + cids = [] + for action in enqueues['actions']: + cid = action['act']['data']['binary_data'] + if cid and not self.is_pinned(cid): + cids.append(cid) + + return cids + + async def capture_submits(self, after: datetime): + submits = await self.hyperion.aget_actions( + account='telos.gpu', + filter='telos.gpu:submit', + sort='desc', + after=after.isoformat(), + limit=1000 + ) + + logging.info(f'got {len(submits["actions"])} submits actions.') + + cids = [] + for action in submits['actions']: + cid = action['act']['data']['ipfs_hash'] + if cid and not self.is_pinned(cid): + cids.append(cid) + + return cids + + async def task_pin(self, cid: str): + logging.info(f'pinning {cid}...') + for _ in range(6): + try: + with trio.move_on_after(5): + resp = await self.ipfs_http.a_pin(cid) + if resp.status_code != 200: + logging.error(f'error pinning {cid}:\n{resp.text}') + + else: + logging.info(f'pinned {cid}') + return + + except trio.TooSlowError: + logging.error(f'timed out pinning {cid}') + + logging.error(f'gave up pinning {cid}') + + async def pin_forever(self): + async with trio.open_nursery() as n: + while True: + try: + self._now = datetime.now() + self.cleanup_old_cids() + + prev_second = self._now - MAX_TIME + + cids = [ + *(await self.capture_enqueues(prev_second)), + *(await self.capture_submits(prev_second)) + ] + + self.pin_cids(cids) + + for cid in cids: + n.start_soon(self.task_pin, cid) + + except OSError as e: + traceback.print_exc() + + except KeyboardInterrupt: + break + + await trio.sleep(1) + From 120d97f478087827ef1105aaf2d64b47165ba389 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 10 Jun 2023 09:38:24 -0300 Subject: [PATCH 33/88] Make frontend more resilient and remove from pinned on error to allow retry --- skynet/frontend/telegram/__init__.py | 43 ++++++++++++++++------------ skynet/ipfs/pinner.py | 1 + 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/skynet/frontend/telegram/__init__.py b/skynet/frontend/telegram/__init__.py index 3fcbeb9..33798c5 100644 --- a/skynet/frontend/telegram/__init__.py +++ b/skynet/frontend/telegram/__init__.py @@ -1,5 +1,6 @@ #!/usr/bin/python +from json import JSONDecodeError import random import logging import asyncio @@ -188,25 +189,29 @@ class SkynetTelegramFrontend: tx_hash = None ipfs_hash = None for i in range(60): - submits = await self.hyperion.aget_actions( - account=self.account, - filter='telos.gpu:submit', - sort='desc', - after=request_time - ) - actions = [ - action - for action in submits['actions'] - if action[ - 'act']['data']['request_hash'] == request_hash - ] - if len(actions) > 0: - tx_hash = actions[0]['trx_id'] - data = actions[0]['act']['data'] - ipfs_hash = data['ipfs_hash'] - worker = data['worker'] - logging.info('Found matching submit!') - break + try: + submits = await self.hyperion.aget_actions( + account=self.account, + filter='telos.gpu:submit', + sort='desc', + after=request_time + ) + actions = [ + action + for action in submits['actions'] + if action[ + 'act']['data']['request_hash'] == request_hash + ] + if len(actions) > 0: + tx_hash = actions[0]['trx_id'] + data = actions[0]['act']['data'] + ipfs_hash = data['ipfs_hash'] + worker = data['worker'] + logging.info('Found matching submit!') + break + + except JSONDecodeError: + logging.error(f'network error while getting actions, retry..') await asyncio.sleep(1) diff --git a/skynet/ipfs/pinner.py b/skynet/ipfs/pinner.py index ab443bf..c4aee10 100644 --- a/skynet/ipfs/pinner.py +++ b/skynet/ipfs/pinner.py @@ -88,6 +88,7 @@ class SkynetPinner: resp = await self.ipfs_http.a_pin(cid) if resp.status_code != 200: logging.error(f'error pinning {cid}:\n{resp.text}') + del self._pinned[cid] else: logging.info(f'pinned {cid}') From 1e05357a72addb8f9fcfde40aad6490fd3a2c112 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sun, 11 Jun 2023 20:27:41 -0300 Subject: [PATCH 34/88] Zoltan's review comments --- skynet/cli.py | 6 +++--- skynet/db/functions.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 767b2da..d7e389d 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -213,7 +213,7 @@ def status(node_url: str, request_id: int): @skynet.command() @click.option( - '--account', '-a', default='telegram1') + '--account', '-a', default='telegram') @click.option( '--permission', '-p', default='active') @click.option( @@ -244,7 +244,7 @@ def dequeue( @skynet.command() @click.option( - '--account', '-a', default='telegram1') + '--account', '-a', default='telos.gpu') @click.option( '--permission', '-p', default='active') @click.option( @@ -278,7 +278,7 @@ def config( @skynet.command() @click.option( - '--account', '-a', default='telegram1') + '--account', '-a', default='telegram') @click.option( '--permission', '-p', default='active') @click.option( diff --git a/skynet/db/functions.py b/skynet/db/functions.py index ac75a97..d98b099 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -42,22 +42,22 @@ CREATE TABLE IF NOT EXISTS skynet.user_config( seed NUMERIC, guidance DECIMAL NOT NULL, strength DECIMAL NOT NULL, - upscaler VARCHAR(128) + upscaler VARCHAR(128), + CONSTRAINT fk_config + FOREIGN KEY(id) + REFERENCES skynet.user(id) ); -ALTER TABLE skynet.user_config - ADD FOREIGN KEY(id) - REFERENCES skynet.user(id); CREATE TABLE IF NOT EXISTS skynet.user_requests( id BIGSERIAL NOT NULL, user_id BIGSERIAL NOT NULL, sent TIMESTAMP NOT NULL, status TEXT NOT NULL, - status_msg BIGSERIAL PRIMARY KEY NOT NULL + status_msg BIGSERIAL PRIMARY KEY NOT NULL, + CONSTRAINT fk_user_req + FOREIGN KEY(user_id) + REFERENCES skynet.user(id) ); -ALTER TABLE skynet.user_requests - ADD FOREIGN KEY(user_id) - REFERENCES skynet.user(id); ''' From cbc9a89bb8cb665091d9356db57bfe403dc20c15 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Mon, 12 Jun 2023 09:43:27 -0300 Subject: [PATCH 35/88] Fix old hardcoded domain on img2img input data fetcher --- skynet/dgpu/network.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skynet/dgpu/network.py b/skynet/dgpu/network.py index e7e0396..466dc59 100644 --- a/skynet/dgpu/network.py +++ b/skynet/dgpu/network.py @@ -14,6 +14,7 @@ from contextlib import asynccontextmanager as acm from leap.cleos import CLEOS from leap.sugar import Checksum256, Name, asset_from_str +from skynet.constants import DEFAULT_DOMAIN from skynet.dgpu.errors import DGPUComputeError from skynet.ipfs import get_ipfs_file @@ -220,8 +221,8 @@ class SkynetGPUConnector: if ipfs_hash == '': return b'' - resp = await get_ipfs_file(f'https://ipfs.ancap.tech/ipfs/{ipfs_hash}/image.png') - if resp.status_code != 200: + resp = await get_ipfs_file(f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png') + if not resp: raise DGPUComputeError('Couldn\'t gather input data from ipfs') return resp.raw From 8f24c727623861fc5b7c75c6e8628cc660d6fac1 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Sun, 25 Jun 2023 17:21:25 -0400 Subject: [PATCH 36/88] add ipfs reconnect before publishing if not connected to peers --- skynet/dgpu/daemon.py | 2 +- skynet/dgpu/network.py | 6 ++++++ skynet/ipfs/docker.py | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/skynet/dgpu/daemon.py b/skynet/dgpu/daemon.py index 216a800..bf7c176 100644 --- a/skynet/dgpu/daemon.py +++ b/skynet/dgpu/daemon.py @@ -66,7 +66,7 @@ class SkynetDGPUDaemon: resp = await self.conn.begin_work(rid) if 'code' in resp: - logging.info(f'probably beign worked on already... skip.') + logging.info(f'probably being worked on already... skip.') else: try: diff --git a/skynet/dgpu/network.py b/skynet/dgpu/network.py index 466dc59..dc6362f 100644 --- a/skynet/dgpu/network.py +++ b/skynet/dgpu/network.py @@ -211,6 +211,12 @@ class SkynetGPUConnector: img = Image.open(io.BytesIO(raw_img)) img.save(f'ipfs-docker-staging/image.png') + # check for connections to peers, reconnect if none + peers = self.ipfs_node.check_connect() + if peers == "": + self.ipfs_node.connect( + '/ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv') + ipfs_hash = self.ipfs_node.add('image.png') self.ipfs_node.pin(ipfs_hash) diff --git a/skynet/ipfs/docker.py b/skynet/ipfs/docker.py index 8158d2e..f13a596 100644 --- a/skynet/ipfs/docker.py +++ b/skynet/ipfs/docker.py @@ -38,6 +38,15 @@ class IPFSDocker: assert ec == 0 + def check_connect(self): + ec, out = self._container.exec_run( + ['ipfs', 'swarm', 'peers']) + if ec != 0: + logging.error(out) + assert ec == 0 + + return out + @cm def open_ipfs_node(name='skynet-ipfs'): From acd8ba91e50cd2237d11232f7172f1243086d72e Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 09:59:32 -0400 Subject: [PATCH 37/88] change ipfs reconnect logic, use config vars --- skynet/dgpu/network.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/skynet/dgpu/network.py b/skynet/dgpu/network.py index dc6362f..976f862 100644 --- a/skynet/dgpu/network.py +++ b/skynet/dgpu/network.py @@ -212,10 +212,9 @@ class SkynetGPUConnector: img.save(f'ipfs-docker-staging/image.png') # check for connections to peers, reconnect if none - peers = self.ipfs_node.check_connect() - if peers == "": - self.ipfs_node.connect( - '/ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv') + peers = self.ipfs_node.check_connect().splitlines() + if self.ipfs_url not in peers: + self.ipfs_node.connect(self.ipfs_url) ipfs_hash = self.ipfs_node.add('image.png') From f8c744e6b47e4596f51df703c204694aac2d1a30 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 12:38:26 -0400 Subject: [PATCH 38/88] add error logging to ipfs add func --- skynet/ipfs/docker.py | 2 ++ 1 file changed, 2 insertions(+) mode change 100644 => 100755 skynet/ipfs/docker.py diff --git a/skynet/ipfs/docker.py b/skynet/ipfs/docker.py old mode 100644 new mode 100755 index f13a596..b6cd6d5 --- a/skynet/ipfs/docker.py +++ b/skynet/ipfs/docker.py @@ -21,6 +21,8 @@ class IPFSDocker: def add(self, file: str) -> str: ec, out = self._container.exec_run( ['ipfs', 'add', '-w', f'/export/{file}', '-Q']) + if ec != 0: + logging.error(out) assert ec == 0 return out.decode().rstrip() From 6a6bdaab0dcb0e3cc04e980313ec41e3ce288dbf Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 12:43:00 -0400 Subject: [PATCH 39/88] update check_connect to return list of peers --- skynet/dgpu/network.py | 4 ++-- skynet/ipfs/docker.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skynet/dgpu/network.py b/skynet/dgpu/network.py index 976f862..5309122 100644 --- a/skynet/dgpu/network.py +++ b/skynet/dgpu/network.py @@ -211,8 +211,8 @@ class SkynetGPUConnector: img = Image.open(io.BytesIO(raw_img)) img.save(f'ipfs-docker-staging/image.png') - # check for connections to peers, reconnect if none - peers = self.ipfs_node.check_connect().splitlines() + # check peer connections, reconnect to skynet gateway if not + peers = self.ipfs_node.check_connect() if self.ipfs_url not in peers: self.ipfs_node.connect(self.ipfs_url) diff --git a/skynet/ipfs/docker.py b/skynet/ipfs/docker.py index b6cd6d5..851bb12 100755 --- a/skynet/ipfs/docker.py +++ b/skynet/ipfs/docker.py @@ -47,7 +47,7 @@ class IPFSDocker: logging.error(out) assert ec == 0 - return out + return out.splitlines() @cm From 1cbb1dd7f3e111c31f9bd2d82775ef7898adfcd8 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 18:02:21 -0400 Subject: [PATCH 40/88] add enqueue spam command --- skynet/cli.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index d7e389d..08a5032 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -110,6 +110,7 @@ def download(): @click.option('--step', '-s', default=26) @click.option('--seed', '-S', default=None) @click.option('--upscaler', '-U', default='x4') +@click.option('--jobs', '-j', default=1) def enqueue( account: str, permission: str, @@ -134,12 +135,19 @@ def enqueue( }) binary = '' - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, binary, reward], f'{account}@{permission}' - ) - - print(collect_stdout(out)) - assert ec == 0 + if not kwargs['jobs']: + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [account, req, binary, reward], f'{account}@{permission}' + ) + print(collect_stdout(out)) + assert ec == 0 + else: + for i in kwargs['jobs']: + ec, out = cleos.push_action( + 'telos.gpu', 'enqueue', [account, req, binary, reward], f'{account}@{permission}' + ) + print(collect_stdout(out)) + assert ec == 0 @skynet.command() @click.option('--loglevel', '-l', default='INFO', help='Logging level') From a452065779c7fef9cde7dfecba82ab6985ee19be Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 18:10:55 -0400 Subject: [PATCH 41/88] remove unneeded if block --- skynet/cli.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 08a5032..d637ea2 100644 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -135,19 +135,12 @@ def enqueue( }) binary = '' - if not kwargs['jobs']: + for i in kwargs['jobs']: ec, out = cleos.push_action( 'telos.gpu', 'enqueue', [account, req, binary, reward], f'{account}@{permission}' ) print(collect_stdout(out)) assert ec == 0 - else: - for i in kwargs['jobs']: - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, binary, reward], f'{account}@{permission}' - ) - print(collect_stdout(out)) - assert ec == 0 @skynet.command() @click.option('--loglevel', '-l', default='INFO', help='Logging level') From c8471ff85ba2245879bfd00fd74e2fa1c17ad5fa Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 18:56:44 -0400 Subject: [PATCH 42/88] add min-verification --- skynet/cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) mode change 100644 => 100755 skynet/cli.py diff --git a/skynet/cli.py b/skynet/cli.py old mode 100644 new mode 100755 index d637ea2..7108934 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -111,6 +111,7 @@ def download(): @click.option('--seed', '-S', default=None) @click.option('--upscaler', '-U', default='x4') @click.option('--jobs', '-j', default=1) +@click.option('--min-verification', '-mv', default=1) def enqueue( account: str, permission: str, @@ -135,9 +136,9 @@ def enqueue( }) binary = '' - for i in kwargs['jobs']: + for i in range(kwargs['jobs']): ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, binary, reward], f'{account}@{permission}' + 'telos.gpu', 'enqueue', [account, req, binary, reward, kwargs['min-verification']], f'{account}@{permission}' ) print(collect_stdout(out)) assert ec == 0 From 1b80de6228a7f86b1b93a93ff696c58059738272 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 21:11:24 -0400 Subject: [PATCH 43/88] change push_action to a_push_action in enqueue cli --- skynet/cli.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 7108934..60e54e9 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -13,7 +13,7 @@ import asyncio import requests from leap.cleos import CLEOS -from leap.sugar import collect_stdout +from leap.sugar import collect_stdout, Name from leap.hyperion import HyperionAPI from skynet.ipfs import IPFSHTTP @@ -100,6 +100,7 @@ def download(): '--node-url', '-n', default='https://skynet.ancap.tech') @click.option( '--reward', '-r', default='20.0000 GPU') +@click.option('--jobs', '-j', default=1) @click.option('--algo', '-a', default='midj') @click.option( '--prompt', '-p', default='a red old tractor in a sunny wheat field') @@ -110,14 +111,13 @@ def download(): @click.option('--step', '-s', default=26) @click.option('--seed', '-S', default=None) @click.option('--upscaler', '-U', default='x4') -@click.option('--jobs', '-j', default=1) -@click.option('--min-verification', '-mv', default=1) def enqueue( account: str, permission: str, key: str | None, node_url: str, reward: str, + jobs: int, **kwargs ): key, account, permission = load_account_info( @@ -136,12 +136,25 @@ def enqueue( }) binary = '' - for i in range(kwargs['jobs']): - ec, out = cleos.push_action( - 'telos.gpu', 'enqueue', [account, req, binary, reward, kwargs['min-verification']], f'{account}@{permission}' + for i in range(jobs): + res = trio.run(cleos.a_push_action, + 'telos.gpu', + 'enqueue', + { + 'user': Name(account), + 'request_body': req, + 'binary_data': binary, + 'reward': reward, + 'min_verification': 1 + }, + # [account, req, binary, reward], + # [account, req, binary, reward, kwargs['min_verification']], + account, key, permission, + #f'{account}@{permission}' ) - print(collect_stdout(out)) - assert ec == 0 + print(res) + # print(collect_stdout(out)) + # assert ec == 0 @skynet.command() @click.option('--loglevel', '-l', default='INFO', help='Logging level') From f17f11e5f35665ff8e53dec9548fe9d372f920ad Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 26 Jun 2023 23:24:32 -0400 Subject: [PATCH 44/88] fix enqueue and deposit to use a_push_action --- skynet/cli.py | 40 ++++++++++++++++++++++++++++++---------- skynet/config.py | 4 ++-- 2 files changed, 32 insertions(+), 12 deletions(-) mode change 100644 => 100755 skynet/config.py diff --git a/skynet/cli.py b/skynet/cli.py index 60e54e9..aaf8c38 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -13,7 +13,7 @@ import asyncio import requests from leap.cleos import CLEOS -from leap.sugar import collect_stdout, Name +from leap.sugar import collect_stdout, Name, asset_from_str from leap.hyperion import HyperionAPI from skynet.ipfs import IPFSHTTP @@ -101,7 +101,7 @@ def download(): @click.option( '--reward', '-r', default='20.0000 GPU') @click.option('--jobs', '-j', default=1) -@click.option('--algo', '-a', default='midj') +@click.option('--model', '-m', default='prompthero/openjourney') @click.option( '--prompt', '-p', default='a red old tractor in a sunny wheat field') @click.option('--output', '-o', default='output.png') @@ -121,10 +121,10 @@ def enqueue( **kwargs ): key, account, permission = load_account_info( - 'user', key, account, permission) + 'dgpu', key, account, permission) node_url, _, _ = load_endpoint_info( - 'user', node_url, None, None) + 'dgpu', node_url, None, None) with open_cleos(node_url, key=key) as cleos: if not kwargs['seed']: @@ -144,7 +144,7 @@ def enqueue( 'user': Name(account), 'request_body': req, 'binary_data': binary, - 'reward': reward, + 'reward': asset_from_str(reward), 'min_verification': 1 }, # [account, req, binary, reward], @@ -298,6 +298,12 @@ def config( '--permission', '-p', default='active') @click.option( '--key', '-k', default=None) +@click.option( + '--sender', '-s', default=None) +@click.option( + '--recipient', '-r', default=None) +@click.option( + '--memo', '-m', default="") @click.option( '--node-url', '-n', default='https://skynet.ancap.tech') @click.argument('quantity') @@ -305,19 +311,33 @@ def deposit( account: str, permission: str, key: str | None, + sender: str, + recipient: str, + memo: str, node_url: str, quantity: str ): key, account, permission = load_account_info( - 'user', key, account, permission) + 'dgpu', key, account, permission) node_url, _, _ = load_endpoint_info( - 'user', node_url, None, None) + 'dgpu', node_url, None, None) with open_cleos(node_url, key=key) as cleos: - ec, out = cleos.transfer_token(account, 'telos.gpu', quantity) + res = trio.run(cleos.a_push_action, + 'eosio.token', + 'transfer', + { + 'sender': Name(sender), + 'recipient': Name(recipient), + 'amount': asset_from_str(quantity), + 'memo': memo + }, + # [sender, recipient, quantity, memo], + account, key, permission, + # f"{account}@{permission}", + ) + print(res) - print(collect_stdout(out)) - assert ec == 0 @skynet.group() def run(*args, **kwargs): diff --git a/skynet/config.py b/skynet/config.py old mode 100644 new mode 100755 index d068295..b025feb --- a/skynet/config.py +++ b/skynet/config.py @@ -71,8 +71,8 @@ def load_account_info( if not key and 'key' in sub_config: key = sub_config['key'] - if not account and 'name' in sub_config: - account = sub_config['name'] + if not account and 'account' in sub_config: + account = sub_config['account'] if not permission and 'permission' in sub_config: permission = sub_config['permission'] From d680ea9b720515b67189cbf075cfe2870b7f561b Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 29 Jun 2023 19:37:50 -0400 Subject: [PATCH 45/88] convert all push_action funcs to async a_push_action --- skynet/cli.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index aaf8c38..72ab4da 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -250,12 +250,20 @@ def dequeue( 'user', node_url, None, None) with open_cleos(node_url, key=key) as cleos: - ec, out = cleos.push_action( - 'telos.gpu', 'dequeue', [account, request_id], f'{account}@{permission}' + res = trio.run(cleos.a_push_action, + 'telos.gpu', + 'dequeue', + { + 'user': Name(account), + 'request_id': request_id, + }, + account, key, permission, + # [account, request_id], f'{account}@{permission}' ) + print(res) + # print(collect_stdout(out)) + # assert ec == 0 - print(collect_stdout(out)) - assert ec == 0 @skynet.command() @click.option( @@ -284,12 +292,20 @@ def config( node_url, _, _ = load_endpoint_info( 'user', node_url, None, None) with open_cleos(node_url, key=key) as cleos: - ec, out = cleos.push_action( - 'telos.gpu', 'config', [token_contract, token_symbol], f'{account}@{permission}' + res = trio.run(cleos.a_push_action, + 'telos.gpu', + 'config', + { + 'token_contract': token_contract, + 'token_symbol': token_symbol, + }, + account, key, permission, + # [token_contract, token_symbol], + # f'{account}@{permission}' ) - - print(collect_stdout(out)) - assert ec == 0 + print(res) + # print(collect_stdout(out)) + # assert ec == 0 @skynet.command() @click.option( From d862954377540b5e8c3c3eaf0b3810df723bcc36 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 29 Jun 2023 19:51:33 -0400 Subject: [PATCH 46/88] add binary data option to enqueue --- skynet/cli.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 72ab4da..70998d1 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -111,6 +111,7 @@ def download(): @click.option('--step', '-s', default=26) @click.option('--seed', '-S', default=None) @click.option('--upscaler', '-U', default='x4') +@click.option('--binary_data', '-b', default='') def enqueue( account: str, permission: str, @@ -127,16 +128,25 @@ def enqueue( 'dgpu', node_url, None, None) with open_cleos(node_url, key=key) as cleos: - if not kwargs['seed']: - kwargs['seed'] = random.randint(0, 10e9) - - req = json.dumps({ - 'method': 'diffuse', - 'params': kwargs - }) - binary = '' + # if not kwargs['seed']: + # kwargs['seed'] = random.randint(0, 10e9) + # + # req = json.dumps({ + # 'method': 'diffuse', + # 'params': kwargs + # }) + # binary = '' for i in range(jobs): + if not kwargs['seed']: + kwargs['seed'] = random.randint(0, 10e9) + + req = json.dumps({ + 'method': 'diffuse', + 'params': kwargs + }) + binary = kwargs['binary_data'] + res = trio.run(cleos.a_push_action, 'telos.gpu', 'enqueue', From de59e3aa1d07c70f747c9cb98bcd7f5f6e1af06a Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 3 Jul 2023 09:16:04 -0400 Subject: [PATCH 47/88] change request_id type to int in dequeue --- skynet/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/cli.py b/skynet/cli.py index 70998d1..0afc5de 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -265,7 +265,7 @@ def dequeue( 'dequeue', { 'user': Name(account), - 'request_id': request_id, + 'request_id': int(request_id), }, account, key, permission, # [account, request_id], f'{account}@{permission}' From 619ffe71ccc6446b51cee89b0c26b465efaa93e8 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Tue, 4 Jul 2023 00:29:51 -0400 Subject: [PATCH 48/88] address pr comments, remove commented code --- skynet/cli.py | 91 ++++++++++++++++++--------------------------------- 1 file changed, 32 insertions(+), 59 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 0afc5de..1f7d24c 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -122,49 +122,38 @@ def enqueue( **kwargs ): key, account, permission = load_account_info( - 'dgpu', key, account, permission) + 'user', key, account, permission) node_url, _, _ = load_endpoint_info( - 'dgpu', node_url, None, None) + 'user', node_url, None, None) with open_cleos(node_url, key=key) as cleos: - # if not kwargs['seed']: - # kwargs['seed'] = random.randint(0, 10e9) - # - # req = json.dumps({ - # 'method': 'diffuse', - # 'params': kwargs - # }) - # binary = '' + async def enqueue_n_jobs(): + for i in range(jobs): + if not kwargs['seed']: + kwargs['seed'] = random.randint(0, 10e9) - for i in range(jobs): - if not kwargs['seed']: - kwargs['seed'] = random.randint(0, 10e9) + req = json.dumps({ + 'method': 'diffuse', + 'params': kwargs + }) + binary = kwargs['binary_data'] - req = json.dumps({ - 'method': 'diffuse', - 'params': kwargs - }) - binary = kwargs['binary_data'] + res = await cleos.a_push_action( + 'telos.gpu', + 'enqueue', + { + 'user': Name(account), + 'request_body': req, + 'binary_data': binary, + 'reward': asset_from_str(reward), + 'min_verification': 1 + }, + account, key, permission, + ) + print(res) + trio.run(enqueue_n_jobs) - res = trio.run(cleos.a_push_action, - 'telos.gpu', - 'enqueue', - { - 'user': Name(account), - 'request_body': req, - 'binary_data': binary, - 'reward': asset_from_str(reward), - 'min_verification': 1 - }, - # [account, req, binary, reward], - # [account, req, binary, reward, kwargs['min_verification']], - account, key, permission, - #f'{account}@{permission}' - ) - print(res) - # print(collect_stdout(out)) - # assert ec == 0 @skynet.command() @click.option('--loglevel', '-l', default='INFO', help='Logging level') @@ -268,11 +257,8 @@ def dequeue( 'request_id': int(request_id), }, account, key, permission, - # [account, request_id], f'{account}@{permission}' ) print(res) - # print(collect_stdout(out)) - # assert ec == 0 @skynet.command() @@ -310,12 +296,9 @@ def config( 'token_symbol': token_symbol, }, account, key, permission, - # [token_contract, token_symbol], - # f'{account}@{permission}' ) print(res) - # print(collect_stdout(out)) - # assert ec == 0 + @skynet.command() @click.option( @@ -324,12 +307,6 @@ def config( '--permission', '-p', default='active') @click.option( '--key', '-k', default=None) -@click.option( - '--sender', '-s', default=None) -@click.option( - '--recipient', '-r', default=None) -@click.option( - '--memo', '-m', default="") @click.option( '--node-url', '-n', default='https://skynet.ancap.tech') @click.argument('quantity') @@ -337,30 +314,26 @@ def deposit( account: str, permission: str, key: str | None, - sender: str, - recipient: str, - memo: str, node_url: str, quantity: str ): key, account, permission = load_account_info( - 'dgpu', key, account, permission) + 'user', key, account, permission) node_url, _, _ = load_endpoint_info( - 'dgpu', node_url, None, None) + 'user', node_url, None, None) + with open_cleos(node_url, key=key) as cleos: res = trio.run(cleos.a_push_action, 'eosio.token', 'transfer', { - 'sender': Name(sender), - 'recipient': Name(recipient), + 'sender': Name(account), + 'recipient': Name('telos.gpu'), 'amount': asset_from_str(quantity), - 'memo': memo + 'memo': f'{account} transferred {quantity} to telos.gpu' }, - # [sender, recipient, quantity, memo], account, key, permission, - # f"{account}@{permission}", ) print(res) From 7b0c1f0868040dfc5448e5260afef26faa47ae03 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Mon, 17 Jul 2023 01:18:08 -0400 Subject: [PATCH 49/88] initial discord bot --- requirements.txt | 1 + skynet.ini.example | 11 + skynet/cli.py | 69 +++++- skynet/config.py | 10 +- skynet/frontend/discord/__init__.py | 280 +++++++++++++++++++++ skynet/frontend/discord/bot.py | 73 ++++++ skynet/frontend/discord/handlers.py | 372 ++++++++++++++++++++++++++++ skynet/frontend/discord/utils.py | 106 ++++++++ 8 files changed, 917 insertions(+), 5 deletions(-) create mode 100644 skynet/frontend/discord/__init__.py create mode 100644 skynet/frontend/discord/bot.py create mode 100644 skynet/frontend/discord/handlers.py create mode 100644 skynet/frontend/discord/utils.py diff --git a/requirements.txt b/requirements.txt index a30a623..6a1a32e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,6 @@ docker aiohttp psycopg2-binary pyTelegramBotAPI +discord.py py-leap@git+https://github.com/guilledk/py-leap.git@v0.1a14 diff --git a/skynet.ini.example b/skynet.ini.example index b498dd8..1f9eab0 100644 --- a/skynet.ini.example +++ b/skynet.ini.example @@ -22,3 +22,14 @@ hyperion_url = https://skynet.ancap.tech ipfs_url = /ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv token = XXXXXXXXXX:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +[skynet.discord] +account = discord +permission = active +key = 5Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +node_url = https://skynet.ancap.tech +hyperion_url = https://skynet.ancap.tech +ipfs_url = /ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv + +token = XXXXXXXXXX:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/skynet/cli.py b/skynet/cli.py index 1f7d24c..385b056 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -24,6 +24,7 @@ from .config import * from .nodeos import open_cleos, open_nodeos from .constants import * from .frontend.telegram import SkynetTelegramFrontend +from .frontend.discord import SkynetDiscordFrontend @click.group() @@ -43,7 +44,7 @@ def skynet(*args, **kwargs): @click.option('--seed', '-S', default=None) def txt2img(*args, **kwargs): from . import utils - _, hf_token, _ = init_env_from_config() + _, hf_token, _, _ = init_env_from_config() utils.txt2img(hf_token, **kwargs) @click.command() @@ -58,7 +59,7 @@ def txt2img(*args, **kwargs): @click.option('--seed', '-S', default=None) def img2img(model, prompt, input, output, strength, guidance, steps, seed): from . import utils - _, hf_token, _ = init_env_from_config() + _, hf_token, _, _ = init_env_from_config() utils.img2img( hf_token, model=model, @@ -86,7 +87,7 @@ def upscale(input, output, model): @skynet.command() def download(): from . import utils - _, hf_token, _ = init_env_from_config() + _, hf_token, _, _ = init_env_from_config() utils.download_all_models(hf_token) @skynet.command() @@ -408,7 +409,7 @@ def telegram( ): logging.basicConfig(level=loglevel) - _, _, tg_token = init_env_from_config() + _, _, tg_token, _ = init_env_from_config() key, account, permission = load_account_info( 'telegram', key, account, permission) @@ -435,6 +436,66 @@ def telegram( asyncio.run(_async_main()) +@run.command() +@click.option('--loglevel', '-l', default='INFO', help='logging level') +@click.option( + '--account', '-a', default='discord') +@click.option( + '--permission', '-p', default='active') +@click.option( + '--key', '-k', default=None) +@click.option( + '--hyperion-url', '-y', default=f'https://{DEFAULT_DOMAIN}') +@click.option( + '--node-url', '-n', default=f'https://{DEFAULT_DOMAIN}') +@click.option( + '--ipfs-url', '-i', default=DEFAULT_IPFS_REMOTE) +@click.option( + '--db-host', '-h', default='localhost:5432') +@click.option( + '--db-user', '-u', default='skynet') +@click.option( + '--db-pass', '-u', default='password') +def discord( + loglevel: str, + account: str, + permission: str, + key: str | None, + hyperion_url: str, + ipfs_url: str, + node_url: str, + db_host: str, + db_user: str, + db_pass: str +): + logging.basicConfig(level=loglevel) + + _, _, _, dc_token = init_env_from_config() + + key, account, permission = load_account_info( + 'discord', key, account, permission) + + node_url, _, ipfs_url = load_endpoint_info( + 'discord', node_url, None, None) + + async def _async_main(): + frontend = SkynetDiscordFrontend( + # dc_token, + account, + permission, + node_url, + hyperion_url, + db_host, db_user, db_pass, + remote_ipfs_node=ipfs_url, + key=key + ) + + async with frontend.open(): + await frontend.bot.start(dc_token) + + asyncio.run(_async_main()) + + @run.command() @click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option('--name', '-n', default='skynet-ipfs', help='container name') diff --git a/skynet/config.py b/skynet/config.py index b025feb..d668a41 100755 --- a/skynet/config.py +++ b/skynet/config.py @@ -23,6 +23,7 @@ def init_env_from_config( hf_token: str | None = None, hf_home: str | None = None, tg_token: str | None = None, + dc_token: str | None = None, file_path=DEFAULT_CONFIG_PATH ): config = load_skynet_ini(file_path=file_path) @@ -52,7 +53,14 @@ def init_env_from_config( if 'token' in sub_config: tg_token = sub_config['token'] - return hf_home, hf_token, tg_token + if 'DC_TOKEN' in os.environ: + dc_token = os.environ['DC_TOKEN'] + elif 'skynet.discord' in config: + sub_config = config['skynet.discord'] + if 'token' in sub_config: + dc_token = sub_config['token'] + + return hf_home, hf_token, tg_token, dc_token def load_account_info( diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py new file mode 100644 index 0000000..a9518b3 --- /dev/null +++ b/skynet/frontend/discord/__init__.py @@ -0,0 +1,280 @@ +#!/usr/bin/python + +from json import JSONDecodeError +import random +import logging +import asyncio + +from decimal import Decimal +from hashlib import sha256 +from datetime import datetime +from contextlib import ExitStack, AsyncExitStack +from contextlib import asynccontextmanager as acm + +from leap.cleos import CLEOS +from leap.sugar import Name, asset_from_str, collect_stdout +from leap.hyperion import HyperionAPI +# from telebot.types import InputMediaPhoto + +# import discord + +from skynet.db import open_new_database, open_database_connection +from skynet.ipfs import get_ipfs_file +from skynet.ipfs.docker import open_ipfs_node +from skynet.constants import * + +from . import * +from .bot import DiscordBot + +from .utils import * +from .handlers import create_handler_context + + +class SkynetDiscordFrontend: + + def __init__( + self, + token: str, + account: str, + permission: str, + node_url: str, + hyperion_url: str, + db_host: str, + db_user: str, + # db_pass: str, + remote_ipfs_node: str, + key: str + ): + self.token = token + self.account = account + self.permission = permission + self.node_url = node_url + self.hyperion_url = hyperion_url + self.db_host = db_host + self.db_user = db_user + # self.db_pass = db_pass + self.remote_ipfs_node = remote_ipfs_node + self.key = key + + self.bot = DiscordBot() + self.cleos = CLEOS(None, None, url=node_url, remote=node_url) + self.hyperion = HyperionAPI(hyperion_url) + + self._exit_stack = ExitStack() + self._async_exit_stack = AsyncExitStack() + + async def start(self): + self.ipfs_node = self._exit_stack.enter_context( + open_ipfs_node()) + + self.ipfs_node.connect(self.remote_ipfs_node) + logging.info( + f'connected to remote ipfs node: {self.remote_ipfs_node}') + + # self.db_call = await self._async_exit_stack.enter_async_context( + # open_database_connection( + # self.db_user, self.db_pass, self.db_host)) + + create_handler_context(self) + + async def stop(self): + await self._async_exit_stack.aclose() + self._exit_stack.close() + + @acm + async def open(self): + await self.start() + yield self + await self.stop() + + # async def update_status_message( + # self, status_msg, new_text: str, **kwargs + # ): + # await self.db_call( + # 'update_user_request_by_sid', status_msg.id, new_text) + # return await self.bot.edit_message_text( + # new_text, + # chat_id=status_msg.chat.id, + # message_id=status_msg.id, + # **kwargs + # ) + + # async def append_status_message( + # self, status_msg, add_text: str, **kwargs + # ): + # request = await self.db_call('get_user_request_by_sid', status_msg.id) + # await self.update_status_message( + # status_msg, + # request['status'] + add_text, + # **kwargs + # ) + + async def work_request( + self, + user, + status_msg, + method: str, + params: dict, + file_id: str | None = None, + binary_data: str = '' + ): + if params['seed'] == None: + params['seed'] = random.randint(0, 0xFFFFFFFF) + + sanitized_params = {} + for key, val in params.items(): + if isinstance(val, Decimal): + val = str(val) + + sanitized_params[key] = val + + body = json.dumps({ + 'method': 'diffuse', + 'params': sanitized_params + }) + request_time = datetime.now().isoformat() + + # await self.update_status_message( + # status_msg, + # f'processing a \'{method}\' request by {tg_user_pretty(user)}\n' + # f'[{timestamp_pretty()}] broadcasting transaction to chain...', + # parse_mode='HTML' + # ) + + reward = '20.0000 GPU' + res = await self.cleos.a_push_action( + 'telos.gpu', + 'enqueue', + { + 'user': Name(self.account), + 'request_body': body, + 'binary_data': binary_data, + 'reward': asset_from_str(reward), + 'min_verification': 1 + }, + self.account, self.key, permission=self.permission + ) + + if 'code' in res or 'statusCode' in res: + logging.error(json.dumps(res, indent=4)) + # await self.update_status_message( + # status_msg, + # 'skynet has suffered an internal error trying to fill this request') + # return + + enqueue_tx_id = res['transaction_id'] + enqueue_tx_link = hlink( + 'Your request on Skynet Explorer', + f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{enqueue_tx_id}' + ) + + # await self.append_status_message( + # status_msg, + # f' broadcasted!\n' + # f'{enqueue_tx_link}\n' + # f'[{timestamp_pretty()}] workers are processing request...', + # parse_mode='HTML' + # ) + + out = collect_stdout(res) + + request_id, nonce = out.split(':') + + request_hash = sha256( + (nonce + body + binary_data).encode('utf-8')).hexdigest().upper() + + request_id = int(request_id) + + logging.info(f'{request_id} enqueued.') + + tx_hash = None + ipfs_hash = None + for i in range(60): + try: + submits = await self.hyperion.aget_actions( + account=self.account, + filter='telos.gpu:submit', + sort='desc', + after=request_time + ) + actions = [ + action + for action in submits['actions'] + if action[ + 'act']['data']['request_hash'] == request_hash + ] + if len(actions) > 0: + tx_hash = actions[0]['trx_id'] + data = actions[0]['act']['data'] + ipfs_hash = data['ipfs_hash'] + worker = data['worker'] + logging.info('Found matching submit!') + break + + except JSONDecodeError: + logging.error(f'network error while getting actions, retry..') + + await asyncio.sleep(1) + + if not ipfs_hash: + # await self.update_status_message( + # status_msg, + # f'\n[{timestamp_pretty()}] timeout processing request', + # parse_mode='HTML' + # ) + return + + tx_link = hlink( + 'Your result on Skynet Explorer', + f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}' + ) + + # await self.append_status_message( + # status_msg, + # f' request processed!\n' + # f'{tx_link}\n' + # f'[{timestamp_pretty()}] trying to download image...\n', + # parse_mode='HTML' + # ) + + # attempt to get the image and send it + ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' + resp = await get_ipfs_file(ipfs_link) + + caption = generate_reply_caption( + user, params, tx_hash, worker, reward) + + if not resp or resp.status_code != 200: + logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') + # await self.update_status_message( + # status_msg, + # caption, + # reply_markup=build_redo_menu(), + # parse_mode='HTML' + # ) + # + else: + logging.info(f'success! sending generated image') + # await self.bot.delete_message( + # chat_id=status_msg.chat.id, message_id=status_msg.id) + # if file_id: # img2img + # await self.bot.send_media_group( + # status_msg.chat.id, + # media=[ + # InputMediaPhoto(file_id), + # InputMediaPhoto( + # resp.raw, + # caption=caption, + # parse_mode='HTML' + # ) + # ], + # ) + # + # else: # txt2img + # await self.bot.send_photo( + # status_msg.chat.id, + # caption=caption, + # photo=resp.raw, + # reply_markup=build_redo_menu(), + # parse_mode='HTML' + # ) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py new file mode 100644 index 0000000..340b89d --- /dev/null +++ b/skynet/frontend/discord/bot.py @@ -0,0 +1,73 @@ +# import os +import discord +# import asyncio +# from dotenv import load_dotenv +# from pathlib import Path +from discord.ext import commands + + +# # Auth +# current_dir = Path(__file__).resolve().parent +# # parent_dir = current_dir.parent +# env_file_path = current_dir / ".env" +# load_dotenv(dotenv_path=env_file_path) +# +# discordToken = os.getenv("DISCORD_TOKEN") + + +# Actual Discord bot. +class DiscordBot(commands.Bot): + + def __init__(self, *args, **kwargs): + intents = discord.Intents( + messages=True, + guilds=True, + typing=True, + members=True, + presences=True, + reactions=True, + message_content=True, + voice_states=True + ) + super().__init__(command_prefix='\\', intents=intents, *args, **kwargs) + + # async def setup_hook(self): + # db.poll_db.start() + + async def on_ready(self): + print(f'{self.user.name} has connected to Discord!') + for guild in self.guilds: + for channel in guild.channels: + if channel.name == "skynet": + await channel.send('Skynet bot online') + + print("\n==============") + print("Logged in as") + print(self.user.name) + print(self.user.id) + print("==============") + + async def on_command_error(self, ctx, error): + if isinstance(error, commands.MissingRequiredArgument): + await ctx.send('You missed a required argument, please try again.') + + # async def on_message(self, message): + # print(f"message from {message.author} what he said {message.content}") + # await message.channel.send(message.content) + +# bot=DiscordBot() +# @bot.command(name='config', help='Responds with the configuration') +# async def config(ctx): +# response = "This is the bot configuration" # Put your bot configuration here +# await ctx.send(response) +# +# @bot.command(name='helper', help='Responds with a help') +# async def helper(ctx): +# response = "This is help information" # Put your help response here +# await ctx.send(response) +# +# @bot.command(name='txt2img', help='Responds with an image') +# async def txt2img(ctx, *, arg): +# response = f"This is your prompt: {arg}" +# await ctx.send(response) +# bot.run(discordToken) diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py new file mode 100644 index 0000000..8ac46a5 --- /dev/null +++ b/skynet/frontend/discord/handlers.py @@ -0,0 +1,372 @@ +#!/usr/bin/python + +import io +import json +import logging + +from datetime import datetime, timedelta + +from PIL import Image +# from telebot.types import CallbackQuery, Message + +from skynet.frontend import validate_user_config_request +from skynet.constants import * + + +def create_handler_context(frontend: 'SkynetDiscordFrontend'): + + bot = frontend.bot + cleos = frontend.cleos + # db_call = frontend.db_call + work_request = frontend.work_request + + ipfs_node = frontend.ipfs_node + + + @bot.command(name='config', help='Responds with the configuration') + async def config(ctx): + response = "This is the bot configuration" # Put your bot configuration here + await ctx.send(response) + + @bot.command(name='helper', help='Responds with a help') + async def helper(ctx): + response = "This is help information" # Put your help response here + await ctx.send(response) + + @bot.command(name='txt2img', help='Responds with an image') + async def send_txt2img(ctx, *, arg): + user = 'tests' + status_msg = 'status' + params = { + 'prompt': prompt, + } + ec = await work_request(user, status_msg, 'txt2img', params) + print(ec) + + # if ec == 0: + # await db_call('increment_generated', user.id) + + response = f"This is your prompt: {arg}" + await ctx.send(response) + + # generic / simple handlers + + # @bot.message_handler(commands=['help']) + # async def send_help(message): + # splt_msg = message.text.split(' ') + # + # if len(splt_msg) == 1: + # await bot.reply_to(message, HELP_TEXT) + # + # else: + # param = splt_msg[1] + # if param in HELP_TOPICS: + # await bot.reply_to(message, HELP_TOPICS[param]) + # + # else: + # await bot.reply_to(message, HELP_UNKWNOWN_PARAM) + # + # @bot.message_handler(commands=['cool']) + # async def send_cool_words(message): + # await bot.reply_to(message, '\n'.join(COOL_WORDS)) + # + # @bot.message_handler(commands=['queue']) + # async def queue(message): + # an_hour_ago = datetime.now() - timedelta(hours=1) + # queue = await cleos.aget_table( + # 'telos.gpu', 'telos.gpu', 'queue', + # index_position=2, + # key_type='i64', + # sort='desc', + # lower_bound=int(an_hour_ago.timestamp()) + # ) + # await bot.reply_to( + # message, f'Total requests on skynet queue: {len(queue)}') + + + # @bot.message_handler(commands=['config']) + # async def set_config(message): + # user = message.from_user.id + # try: + # attr, val, reply_txt = validate_user_config_request( + # message.text) + # + # logging.info(f'user config update: {attr} to {val}') + # await db_call('update_user_config', user, attr, val) + # logging.info('done') + # + # except BaseException as e: + # reply_txt = str(e) + # + # finally: + # await bot.reply_to(message, reply_txt) + # + # @bot.message_handler(commands=['stats']) + # async def user_stats(message): + # user = message.from_user.id + # + # await db_call('get_or_create_user', user) + # generated, joined, role = await db_call('get_user_stats', user) + # + # stats_str = f'generated: {generated}\n' + # stats_str += f'joined: {joined}\n' + # stats_str += f'role: {role}\n' + # + # await bot.reply_to( + # message, stats_str) + # + # @bot.message_handler(commands=['donate']) + # async def donation_info(message): + # await bot.reply_to( + # message, DONATION_INFO) + # + # @bot.message_handler(commands=['say']) + # async def say(message): + # chat = message.chat + # user = message.from_user + # + # if (chat.type == 'group') or (user.id != 383385940): + # return + # + # await bot.send_message(GROUP_ID, message.text[4:]) + + + # generic txt2img handler + + # async def _generic_txt2img(message_or_query): + # if isinstance(message_or_query, CallbackQuery): + # query = message_or_query + # message = query.message + # user = query.from_user + # chat = query.message.chat + # + # else: + # message = message_or_query + # user = message.from_user + # chat = message.chat + # + # reply_id = None + # if chat.type == 'group' and chat.id == GROUP_ID: + # reply_id = message.message_id + # + # user_row = await db_call('get_or_create_user', user.id) + # + # # init new msg + # init_msg = 'started processing txt2img request...' + # status_msg = await bot.reply_to(message, init_msg) + # await db_call( + # 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + # + # prompt = ' '.join(message.text.split(' ')[1:]) + # + # if len(prompt) == 0: + # await bot.edit_message_text( + # 'Empty text prompt ignored.', + # chat_id=status_msg.chat.id, + # message_id=status_msg.id + # ) + # await db_call('update_user_request', status_msg.id, 'Empty text prompt ignored.') + # return + # + # logging.info(f'mid: {message.id}') + # + # user_config = {**user_row} + # del user_config['id'] + # + # params = { + # 'prompt': prompt, + # **user_config + # } + # + # await db_call( + # 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) + # + # ec = await work_request(user, status_msg, 'txt2img', params) + + # if ec == 0: + # await db_call('increment_generated', user.id) + # + # + # # generic img2img handler + # + # async def _generic_img2img(message_or_query): + # if isinstance(message_or_query, CallbackQuery): + # query = message_or_query + # message = query.message + # user = query.from_user + # chat = query.message.chat + # + # else: + # message = message_or_query + # user = message.from_user + # chat = message.chat + # + # reply_id = None + # if chat.type == 'group' and chat.id == GROUP_ID: + # reply_id = message.message_id + # + # user_row = await db_call('get_or_create_user', user.id) + # + # # init new msg + # init_msg = 'started processing txt2img request...' + # status_msg = await bot.reply_to(message, init_msg) + # await db_call( + # 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + # + # if not message.caption.startswith('/img2img'): + # await bot.reply_to( + # message, + # 'For image to image you need to add /img2img to the beggining of your caption' + # ) + # return + # + # prompt = ' '.join(message.caption.split(' ')[1:]) + # + # if len(prompt) == 0: + # await bot.reply_to(message, 'Empty text prompt ignored.') + # return + # + # file_id = message.photo[-1].file_id + # file_path = (await bot.get_file(file_id)).file_path + # image_raw = await bot.download_file(file_path) + + # with Image.open(io.BytesIO(image_raw)) as image: + # w, h = image.size + # + # if w > 512 or h > 512: + # logging.warning(f'user sent img of size {image.size}') + # image.thumbnail((512, 512)) + # logging.warning(f'resized it to {image.size}') + # + # image.save(f'ipfs-docker-staging/image.png', format='PNG') + # + # ipfs_hash = ipfs_node.add('image.png') + # ipfs_node.pin(ipfs_hash) + # + # logging.info(f'published input image {ipfs_hash} on ipfs') + # + # logging.info(f'mid: {message.id}') + # + # user_config = {**user_row} + # del user_config['id'] + # + # params = { + # 'prompt': prompt, + # **user_config + # } + # + # await db_call( + # 'update_user_stats', + # user.id, + # 'img2img', + # last_file=file_id, + # last_prompt=prompt, + # last_binary=ipfs_hash + # ) + # + # ec = await work_request( + # user, status_msg, 'img2img', params, + # file_id=file_id, + # binary_data=ipfs_hash + # ) + # + # if ec == 0: + # await db_call('increment_generated', user.id) + # + + # generic redo handler + + # async def _redo(message_or_query): + # is_query = False + # if isinstance(message_or_query, CallbackQuery): + # is_query = True + # query = message_or_query + # message = query.message + # user = query.from_user + # chat = query.message.chat + # + # elif isinstance(message_or_query, Message): + # message = message_or_query + # user = message.from_user + # chat = message.chat + # + # init_msg = 'started processing redo request...' + # if is_query: + # status_msg = await bot.send_message(chat.id, init_msg) + # + # else: + # status_msg = await bot.reply_to(message, init_msg) + # + # method = await db_call('get_last_method_of', user.id) + # prompt = await db_call('get_last_prompt_of', user.id) + # + # file_id = None + # binary = '' + # if method == 'img2img': + # file_id = await db_call('get_last_file_of', user.id) + # binary = await db_call('get_last_binary_of', user.id) + # + # if not prompt: + # await bot.reply_to( + # message, + # 'no last prompt found, do a txt2img cmd first!' + # ) + # return + # + # + # user_row = await db_call('get_or_create_user', user.id) + # await db_call( + # 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + # user_config = {**user_row} + # del user_config['id'] + # + # params = { + # 'prompt': prompt, + # **user_config + # } + # + # await work_request( + # user, status_msg, 'redo', params, + # file_id=file_id, + # binary_data=binary + # ) + + + # "proxy" handlers just request routers + + # @bot.message_handler(commands=['txt2img']) + # async def send_txt2img(message): + # await _generic_txt2img(message) + # + # @bot.message_handler(func=lambda message: True, content_types=[ + # 'photo', 'document']) + # async def send_img2img(message): + # await _generic_img2img(message) + # + # @bot.message_handler(commands=['img2img']) + # async def img2img_missing_image(message): + # await bot.reply_to( + # message, + # 'seems you tried to do an img2img command without sending image' + # ) + # + # @bot.message_handler(commands=['redo']) + # async def redo(message): + # await _redo(message) + # + # @bot.callback_query_handler(func=lambda call: True) + # async def callback_query(call): + # msg = json.loads(call.data) + # logging.info(call.data) + # method = msg.get('method') + # match method: + # case 'redo': + # await _redo(call) + + + # catch all handler for things we dont support + + # @bot.message_handler(func=lambda message: True) + # async def echo_message(message): + # if message.text[0] == '/': + # await bot.reply_to(message, UNKNOWN_CMD_TEXT) diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py new file mode 100644 index 0000000..ad08bba --- /dev/null +++ b/skynet/frontend/discord/utils.py @@ -0,0 +1,106 @@ +#!/usr/bin/python + +import json +import logging +import traceback + +from datetime import datetime, timezone + +from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup +from telebot.async_telebot import ExceptionHandler +from telebot.formatting import hlink + +from skynet.constants import * + + +def timestamp_pretty(): + return datetime.now(timezone.utc).strftime('%H:%M:%S') + + +def tg_user_pretty(tguser): + if tguser.username: + return f'@{tguser.username}' + else: + return f'{tguser.first_name} id: {tguser.id}' + + +class SKYExceptionHandler(ExceptionHandler): + + def handle(exception): + traceback.print_exc() + + +def build_redo_menu(): + btn_redo = InlineKeyboardButton("Redo", callback_data=json.dumps({'method': 'redo'})) + inline_keyboard = InlineKeyboardMarkup() + inline_keyboard.add(btn_redo) + return inline_keyboard + + +def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> str: + prompt = meta["prompt"] + if len(prompt) > 256: + prompt = prompt[:256] + + + meta_str = f'by {tg_user_pretty(tguser)}\n' + meta_str += f'performed by {worker}\n' + meta_str += f'reward: {reward}\n' + + meta_str += f'prompt: {prompt}\n' + meta_str += f'seed: {meta["seed"]}\n' + meta_str += f'step: {meta["step"]}\n' + meta_str += f'guidance: {meta["guidance"]}\n' + if meta['strength']: + meta_str += f'strength: {meta["strength"]}\n' + meta_str += f'algo: {meta["model"]}\n' + if meta['upscaler']: + meta_str += f'upscaler: {meta["upscaler"]}\n' + + meta_str += f'Made with Skynet v{VERSION}\n' + meta_str += f'JOIN THE SWARM: @skynetgpu' + return meta_str + + +def generate_reply_caption( + tguser, # telegram user + params: dict, + tx_hash: str, + worker: str, + reward: str +): + explorer_link = hlink( + 'SKYNET Transaction Explorer', + f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}' + ) + + meta_info = prepare_metainfo_caption(tguser, worker, reward, params) + + final_msg = '\n'.join([ + 'Worker finished your task!', + explorer_link, + f'PARAMETER INFO:\n{meta_info}' + ]) + + final_msg = '\n'.join([ + f'{explorer_link}', + f'{meta_info}' + ]) + + logging.info(final_msg) + + return final_msg + + +async def get_global_config(cleos): + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'config'))[0] + +async def get_user_nonce(cleos, user: str): + return (await cleos.aget_table( + 'telos.gpu', 'telos.gpu', 'users', + index_position=1, + key_type='name', + lower_bound=user, + upper_bound=user + ))[0]['nonce'] From fadb4eab6d9e3b3e7d5d49193dcf67f1a1b65c15 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Tue, 18 Jul 2023 12:40:21 -0400 Subject: [PATCH 50/88] update params for work queue --- skynet/frontend/discord/__init__.py | 10 ++++++---- skynet/frontend/discord/handlers.py | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index a9518b3..42afd17 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -134,6 +134,7 @@ class SkynetDiscordFrontend: }) request_time = datetime.now().isoformat() + # import pdb; pdb.set_trace() # await self.update_status_message( # status_msg, # f'processing a \'{method}\' request by {tg_user_pretty(user)}\n' @@ -154,13 +155,14 @@ class SkynetDiscordFrontend: }, self.account, self.key, permission=self.permission ) + print(res) if 'code' in res or 'statusCode' in res: logging.error(json.dumps(res, indent=4)) - # await self.update_status_message( - # status_msg, - # 'skynet has suffered an internal error trying to fill this request') - # return + await self.bot.send( + status_msg, + 'skynet has suffered an internal error trying to fill this request') + return enqueue_tx_id = res['transaction_id'] enqueue_tx_link = hlink( diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index 8ac46a5..c408840 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -38,8 +38,12 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): user = 'tests' status_msg = 'status' params = { - 'prompt': prompt, + 'prompt': arg, + 'seed': None, + 'step': 35, + 'guidance': 1, } + # import pdb; pdb.set_trace() ec = await work_request(user, status_msg, 'txt2img', params) print(ec) From 1d7633d340b3e5a9030fcd2cb6b0732e00ad25a4 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Tue, 18 Jul 2023 23:44:58 -0400 Subject: [PATCH 51/88] get initial discord bot working with hardcoded config and image return --- skynet/cli.py | 4 +- skynet/frontend/discord/__init__.py | 60 ++++++++++++++++++++--------- skynet/frontend/discord/bot.py | 2 +- skynet/frontend/discord/handlers.py | 13 +++++-- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 385b056..34c4562 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -439,7 +439,7 @@ def telegram( @run.command() @click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option( - '--account', '-a', default='discord') + '--account', '-a', default=None) @click.option( '--permission', '-p', default='active') @click.option( @@ -485,7 +485,7 @@ def discord( permission, node_url, hyperion_url, - db_host, db_user, db_pass, + # db_host, db_user, db_pass, remote_ipfs_node=ipfs_url, key=key ) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 42afd17..1c0df56 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -16,7 +16,8 @@ from leap.sugar import Name, asset_from_str, collect_stdout from leap.hyperion import HyperionAPI # from telebot.types import InputMediaPhoto -# import discord +import discord +import io from skynet.db import open_new_database, open_database_connection from skynet.ipfs import get_ipfs_file @@ -34,24 +35,24 @@ class SkynetDiscordFrontend: def __init__( self, - token: str, + # token: str, account: str, permission: str, node_url: str, hyperion_url: str, - db_host: str, - db_user: str, + # db_host: str, + # db_user: str, # db_pass: str, remote_ipfs_node: str, key: str ): - self.token = token + # self.token = token self.account = account self.permission = permission self.node_url = node_url self.hyperion_url = hyperion_url - self.db_host = db_host - self.db_user = db_user + # self.db_host = db_host + # self.db_user = db_user # self.db_pass = db_pass self.remote_ipfs_node = remote_ipfs_node self.key = key @@ -115,6 +116,7 @@ class SkynetDiscordFrontend: status_msg, method: str, params: dict, + ctx: discord.TextChannel, file_id: str | None = None, binary_data: str = '' ): @@ -134,13 +136,17 @@ class SkynetDiscordFrontend: }) request_time = datetime.now().isoformat() - # import pdb; pdb.set_trace() + # maybe get rid of this # await self.update_status_message( # status_msg, # f'processing a \'{method}\' request by {tg_user_pretty(user)}\n' # f'[{timestamp_pretty()}] broadcasting transaction to chain...', # parse_mode='HTML' # ) + message = await ctx.send( + f'processing a \'{method}\' request by {user}\n \ + [{timestamp_pretty()}] *broadcasting transaction to chain...*' + ) reward = '20.0000 GPU' res = await self.cleos.a_push_action( @@ -178,6 +184,12 @@ class SkynetDiscordFrontend: # parse_mode='HTML' # ) + await message.edit( + f'**broadcasted!**\n \ + **{enqueue_tx_link}**\n \ + [{timestamp_pretty()}] *workers are processing request...*' + ) + out = collect_stdout(res) request_id, nonce = out.split(':') @@ -238,13 +250,18 @@ class SkynetDiscordFrontend: # f'[{timestamp_pretty()}] trying to download image...\n', # parse_mode='HTML' # ) + await message.edit( + f'**request processed!**\n \ + **{tx_link}**\n \ + [{timestamp_pretty()}] *trying to download image...*\n' + ) # attempt to get the image and send it ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' resp = await get_ipfs_file(ipfs_link) - caption = generate_reply_caption( - user, params, tx_hash, worker, reward) + # caption = generate_reply_caption( + # user, params, tx_hash, worker, reward) if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') @@ -257,9 +274,11 @@ class SkynetDiscordFrontend: # else: logging.info(f'success! sending generated image') + image = io.BytesIO(resp.raw) # await self.bot.delete_message( # chat_id=status_msg.chat.id, message_id=status_msg.id) - # if file_id: # img2img + if file_id: # img2img + pass # await self.bot.send_media_group( # status_msg.chat.id, # media=[ @@ -272,11 +291,14 @@ class SkynetDiscordFrontend: # ], # ) # - # else: # txt2img - # await self.bot.send_photo( - # status_msg.chat.id, - # caption=caption, - # photo=resp.raw, - # reply_markup=build_redo_menu(), - # parse_mode='HTML' - # ) + else: # txt2img + # await self.bot.send_photo( + # status_msg.chat.id, + # caption=caption, + # photo=resp.raw, + # reply_markup=build_redo_menu(), + # parse_mode='HTML' + # ) + await ctx.send( + file=discord.File(image, 'image.png') + ) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index 340b89d..d8ecb3b 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -29,7 +29,7 @@ class DiscordBot(commands.Bot): message_content=True, voice_states=True ) - super().__init__(command_prefix='\\', intents=intents, *args, **kwargs) + super().__init__(command_prefix='/', intents=intents, *args, **kwargs) # async def setup_hook(self): # db.poll_db.start() diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index c408840..cbde27e 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -35,16 +35,21 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): @bot.command(name='txt2img', help='Responds with an image') async def send_txt2img(ctx, *, arg): - user = 'tests' + user = 'testworker3' status_msg = 'status' params = { 'prompt': arg, 'seed': None, 'step': 35, - 'guidance': 1, + 'guidance': 7.5, + 'strength': 0.5, + 'width': 512, + 'height': 512, + 'upscaler': None, + 'model': 'prompthero/openjourney', } - # import pdb; pdb.set_trace() - ec = await work_request(user, status_msg, 'txt2img', params) + + ec = await work_request(user, status_msg, 'txt2img', params, ctx) print(ec) # if ec == 0: From 609c741ae9ba047d97a06d92e0c05188568b1457 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 00:58:55 -0400 Subject: [PATCH 52/88] get db, config, help, cool, and txt2img working --- skynet/cli.py | 2 +- skynet/frontend/discord/__init__.py | 47 ++++++------ skynet/frontend/discord/handlers.py | 110 ++++++++++++++++++++++------ 3 files changed, 112 insertions(+), 47 deletions(-) diff --git a/skynet/cli.py b/skynet/cli.py index 34c4562..ce4c351 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -485,7 +485,7 @@ def discord( permission, node_url, hyperion_url, - # db_host, db_user, db_pass, + db_host, db_user, db_pass, remote_ipfs_node=ipfs_url, key=key ) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 1c0df56..445eade 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -40,9 +40,9 @@ class SkynetDiscordFrontend: permission: str, node_url: str, hyperion_url: str, - # db_host: str, - # db_user: str, - # db_pass: str, + db_host: str, + db_user: str, + db_pass: str, remote_ipfs_node: str, key: str ): @@ -51,9 +51,9 @@ class SkynetDiscordFrontend: self.permission = permission self.node_url = node_url self.hyperion_url = hyperion_url - # self.db_host = db_host - # self.db_user = db_user - # self.db_pass = db_pass + self.db_host = db_host + self.db_user = db_user + self.db_pass = db_pass self.remote_ipfs_node = remote_ipfs_node self.key = key @@ -72,9 +72,9 @@ class SkynetDiscordFrontend: logging.info( f'connected to remote ipfs node: {self.remote_ipfs_node}') - # self.db_call = await self._async_exit_stack.enter_async_context( - # open_database_connection( - # self.db_user, self.db_pass, self.db_host)) + self.db_call = await self._async_exit_stack.enter_async_context( + open_database_connection( + self.db_user, self.db_pass, self.db_host)) create_handler_context(self) @@ -143,10 +143,10 @@ class SkynetDiscordFrontend: # f'[{timestamp_pretty()}] broadcasting transaction to chain...', # parse_mode='HTML' # ) - message = await ctx.send( - f'processing a \'{method}\' request by {user}\n \ - [{timestamp_pretty()}] *broadcasting transaction to chain...*' - ) + # message = await ctx.send( + # f'processing a \'{method}\' request by {user}\n \ + # [{timestamp_pretty()}] *broadcasting transaction to chain...*' + # ) reward = '20.0000 GPU' res = await self.cleos.a_push_action( @@ -183,12 +183,11 @@ class SkynetDiscordFrontend: # f'[{timestamp_pretty()}] workers are processing request...', # parse_mode='HTML' # ) - - await message.edit( - f'**broadcasted!**\n \ - **{enqueue_tx_link}**\n \ - [{timestamp_pretty()}] *workers are processing request...*' - ) + # await message.edit(content= + # f'**broadcasted!**\n \ + # **{enqueue_tx_link}**\n \ + # [{timestamp_pretty()}] *workers are processing request...*' + # ) out = collect_stdout(res) @@ -250,11 +249,11 @@ class SkynetDiscordFrontend: # f'[{timestamp_pretty()}] trying to download image...\n', # parse_mode='HTML' # ) - await message.edit( - f'**request processed!**\n \ - **{tx_link}**\n \ - [{timestamp_pretty()}] *trying to download image...*\n' - ) + # await message.edit(content= + # f'**request processed!**\n \ + # **{tx_link}**\n \ + # [{timestamp_pretty()}] *trying to download image...*\n' + # ) # attempt to get the image and send it ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index cbde27e..a72ab72 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -17,46 +17,112 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): bot = frontend.bot cleos = frontend.cleos - # db_call = frontend.db_call + db_call = frontend.db_call work_request = frontend.work_request ipfs_node = frontend.ipfs_node @bot.command(name='config', help='Responds with the configuration') - async def config(ctx): - response = "This is the bot configuration" # Put your bot configuration here - await ctx.send(response) + async def set_config(ctx): + + user = ctx.author + try: + attr, val, reply_txt = validate_user_config_request( + ctx.message.content) + + logging.info(f'user config update: {attr} to {val}') + await db_call('update_user_config', user.id, attr, val) + logging.info('done') + + except BaseException as e: + reply_txt = str(e) + + finally: + await ctx.reply(content=reply_txt) @bot.command(name='helper', help='Responds with a help') async def helper(ctx): - response = "This is help information" # Put your help response here - await ctx.send(response) + splt_msg = ctx.message.content.split(' ') + + if len(splt_msg) == 1: + await ctx.reply(content=HELP_TEXT) + + else: + param = splt_msg[1] + if param in HELP_TOPICS: + await ctx.reply(content=HELP_TOPICS[param]) + + else: + await ctx.reply(content=HELP_UNKWNOWN_PARAM) + + @bot.command(name='cool', help='Display a list of cool prompt words') + async def send_cool_words(ctx): + await ctx.reply(content='\n'.join(COOL_WORDS)) @bot.command(name='txt2img', help='Responds with an image') - async def send_txt2img(ctx, *, arg): - user = 'testworker3' - status_msg = 'status' + async def send_txt2img(ctx): + + # grab user from ctx + user = ctx.author + user_row = await db_call('get_or_create_user', user.id) + + # init new msg + init_msg = 'started processing txt2img request...' + status_msg = await ctx.reply(init_msg) + await db_call( + 'new_user_request', user.id, ctx.message.id, status_msg.id, status=init_msg) + + prompt = ' '.join(ctx.message.content.split(' ')[1:]) + + if len(prompt) == 0: + await status_msg.edit(content= + 'Empty text prompt ignored.' + ) + await db_call('update_user_request', status_msg.id, 'Empty text prompt ignored.') + return + + logging.info(f'mid: {ctx.message.id}') + + user_config = {**user_row} + del user_config['id'] + params = { - 'prompt': arg, - 'seed': None, - 'step': 35, - 'guidance': 7.5, - 'strength': 0.5, - 'width': 512, - 'height': 512, - 'upscaler': None, - 'model': 'prompthero/openjourney', + 'prompt': prompt, + **user_config } - ec = await work_request(user, status_msg, 'txt2img', params, ctx) - print(ec) + await db_call( + 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) + + ec = await work_request(user.name, status_msg, 'txt2img', params, ctx) + + if ec == 0: + await db_call('increment_generated', user.id) + + # TODO: DELETE BELOW + # user = 'testworker3' + # status_msg = 'status' + # params = { + # 'prompt': arg, + # 'seed': None, + # 'step': 35, + # 'guidance': 7.5, + # 'strength': 0.5, + # 'width': 512, + # 'height': 512, + # 'upscaler': None, + # 'model': 'prompthero/openjourney', + # } + # + # ec = await work_request(user, status_msg, 'txt2img', params, ctx) + # print(ec) # if ec == 0: # await db_call('increment_generated', user.id) - response = f"This is your prompt: {arg}" - await ctx.send(response) + # response = f"This is your prompt: {arg}" + # await ctx.send(response) # generic / simple handlers From 06827a0e7090928484308a000597e42168dec7d0 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 12:28:57 -0400 Subject: [PATCH 53/88] add block on any channel but skynet --- skynet/cli.py | 2 +- skynet/frontend/discord/bot.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/skynet/cli.py b/skynet/cli.py index ce4c351..385b056 100755 --- a/skynet/cli.py +++ b/skynet/cli.py @@ -439,7 +439,7 @@ def telegram( @run.command() @click.option('--loglevel', '-l', default='INFO', help='logging level') @click.option( - '--account', '-a', default=None) + '--account', '-a', default='discord') @click.option( '--permission', '-p', default='active') @click.option( diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index d8ecb3b..c82d924 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -47,6 +47,11 @@ class DiscordBot(commands.Bot): print(self.user.id) print("==============") + async def on_message(self, message): + if message.channel.name != 'skynet': + return + await self.process_commands(message) + async def on_command_error(self, ctx, error): if isinstance(error, commands.MissingRequiredArgument): await ctx.send('You missed a required argument, please try again.') From c7cd1503168f2fccc021c9f96cf66b8f3d50c085 Mon Sep 17 00:00:00 2001 From: zoltan Date: Wed, 19 Jul 2023 17:05:13 +0000 Subject: [PATCH 54/88] remove print of a_push_action res --- skynet/frontend/discord/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 445eade..8216956 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -161,7 +161,7 @@ class SkynetDiscordFrontend: }, self.account, self.key, permission=self.permission ) - print(res) + # print(res) if 'code' in res or 'statusCode' in res: logging.error(json.dumps(res, indent=4)) From ecd7d17bbf83d78bbfe6b4412ac65576658120e0 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 13:23:20 -0400 Subject: [PATCH 55/88] change image gen to a reply --- skynet/frontend/discord/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 8216956..9fee423 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -298,6 +298,6 @@ class SkynetDiscordFrontend: # reply_markup=build_redo_menu(), # parse_mode='HTML' # ) - await ctx.send( + await ctx.reply( file=discord.File(image, 'image.png') ) From d22c0556d4314b1a32375c85578e212861b0a617 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 13:28:08 -0400 Subject: [PATCH 56/88] add clean cool words and remove slut from copy --- skynet/constants.py | 22 ++++++++++++++++++++++ skynet/frontend/discord/handlers.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/skynet/constants.py b/skynet/constants.py index d3e319d..fae7805 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -82,6 +82,28 @@ COOL_WORDS = [ 'michelangelo' ] +CLEAN_COOL_WORDS = [ + 'cyberpunk', + 'soviet propaganda poster', + 'rastafari', + 'cannabis', + 'art deco', + 'H R Giger Necronom IV', + 'dimethyltryptamine', + 'lysergic', + 'psilocybin', + 'trippy', + 'lucy in the sky with diamonds', + 'fractal', + 'da vinci', + 'pencil illustration', + 'blueprint', + 'internal diagram', + 'baroque', + 'the last judgment', + 'michelangelo' +] + HELP_TOPICS = { 'step': ''' Diffusion models are iterative processes – a repeated cycle that starts with a\ diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index a72ab72..2919df8 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -58,7 +58,7 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): @bot.command(name='cool', help='Display a list of cool prompt words') async def send_cool_words(ctx): - await ctx.reply(content='\n'.join(COOL_WORDS)) + await ctx.reply(content='\n'.join(CLEAN_COOL_WORDS)) @bot.command(name='txt2img', help='Responds with an image') async def send_txt2img(ctx): From 239faed71f975fe84c923b5c8d9a51be4b2bff1c Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 13:40:11 -0400 Subject: [PATCH 57/88] add redo command --- skynet/frontend/discord/handlers.py | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index 2919df8..8135d0c 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -100,6 +100,44 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): if ec == 0: await db_call('increment_generated', user.id) + @bot.command(name='redo', help='Redo last request') + async def redo(ctx): + init_msg = 'started processing redo request...' + status_msg = await ctx.reply(init_msg) + user = ctx.author + + method = await db_call('get_last_method_of', user.id) + prompt = await db_call('get_last_prompt_of', user.id) + + file_id = None + binary = '' + if method == 'img2img': + file_id = await db_call('get_last_file_of', user.id) + binary = await db_call('get_last_binary_of', user.id) + + if not prompt: + await ctx.reply( + 'no last prompt found, do a txt2img cmd first!' + ) + return + + user_row = await db_call('get_or_create_user', user.id) + await db_call( + 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await work_request( + user, status_msg, 'redo', params, + file_id=file_id, + binary_data=binary + ) + # TODO: DELETE BELOW # user = 'testworker3' # status_msg = 'status' From 99744bab3e6aa8669a1f4e913b44acf1243ec5a7 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 13:48:06 -0400 Subject: [PATCH 58/88] fix red --- skynet/frontend/discord/handlers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index 8135d0c..6262b28 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -123,7 +123,7 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): user_row = await db_call('get_or_create_user', user.id) await db_call( - 'new_user_request', user.id, message.id, status_msg.id, status=init_msg) + 'new_user_request', user.id, ctx.message.id, status_msg.id, status=init_msg) user_config = {**user_row} del user_config['id'] @@ -133,7 +133,7 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): } await work_request( - user, status_msg, 'redo', params, + user, status_msg, 'redo', params, ctx, file_id=file_id, binary_data=binary ) From 6e8d43e00c9efb99fc3dbe30a90b7611b84136f7 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 15:21:57 -0400 Subject: [PATCH 59/88] add new models --- skynet/constants.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/skynet/constants.py b/skynet/constants.py index fae7805..727cd76 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -5,15 +5,17 @@ VERSION = '0.1a10' DOCKER_RUNTIME_CUDA = 'skynet:runtime-cuda' MODELS = { - 'prompthero/openjourney': { 'short': 'midj'}, - 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, - 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, - 'hakurei/waifu-diffusion': { 'short': 'waifu'}, - 'nitrosocke/Ghibli-Diffusion': { 'short': 'ghibli'}, - 'dallinmackay/Van-Gogh-diffusion': { 'short': 'van-gogh'}, - 'lambdalabs/sd-pokemon-diffusers': { 'short': 'pokemon'}, - 'Envvi/Inkpunk-Diffusion': { 'short': 'ink'}, - 'nousr/robo-diffusion': { 'short': 'robot'} + 'prompthero/openjourney': { 'short': 'midj'}, + 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, + 'stabilityai/stable-diffusion-2-1': { 'short': 'stable2'}, + 'stabilityai/stable-diffusion-xl-base-0.9': { 'short': 'stablexl'}, + 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, + 'hakurei/waifu-diffusion': { 'short': 'waifu'}, + 'nitrosocke/Ghibli-Diffusion': { 'short': 'ghibli'}, + 'dallinmackay/Van-Gogh-diffusion': { 'short': 'van-gogh'}, + 'lambdalabs/sd-pokemon-diffusers': { 'short': 'pokemon'}, + 'Envvi/Inkpunk-Diffusion': { 'short': 'ink'}, + 'nousr/robo-diffusion': { 'short': 'robot'} } SHORT_NAMES = [ From bcb499448e9882c2eb8eae3c45ef89607b390a62 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 15:34:06 -0400 Subject: [PATCH 60/88] add stable2 and stablexl models --- skynet/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/constants.py b/skynet/constants.py index 727cd76..1bae4b1 100644 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -8,7 +8,7 @@ MODELS = { 'prompthero/openjourney': { 'short': 'midj'}, 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, 'stabilityai/stable-diffusion-2-1': { 'short': 'stable2'}, - 'stabilityai/stable-diffusion-xl-base-0.9': { 'short': 'stablexl'}, + 'snowkidy/stable-diffusion-xl-base-0.9': { 'short': 'stablexl'}, 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, 'hakurei/waifu-diffusion': { 'short': 'waifu'}, 'nitrosocke/Ghibli-Diffusion': { 'short': 'ghibli'}, From ae348c7c6fd679cba1475c12e0860a16a888f4b8 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 16:11:48 -0400 Subject: [PATCH 61/88] add param for stablexl --- skynet/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/skynet/utils.py b/skynet/utils.py index 2837118..177f62b 100644 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -77,6 +77,10 @@ def pipeline_for(model: str, mem_fraction: float = 1.0, image=False) -> Diffusio 'safety_checker': None } + if model == 'snowkidy/stable-diffusion-xl-base-0.9': + # TODO: figure out what this does + params['addition_embed_type'] = None + if model == 'runwayml/stable-diffusion-v1-5': params['revision'] = 'fp16' From 08da0681cd317f1a0b415f20fb58fb8096db0ce7 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 16:14:03 -0400 Subject: [PATCH 62/88] add param for stablexl --- skynet/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/utils.py b/skynet/utils.py index 177f62b..ad13a15 100644 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -79,7 +79,7 @@ def pipeline_for(model: str, mem_fraction: float = 1.0, image=False) -> Diffusio if model == 'snowkidy/stable-diffusion-xl-base-0.9': # TODO: figure out what this does - params['addition_embed_type'] = None + params['addition_embed_type'] = { 'text_time': None } if model == 'runwayml/stable-diffusion-v1-5': params['revision'] = 'fp16' From ff0114d3414834ace6896c7117d7b88cc9f7cef3 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Wed, 19 Jul 2023 17:25:28 -0400 Subject: [PATCH 63/88] change stable2 model and how stablexl loads, add reqs --- requirements.cuda.0.txt | 3 ++- skynet/constants.py | 2 +- skynet/utils.py | 6 ++---- 3 files changed, 5 insertions(+), 6 deletions(-) mode change 100644 => 100755 skynet/constants.py mode change 100644 => 100755 skynet/utils.py diff --git a/requirements.cuda.0.txt b/requirements.cuda.0.txt index e31de88..f796537 100644 --- a/requirements.cuda.0.txt +++ b/requirements.cuda.0.txt @@ -3,6 +3,7 @@ triton accelerate transformers huggingface_hub -diffusers[torch] +diffusers[torch]>=0.18.0 +invisible-watermark torch==1.13.0+cu117 --extra-index-url https://download.pytorch.org/whl/cu117 diff --git a/skynet/constants.py b/skynet/constants.py old mode 100644 new mode 100755 index 1bae4b1..2bb6d3b --- a/skynet/constants.py +++ b/skynet/constants.py @@ -7,7 +7,7 @@ DOCKER_RUNTIME_CUDA = 'skynet:runtime-cuda' MODELS = { 'prompthero/openjourney': { 'short': 'midj'}, 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, - 'stabilityai/stable-diffusion-2-1': { 'short': 'stable2'}, + 'stabilityai/stable-diffusion-2-1-base': { 'short': 'stable2'}, 'snowkidy/stable-diffusion-xl-base-0.9': { 'short': 'stablexl'}, 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, 'hakurei/waifu-diffusion': { 'short': 'waifu'}, diff --git a/skynet/utils.py b/skynet/utils.py old mode 100644 new mode 100755 index ad13a15..79932a5 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -77,15 +77,13 @@ def pipeline_for(model: str, mem_fraction: float = 1.0, image=False) -> Diffusio 'safety_checker': None } - if model == 'snowkidy/stable-diffusion-xl-base-0.9': - # TODO: figure out what this does - params['addition_embed_type'] = { 'text_time': None } - if model == 'runwayml/stable-diffusion-v1-5': params['revision'] = 'fp16' if image: pipe_class = StableDiffusionImg2ImgPipeline + elif model == 'snowkidy/stable-diffusion-xl-base-0.9': + pipe_class = DiffusionPipeline else: pipe_class = StableDiffusionPipeline From 8625b5747b915b510bb1c2d4fa05ff62aedae902 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 20 Jul 2023 01:16:22 -0400 Subject: [PATCH 64/88] add initial buttons, help and txt2img --- skynet/frontend/discord/__init__.py | 2 +- skynet/frontend/discord/bot.py | 13 +++- skynet/frontend/discord/handlers.py | 2 +- skynet/frontend/discord/ui.py | 93 +++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 skynet/frontend/discord/ui.py diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 9fee423..f020734 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -57,7 +57,7 @@ class SkynetDiscordFrontend: self.remote_ipfs_node = remote_ipfs_node self.key = key - self.bot = DiscordBot() + self.bot = DiscordBot(self) self.cleos = CLEOS(None, None, url=node_url, remote=node_url) self.hyperion = HyperionAPI(hyperion_url) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index c82d924..97cff8e 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -4,6 +4,7 @@ import discord # from dotenv import load_dotenv # from pathlib import Path from discord.ext import commands +from .ui import SkynetView # # Auth @@ -18,7 +19,8 @@ from discord.ext import commands # Actual Discord bot. class DiscordBot(commands.Bot): - def __init__(self, *args, **kwargs): + def __init__(self, bot, *args, **kwargs): + self.bot = bot intents = discord.Intents( messages=True, guilds=True, @@ -39,7 +41,7 @@ class DiscordBot(commands.Bot): for guild in self.guilds: for channel in guild.channels: if channel.name == "skynet": - await channel.send('Skynet bot online') + await channel.send('Skynet bot online', view=SkynetView(self.bot)) print("\n==============") print("Logged in as") @@ -48,7 +50,12 @@ class DiscordBot(commands.Bot): print("==============") async def on_message(self, message): - if message.channel.name != 'skynet': + if isinstance(message.channel, discord.DMChannel): + return + elif message.channel.name != 'skynet': + return + elif message.author != self.user: + await message.channel.send('', view=SkynetView(self.bot)) return await self.process_commands(message) diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index 6262b28..bc0cf56 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -22,7 +22,6 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): ipfs_node = frontend.ipfs_node - @bot.command(name='config', help='Responds with the configuration') async def set_config(ctx): @@ -138,6 +137,7 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): binary_data=binary ) + # TODO: DELETE BELOW # user = 'testworker3' # status_msg = 'status' diff --git a/skynet/frontend/discord/ui.py b/skynet/frontend/discord/ui.py new file mode 100644 index 0000000..6704bbe --- /dev/null +++ b/skynet/frontend/discord/ui.py @@ -0,0 +1,93 @@ +import discord +import logging +from skynet.constants import * + + +class SkynetView(discord.ui.View): + + def __init__(self, bot): + self.bot = bot + super().__init__(timeout=None) + self.add_item(Txt2ImgButton('Txt2Img', discord.ButtonStyle.green, self.bot)) + self.add_item(HelpButton('Help', discord.ButtonStyle.grey)) + + +class Txt2ImgButton(discord.ui.Button): + + def __init__(self, label:str, style:discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style = style) + + async def callback(self, interaction): + db_call = self.bot.db_call + work_request = self.bot.work_request + msg = await grab('Text Prompt:', interaction) + # grab user from msg + user = msg.author + user_row = await db_call('get_or_create_user', user.id) + + # init new msg + init_msg = 'started processing txt2img request...' + status_msg = await msg.reply(init_msg) + await db_call( + 'new_user_request', user.id, msg.id, status_msg.id, status=init_msg) + + prompt = msg.content + + if len(prompt) == 0: + await status_msg.edit(content= + 'Empty text prompt ignored.' + ) + await db_call('update_user_request', status_msg.id, 'Empty text prompt ignored.') + return + + logging.info(f'mid: {msg.id}') + + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await db_call( + 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) + + ec = await work_request(user.name, status_msg, 'txt2img', params, msg) + + if ec == 0: + await db_call('increment_generated', user.id) + + +class HelpButton(discord.ui.Button): + + def __init__(self, label:str, style:discord.ButtonStyle): + super().__init__(label=label, style = style) + + async def callback(self, interaction): + msg = await grab('What would you like help with? (a for all)', interaction) + + param = msg.content + + if param == 'a': + await msg.reply(content=HELP_TEXT) + + else: + if param in HELP_TOPICS: + await msg.reply(content=HELP_TOPICS[param]) + + else: + await msg.reply(content=HELP_UNKWNOWN_PARAM) + + + +async def grab(prompt, interaction): + def vet(m): + return m.author == interaction.user and m.channel == interaction.channel + + await interaction.response.send_message(prompt, ephemeral=True) + message = await interaction.client.wait_for('message', check=vet) + return message + + From 2b2e82e28f54cd3142cbd029c439f9a3f30adb45 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 20 Jul 2023 01:24:49 -0400 Subject: [PATCH 65/88] fix /command bug --- skynet/frontend/discord/bot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index 97cff8e..e467e5f 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -55,9 +55,8 @@ class DiscordBot(commands.Bot): elif message.channel.name != 'skynet': return elif message.author != self.user: - await message.channel.send('', view=SkynetView(self.bot)) - return - await self.process_commands(message) + await self.process_commands(message) + await message.channel.send('', view=SkynetView(self.bot)) async def on_command_error(self, ctx, error): if isinstance(error, commands.MissingRequiredArgument): From 0eef370d157ebbd0e7fba0445d22b3f3afb35954 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 20 Jul 2023 01:27:21 -0400 Subject: [PATCH 66/88] fix /command bug 2 --- skynet/frontend/discord/bot.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index e467e5f..665e8d5 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -54,8 +54,9 @@ class DiscordBot(commands.Bot): return elif message.channel.name != 'skynet': return - elif message.author != self.user: - await self.process_commands(message) + elif message.author == self.user: + return + await self.process_commands(message) await message.channel.send('', view=SkynetView(self.bot)) async def on_command_error(self, ctx, error): From 2e47ee97f2c53e6975bb31efb22f893d6f801f30 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 20 Jul 2023 01:35:54 -0400 Subject: [PATCH 67/88] add delay for button to reappear --- skynet/frontend/discord/bot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index 665e8d5..cde4144 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -1,6 +1,6 @@ # import os import discord -# import asyncio +import asyncio # from dotenv import load_dotenv # from pathlib import Path from discord.ext import commands @@ -57,6 +57,7 @@ class DiscordBot(commands.Bot): elif message.author == self.user: return await self.process_commands(message) + await asyncio.sleep(3) await message.channel.send('', view=SkynetView(self.bot)) async def on_command_error(self, ctx, error): From 2440fe32db2785700f6c04fc3d244091ef9d57d5 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 20 Jul 2023 17:02:14 -0400 Subject: [PATCH 68/88] update max size params and add discord return card --- skynet/constants.py | 4 +-- skynet/frontend/discord/__init__.py | 21 ++++++++----- skynet/frontend/discord/ui.py | 2 +- skynet/frontend/discord/utils.py | 47 +++++++++++++++-------------- 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/skynet/constants.py b/skynet/constants.py index 2bb6d3b..0ddfa65 100755 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -133,8 +133,8 @@ MP_ENABLED_ROLES = ['god'] MIN_STEP = 1 MAX_STEP = 100 -MAX_WIDTH = 512 -MAX_HEIGHT = 656 +MAX_WIDTH = 1024 +MAX_HEIGHT = 1024 MAX_GUIDANCE = 20 DEFAULT_SEED = None diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index f020734..29809e8 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -116,10 +116,12 @@ class SkynetDiscordFrontend: status_msg, method: str, params: dict, - ctx: discord.TextChannel, + ctx: discord.ext.commands.context.Context | discord.Message, file_id: str | None = None, binary_data: str = '' ): + send = ctx.channel.send + if params['seed'] == None: params['seed'] = random.randint(0, 0xFFFFFFFF) @@ -258,9 +260,9 @@ class SkynetDiscordFrontend: # attempt to get the image and send it ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' resp = await get_ipfs_file(ipfs_link) - - # caption = generate_reply_caption( - # user, params, tx_hash, worker, reward) + + caption, embed = generate_reply_caption( + user, params, tx_hash, worker, reward) if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') @@ -273,7 +275,9 @@ class SkynetDiscordFrontend: # else: logging.info(f'success! sending generated image') - image = io.BytesIO(resp.raw) + # image = io.BytesIO(resp.raw) + # embed.set_image(url=ipfs_link) + # embed.add_field(name='params', value=caption) # await self.bot.delete_message( # chat_id=status_msg.chat.id, message_id=status_msg.id) if file_id: # img2img @@ -298,6 +302,7 @@ class SkynetDiscordFrontend: # reply_markup=build_redo_menu(), # parse_mode='HTML' # ) - await ctx.reply( - file=discord.File(image, 'image.png') - ) + + embed.set_image(url=ipfs_link) + embed.add_field(name='Parameters:', value=caption) + await send(embed=embed) diff --git a/skynet/frontend/discord/ui.py b/skynet/frontend/discord/ui.py index 6704bbe..cb54d5c 100644 --- a/skynet/frontend/discord/ui.py +++ b/skynet/frontend/discord/ui.py @@ -54,7 +54,7 @@ class Txt2ImgButton(discord.ui.Button): await db_call( 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) - ec = await work_request(user.name, status_msg, 'txt2img', params, msg) + ec = await work_request(user, status_msg, 'txt2img', params, msg) if ec == 0: await db_call('increment_generated', user.id) diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py index ad08bba..81724b9 100644 --- a/skynet/frontend/discord/utils.py +++ b/skynet/frontend/discord/utils.py @@ -9,6 +9,7 @@ from datetime import datetime, timezone from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup from telebot.async_telebot import ExceptionHandler from telebot.formatting import hlink +import discord from skynet.constants import * @@ -37,59 +38,59 @@ def build_redo_menu(): return inline_keyboard -def prepare_metainfo_caption(tguser, worker: str, reward: str, meta: dict) -> str: +def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict) -> str: prompt = meta["prompt"] if len(prompt) > 256: prompt = prompt[:256] + meta_str = f'__by {user.name}__\n' + meta_str += f'*performed by {worker}*\n' + meta_str += f'__**reward: {reward}**__\n' - meta_str = f'by {tg_user_pretty(tguser)}\n' - meta_str += f'performed by {worker}\n' - meta_str += f'reward: {reward}\n' - - meta_str += f'prompt: {prompt}\n' - meta_str += f'seed: {meta["seed"]}\n' - meta_str += f'step: {meta["step"]}\n' - meta_str += f'guidance: {meta["guidance"]}\n' + meta_str += f'`prompt:` {prompt}\n' + meta_str += f'`seed: {meta["seed"]}`\n' + meta_str += f'`step: {meta["step"]}`\n' + meta_str += f'`guidance: {meta["guidance"]}`\n' if meta['strength']: - meta_str += f'strength: {meta["strength"]}\n' - meta_str += f'algo: {meta["model"]}\n' + meta_str += f'`strength: {meta["strength"]}`\n' + meta_str += f'`algo: {meta["model"]}`\n' if meta['upscaler']: - meta_str += f'upscaler: {meta["upscaler"]}\n' + meta_str += f'`upscaler: {meta["upscaler"]}`\n' - meta_str += f'Made with Skynet v{VERSION}\n' - meta_str += f'JOIN THE SWARM: @skynetgpu' + meta_str += f'__**Made with Skynet v{VERSION}**__\n' + meta_str += f'**JOIN THE SWARM: @skynetgpu**' return meta_str def generate_reply_caption( - tguser, # telegram user + user, # discord user params: dict, tx_hash: str, worker: str, reward: str ): - explorer_link = hlink( - 'SKYNET Transaction Explorer', - f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}' - ) + explorer_link = discord.Embed( + title='[SKYNET Transaction Explorer]', + url=f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}', + color=discord.Color.blue()) - meta_info = prepare_metainfo_caption(tguser, worker, reward, params) + meta_info = prepare_metainfo_caption(user, worker, reward, params) + # why do we have this? final_msg = '\n'.join([ 'Worker finished your task!', - explorer_link, + # explorer_link, f'PARAMETER INFO:\n{meta_info}' ]) final_msg = '\n'.join([ - f'{explorer_link}', + # f'***{explorer_link}***', f'{meta_info}' ]) logging.info(final_msg) - return final_msg + return final_msg, explorer_link async def get_global_config(cleos): From 53ed74e9a3fd309aa36837801aaaf67258b953ef Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Thu, 20 Jul 2023 20:54:59 -0400 Subject: [PATCH 69/88] add and finalize buttons --- skynet/frontend/discord/__init__.py | 5 +- skynet/frontend/discord/bot.py | 4 +- skynet/frontend/discord/handlers.py | 13 ++-- skynet/frontend/discord/ui.py | 109 +++++++++++++++++++++++++--- 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 29809e8..50878d3 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -29,6 +29,7 @@ from .bot import DiscordBot from .utils import * from .handlers import create_handler_context +from .ui import SkynetView class SkynetDiscordFrontend: @@ -260,7 +261,7 @@ class SkynetDiscordFrontend: # attempt to get the image and send it ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' resp = await get_ipfs_file(ipfs_link) - + caption, embed = generate_reply_caption( user, params, tx_hash, worker, reward) @@ -305,4 +306,4 @@ class SkynetDiscordFrontend: embed.set_image(url=ipfs_link) embed.add_field(name='Parameters:', value=caption) - await send(embed=embed) + await send(embed=embed, view=SkynetView(self)) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index cde4144..ac8744f 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -57,8 +57,8 @@ class DiscordBot(commands.Bot): elif message.author == self.user: return await self.process_commands(message) - await asyncio.sleep(3) - await message.channel.send('', view=SkynetView(self.bot)) + # await asyncio.sleep(3) + # await message.channel.send('', view=SkynetView(self.bot)) async def on_command_error(self, ctx, error): if isinstance(error, commands.MissingRequiredArgument): diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index bc0cf56..dbf19f4 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -11,6 +11,7 @@ from PIL import Image from skynet.frontend import validate_user_config_request from skynet.constants import * +from .ui import SkynetView def create_handler_context(frontend: 'SkynetDiscordFrontend'): @@ -38,26 +39,26 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): reply_txt = str(e) finally: - await ctx.reply(content=reply_txt) + await ctx.reply(content=reply_txt, view=SkynetView(frontend)) @bot.command(name='helper', help='Responds with a help') async def helper(ctx): splt_msg = ctx.message.content.split(' ') if len(splt_msg) == 1: - await ctx.reply(content=HELP_TEXT) + await ctx.reply(content=HELP_TEXT, view=SkynetView(frontend)) else: param = splt_msg[1] if param in HELP_TOPICS: - await ctx.reply(content=HELP_TOPICS[param]) + await ctx.reply(content=HELP_TOPICS[param], view=SkynetView(frontend)) else: - await ctx.reply(content=HELP_UNKWNOWN_PARAM) + await ctx.reply(content=HELP_UNKWNOWN_PARAM, view=SkynetView(frontend)) @bot.command(name='cool', help='Display a list of cool prompt words') async def send_cool_words(ctx): - await ctx.reply(content='\n'.join(CLEAN_COOL_WORDS)) + await ctx.reply(content='\n'.join(CLEAN_COOL_WORDS), view=SkynetView(frontend)) @bot.command(name='txt2img', help='Responds with an image') async def send_txt2img(ctx): @@ -94,7 +95,7 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): await db_call( 'update_user_stats', user.id, 'txt2img', last_prompt=prompt) - ec = await work_request(user.name, status_msg, 'txt2img', params, ctx) + ec = await work_request(user, status_msg, 'txt2img', params, ctx) if ec == 0: await db_call('increment_generated', user.id) diff --git a/skynet/frontend/discord/ui.py b/skynet/frontend/discord/ui.py index cb54d5c..0f9452e 100644 --- a/skynet/frontend/discord/ui.py +++ b/skynet/frontend/discord/ui.py @@ -1,6 +1,7 @@ import discord import logging from skynet.constants import * +from skynet.frontend import validate_user_config_request class SkynetView(discord.ui.View): @@ -8,20 +9,23 @@ class SkynetView(discord.ui.View): def __init__(self, bot): self.bot = bot super().__init__(timeout=None) - self.add_item(Txt2ImgButton('Txt2Img', discord.ButtonStyle.green, self.bot)) - self.add_item(HelpButton('Help', discord.ButtonStyle.grey)) + self.add_item(RedoButton('redo', discord.ButtonStyle.green, self.bot)) + self.add_item(Txt2ImgButton('txt2img', discord.ButtonStyle.green, self.bot)) + self.add_item(ConfigButton('config', discord.ButtonStyle.grey, self.bot)) + self.add_item(HelpButton('help', discord.ButtonStyle.grey, self.bot)) + self.add_item(CoolButton('cool', discord.ButtonStyle.gray, self.bot)) class Txt2ImgButton(discord.ui.Button): - def __init__(self, label:str, style:discord.ButtonStyle, bot): + def __init__(self, label: str, style: discord.ButtonStyle, bot): self.bot = bot - super().__init__(label=label, style = style) + super().__init__(label=label, style=style) async def callback(self, interaction): db_call = self.bot.db_call work_request = self.bot.work_request - msg = await grab('Text Prompt:', interaction) + msg = await grab('Enter your prompt:', interaction) # grab user from msg user = msg.author user_row = await db_call('get_or_create_user', user.id) @@ -60,10 +64,95 @@ class Txt2ImgButton(discord.ui.Button): await db_call('increment_generated', user.id) +class RedoButton(discord.ui.Button): + + def __init__(self, label: str, style: discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style=style) + + async def callback(self, interaction): + db_call = self.bot.db_call + work_request = self.bot.work_request + init_msg = 'started processing redo request...' + await interaction.response.send_message(init_msg) + status_msg = await interaction.original_response() + user = interaction.user + + method = await db_call('get_last_method_of', user.id) + prompt = await db_call('get_last_prompt_of', user.id) + + file_id = None + binary = '' + if method == 'img2img': + file_id = await db_call('get_last_file_of', user.id) + binary = await db_call('get_last_binary_of', user.id) + + if not prompt: + await interaction.response.edit_message( + 'no last prompt found, do a txt2img cmd first!' + ) + return + + user_row = await db_call('get_or_create_user', user.id) + await db_call( + 'new_user_request', user.id, interaction.id, status_msg.id, status=init_msg) + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + await work_request( + user, status_msg, 'redo', params, interaction, + file_id=file_id, + binary_data=binary + ) + + +class ConfigButton(discord.ui.Button): + + def __init__(self, label: str, style: discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style=style) + + async def callback(self, interaction): + db_call = self.bot.db_call + msg = await grab('What params do you want to change? (format: )', interaction) + + user = interaction.user + try: + attr, val, reply_txt = validate_user_config_request( + '/config ' + msg.content) + + logging.info(f'user config update: {attr} to {val}') + await db_call('update_user_config', user.id, attr, val) + logging.info('done') + + except BaseException as e: + reply_txt = str(e) + + finally: + await msg.reply(content=reply_txt, view=SkynetView(self.bot)) + + +class CoolButton(discord.ui.Button): + + def __init__(self, label: str, style: discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style=style) + + async def callback(self, interaction): + await interaction.response.send_message( + content='\n'.join(CLEAN_COOL_WORDS), + view=SkynetView(self.bot)) + + class HelpButton(discord.ui.Button): - def __init__(self, label:str, style:discord.ButtonStyle): - super().__init__(label=label, style = style) + def __init__(self, label: str, style: discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style=style) async def callback(self, interaction): msg = await grab('What would you like help with? (a for all)', interaction) @@ -71,14 +160,14 @@ class HelpButton(discord.ui.Button): param = msg.content if param == 'a': - await msg.reply(content=HELP_TEXT) + await msg.reply(content=HELP_TEXT, view=SkynetView(self.bot)) else: if param in HELP_TOPICS: - await msg.reply(content=HELP_TOPICS[param]) + await msg.reply(content=HELP_TOPICS[param], view=SkynetView(self.bot)) else: - await msg.reply(content=HELP_UNKWNOWN_PARAM) + await msg.reply(content=HELP_UNKWNOWN_PARAM, view=SkynetView(self.bot)) From 58c6a2070e4f49b20b7fd5d212ba1034ca1d2faa Mon Sep 17 00:00:00 2001 From: zoltan Date: Fri, 21 Jul 2023 03:43:59 +0000 Subject: [PATCH 70/88] change send function on failure (not sure if this fixed it) --- skynet/frontend/discord/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 50878d3..656f84f 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -168,7 +168,7 @@ class SkynetDiscordFrontend: if 'code' in res or 'statusCode' in res: logging.error(json.dumps(res, indent=4)) - await self.bot.send( + await self.bot.channel.send( status_msg, 'skynet has suffered an internal error trying to fill this request') return From 426018720882fe6e52922aee2d079d9f5c057503 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 16:57:54 -0400 Subject: [PATCH 71/88] add img2img support, add stats and donate button, finalize UI, add live updates --- skynet/constants.py | 1 + skynet/db/functions.py | 3 +- skynet/frontend/discord/__init__.py | 125 +++++++-------------- skynet/frontend/discord/handlers.py | 139 +++++++++++++++++++++--- skynet/frontend/discord/ui.py | 161 +++++++++++++++++++++++++--- skynet/frontend/discord/utils.py | 42 +++++--- 6 files changed, 344 insertions(+), 127 deletions(-) diff --git a/skynet/constants.py b/skynet/constants.py index 0ddfa65..4dc1c48 100755 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -36,6 +36,7 @@ commands work on a user per user basis! config is individual to each user! /txt2img TEXT - request an image based on a prompt +/img2img TEXT - request an image base on an image and a promtp /redo - redo last command (only works for txt2img for now!) diff --git a/skynet/db/functions.py b/skynet/db/functions.py index d98b099..f52703e 100644 --- a/skynet/db/functions.py +++ b/skynet/db/functions.py @@ -96,7 +96,8 @@ def open_new_database(cleanup=True): 'POSTGRES_PASSWORD': rpassword }, detach=True, - remove=True + # could remove this if we ant the dockers to be persistent. + # remove=True ) try: diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 656f84f..7ab0f17 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -89,6 +89,7 @@ class SkynetDiscordFrontend: yield self await self.stop() + # maybe do this? # async def update_status_message( # self, status_msg, new_text: str, **kwargs # ): @@ -139,17 +140,14 @@ class SkynetDiscordFrontend: }) request_time = datetime.now().isoformat() - # maybe get rid of this - # await self.update_status_message( - # status_msg, - # f'processing a \'{method}\' request by {tg_user_pretty(user)}\n' - # f'[{timestamp_pretty()}] broadcasting transaction to chain...', - # parse_mode='HTML' - # ) - # message = await ctx.send( - # f'processing a \'{method}\' request by {user}\n \ - # [{timestamp_pretty()}] *broadcasting transaction to chain...*' - # ) + await status_msg.delete() + msg_text = f'processing a \'{method}\' request by {user.name}\n[{timestamp_pretty()}] *broadcasting transaction to chain...* ' + embed = discord.Embed( + title='live updates', + description=msg_text, + color=discord.Color.blue()) + + message = await send(embed=embed) reward = '20.0000 GPU' res = await self.cleos.a_push_action( @@ -164,7 +162,6 @@ class SkynetDiscordFrontend: }, self.account, self.key, permission=self.permission ) - # print(res) if 'code' in res or 'statusCode' in res: logging.error(json.dumps(res, indent=4)) @@ -174,23 +171,15 @@ class SkynetDiscordFrontend: return enqueue_tx_id = res['transaction_id'] - enqueue_tx_link = hlink( - 'Your request on Skynet Explorer', - f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{enqueue_tx_id}' - ) + enqueue_tx_link = f'[**Your request on Skynet Explorer**](https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{enqueue_tx_id})' - # await self.append_status_message( - # status_msg, - # f' broadcasted!\n' - # f'{enqueue_tx_link}\n' - # f'[{timestamp_pretty()}] workers are processing request...', - # parse_mode='HTML' - # ) - # await message.edit(content= - # f'**broadcasted!**\n \ - # **{enqueue_tx_link}**\n \ - # [{timestamp_pretty()}] *workers are processing request...*' - # ) + msg_text += f'**broadcasted!** \n{enqueue_tx_link}\n[{timestamp_pretty()}] *workers are processing request...* ' + embed = discord.Embed( + title='live updates', + description=msg_text, + color=discord.Color.blue()) + + await message.edit(embed=embed) out = collect_stdout(res) @@ -233,77 +222,45 @@ class SkynetDiscordFrontend: await asyncio.sleep(1) if not ipfs_hash: - # await self.update_status_message( - # status_msg, - # f'\n[{timestamp_pretty()}] timeout processing request', - # parse_mode='HTML' - # ) + + msg_text += f'\n[{timestamp_pretty()}] **timeout processing request**' + embed = discord.Embed( + title='live updates', + description=msg_text, + color=discord.Color.blue()) + + await message.edit(embed=embed) return - tx_link = hlink( - 'Your result on Skynet Explorer', - f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}' - ) + tx_link = f'[**Your result on Skynet Explorer**](https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash})' - # await self.append_status_message( - # status_msg, - # f' request processed!\n' - # f'{tx_link}\n' - # f'[{timestamp_pretty()}] trying to download image...\n', - # parse_mode='HTML' - # ) - # await message.edit(content= - # f'**request processed!**\n \ - # **{tx_link}**\n \ - # [{timestamp_pretty()}] *trying to download image...*\n' - # ) + msg_text += f'**request processed!**\n{tx_link}\n[{timestamp_pretty()}] *trying to download image...*\n ' + embed = discord.Embed( + title='live updates', + description=msg_text, + color=discord.Color.blue()) + + await message.edit(embed=embed) # attempt to get the image and send it ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' resp = await get_ipfs_file(ipfs_link) + # reword this function, may not need caption caption, embed = generate_reply_caption( user, params, tx_hash, worker, reward) if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') - # await self.update_status_message( - # status_msg, - # caption, - # reply_markup=build_redo_menu(), - # parse_mode='HTML' - # ) - # + await message.edit(embed=embed, view=SkynetView(self)) else: logging.info(f'success! sending generated image') - # image = io.BytesIO(resp.raw) - # embed.set_image(url=ipfs_link) - # embed.add_field(name='params', value=caption) - # await self.bot.delete_message( - # chat_id=status_msg.chat.id, message_id=status_msg.id) + await message.delete() if file_id: # img2img - pass - # await self.bot.send_media_group( - # status_msg.chat.id, - # media=[ - # InputMediaPhoto(file_id), - # InputMediaPhoto( - # resp.raw, - # caption=caption, - # parse_mode='HTML' - # ) - # ], - # ) - # - else: # txt2img - # await self.bot.send_photo( - # status_msg.chat.id, - # caption=caption, - # photo=resp.raw, - # reply_markup=build_redo_menu(), - # parse_mode='HTML' - # ) - + embed.set_thumbnail( + url='https://ipfs.skygpu.net/ipfs/' + binary_data + '/image.png') + embed.set_image(url=ipfs_link) + await send(embed=embed, view=SkynetView(self)) + else: # txt2img embed.set_image(url=ipfs_link) - embed.add_field(name='Parameters:', value=caption) await send(embed=embed, view=SkynetView(self)) diff --git a/skynet/frontend/discord/handlers.py b/skynet/frontend/discord/handlers.py index dbf19f4..c6f2735 100644 --- a/skynet/frontend/discord/handlers.py +++ b/skynet/frontend/discord/handlers.py @@ -41,24 +41,44 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): finally: await ctx.reply(content=reply_txt, view=SkynetView(frontend)) - @bot.command(name='helper', help='Responds with a help') - async def helper(ctx): + bot.remove_command('help') + @bot.command(name='help', help='Responds with a help') + async def help(ctx): splt_msg = ctx.message.content.split(' ') if len(splt_msg) == 1: - await ctx.reply(content=HELP_TEXT, view=SkynetView(frontend)) + await ctx.send(content=f'```{HELP_TEXT}```', view=SkynetView(frontend)) else: param = splt_msg[1] if param in HELP_TOPICS: - await ctx.reply(content=HELP_TOPICS[param], view=SkynetView(frontend)) + await ctx.send(content=f'```{HELP_TOPICS[param]}```', view=SkynetView(frontend)) else: - await ctx.reply(content=HELP_UNKWNOWN_PARAM, view=SkynetView(frontend)) + await ctx.send(content=f'```{HELP_UNKWNOWN_PARAM}```', view=SkynetView(frontend)) @bot.command(name='cool', help='Display a list of cool prompt words') async def send_cool_words(ctx): - await ctx.reply(content='\n'.join(CLEAN_COOL_WORDS), view=SkynetView(frontend)) + clean_cool_word = '\n'.join(CLEAN_COOL_WORDS) + await ctx.send(content=f'```{clean_cool_word}```', view=SkynetView(frontend)) + + @bot.command(name='stats', help='See user statistics' ) + async def user_stats(ctx): + user = ctx.author + + await db_call('get_or_create_user', user.id) + generated, joined, role = await db_call('get_user_stats', user.id) + + stats_str = f'```generated: {generated}\n' + stats_str += f'joined: {joined}\n' + stats_str += f'role: {role}\n```' + + await ctx.reply(stats_str, view=SkynetView(frontend)) + + @bot.command(name='donate', help='See donate info') + async def donation_info(ctx): + await ctx.reply( + f'```\n{DONATION_INFO}```', view=SkynetView(frontend)) @bot.command(name='txt2img', help='Responds with an image') async def send_txt2img(ctx): @@ -69,7 +89,7 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): # init new msg init_msg = 'started processing txt2img request...' - status_msg = await ctx.reply(init_msg) + status_msg = await ctx.send(init_msg) await db_call( 'new_user_request', user.id, ctx.message.id, status_msg.id, status=init_msg) @@ -97,13 +117,13 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): ec = await work_request(user, status_msg, 'txt2img', params, ctx) - if ec == 0: + if ec == None: await db_call('increment_generated', user.id) @bot.command(name='redo', help='Redo last request') async def redo(ctx): init_msg = 'started processing redo request...' - status_msg = await ctx.reply(init_msg) + status_msg = await ctx.send(init_msg) user = ctx.author method = await db_call('get_last_method_of', user.id) @@ -116,8 +136,9 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): binary = await db_call('get_last_binary_of', user.id) if not prompt: - await ctx.reply( - 'no last prompt found, do a txt2img cmd first!' + await status_msg.edit( + content='no last prompt found, do a txt2img cmd first!', + view=SkynetView(frontend) ) return @@ -132,12 +153,106 @@ def create_handler_context(frontend: 'SkynetDiscordFrontend'): **user_config } - await work_request( + ec = await work_request( user, status_msg, 'redo', params, ctx, file_id=file_id, binary_data=binary ) + if ec == None: + await db_call('increment_generated', user.id) + + @bot.command(name='img2img', help='Responds with an image') + async def send_img2img(ctx): + # if isinstance(message_or_query, CallbackQuery): + # query = message_or_query + # message = query.message + # user = query.from_user + # chat = query.message.chat + # + # else: + # message = message_or_query + # user = message.from_user + # chat = message.chat + + # reply_id = None + # if chat.type == 'group' and chat.id == GROUP_ID: + # reply_id = message.message_id + # + user = ctx.author + user_row = await db_call('get_or_create_user', user.id) + + # init new msg + init_msg = 'started processing img2img request...' + status_msg = await ctx.send(init_msg) + await db_call( + 'new_user_request', user.id, ctx.message.id, status_msg.id, status=init_msg) + + if not ctx.message.content.startswith('/img2img'): + await ctx.reply( + 'For image to image you need to add /img2img to the beggining of your caption' + ) + return + + prompt = ' '.join(ctx.message.content.split(' ')[1:]) + + if len(prompt) == 0: + await ctx.reply('Empty text prompt ignored.') + return + + # file_id = message.photo[-1].file_id + # file_path = (await bot.get_file(file_id)).file_path + # image_raw = await bot.download_file(file_path) + # + + file = ctx.message.attachments[-1] + file_id = str(file.id) + # file bytes + image_raw = await file.read() + with Image.open(io.BytesIO(image_raw)) as image: + w, h = image.size + + if w > 512 or h > 512: + logging.warning(f'user sent img of size {image.size}') + image.thumbnail((512, 512)) + logging.warning(f'resized it to {image.size}') + + image.save(f'ipfs-docker-staging/image.png', format='PNG') + + ipfs_hash = ipfs_node.add('image.png') + ipfs_node.pin(ipfs_hash) + + logging.info(f'published input image {ipfs_hash} on ipfs') + + logging.info(f'mid: {ctx.message.id}') + + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await db_call( + 'update_user_stats', + user.id, + 'img2img', + last_file=file_id, + last_prompt=prompt, + last_binary=ipfs_hash + ) + + ec = await work_request( + user, status_msg, 'img2img', params, ctx, + file_id=file_id, + binary_data=ipfs_hash + ) + + if ec == None: + await db_call('increment_generated', user.id) + + # TODO: DELETE BELOW # user = 'testworker3' diff --git a/skynet/frontend/discord/ui.py b/skynet/frontend/discord/ui.py index 0f9452e..95d71b1 100644 --- a/skynet/frontend/discord/ui.py +++ b/skynet/frontend/discord/ui.py @@ -1,4 +1,6 @@ +import io import discord +from PIL import Image import logging from skynet.constants import * from skynet.frontend import validate_user_config_request @@ -9,11 +11,14 @@ class SkynetView(discord.ui.View): def __init__(self, bot): self.bot = bot super().__init__(timeout=None) - self.add_item(RedoButton('redo', discord.ButtonStyle.green, self.bot)) - self.add_item(Txt2ImgButton('txt2img', discord.ButtonStyle.green, self.bot)) - self.add_item(ConfigButton('config', discord.ButtonStyle.grey, self.bot)) - self.add_item(HelpButton('help', discord.ButtonStyle.grey, self.bot)) - self.add_item(CoolButton('cool', discord.ButtonStyle.gray, self.bot)) + self.add_item(RedoButton('redo', discord.ButtonStyle.primary, self.bot)) + self.add_item(Txt2ImgButton('txt2img', discord.ButtonStyle.primary, self.bot)) + self.add_item(Img2ImgButton('img2img', discord.ButtonStyle.primary, self.bot)) + self.add_item(StatsButton('stats', discord.ButtonStyle.secondary, self.bot)) + self.add_item(DonateButton('donate', discord.ButtonStyle.secondary, self.bot)) + self.add_item(ConfigButton('config', discord.ButtonStyle.secondary, self.bot)) + self.add_item(HelpButton('help', discord.ButtonStyle.secondary, self.bot)) + self.add_item(CoolButton('cool', discord.ButtonStyle.secondary, self.bot)) class Txt2ImgButton(discord.ui.Button): @@ -32,7 +37,7 @@ class Txt2ImgButton(discord.ui.Button): # init new msg init_msg = 'started processing txt2img request...' - status_msg = await msg.reply(init_msg) + status_msg = await msg.channel.send(init_msg) await db_call( 'new_user_request', user.id, msg.id, status_msg.id, status=init_msg) @@ -60,7 +65,93 @@ class Txt2ImgButton(discord.ui.Button): ec = await work_request(user, status_msg, 'txt2img', params, msg) - if ec == 0: + if ec == None: + await db_call('increment_generated', user.id) + + +class Img2ImgButton(discord.ui.Button): + + def __init__(self, label: str, style: discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style=style) + + async def callback(self, interaction): + db_call = self.bot.db_call + work_request = self.bot.work_request + ipfs_node = self.bot.ipfs_node + msg = await grab('Attach an Image. Enter your prompt:', interaction) + + user = msg.author + user_row = await db_call('get_or_create_user', user.id) + + # init new msg + init_msg = 'started processing img2img request...' + status_msg = await msg.channel.send(init_msg) + await db_call( + 'new_user_request', user.id, msg.id, status_msg.id, status=init_msg) + + # if not msg.content.startswith('/img2img'): + # await msg.reply( + # 'For image to image you need to add /img2img to the beggining of your caption' + # ) + # return + + prompt = msg.content + + if len(prompt) == 0: + await msg.reply('Empty text prompt ignored.') + return + + # file_id = message.photo[-1].file_id + # file_path = (await bot.get_file(file_id)).file_path + # image_raw = await bot.download_file(file_path) + # + + file = msg.attachments[-1] + file_id = str(file.id) + # file bytes + image_raw = await file.read() + with Image.open(io.BytesIO(image_raw)) as image: + w, h = image.size + + if w > 512 or h > 512: + logging.warning(f'user sent img of size {image.size}') + image.thumbnail((512, 512)) + logging.warning(f'resized it to {image.size}') + + image.save(f'ipfs-docker-staging/image.png', format='PNG') + + ipfs_hash = ipfs_node.add('image.png') + ipfs_node.pin(ipfs_hash) + + logging.info(f'published input image {ipfs_hash} on ipfs') + + logging.info(f'mid: {msg.id}') + + user_config = {**user_row} + del user_config['id'] + + params = { + 'prompt': prompt, + **user_config + } + + await db_call( + 'update_user_stats', + user.id, + 'img2img', + last_file=file_id, + last_prompt=prompt, + last_binary=ipfs_hash + ) + + ec = await work_request( + user, status_msg, 'img2img', params, msg, + file_id=file_id, + binary_data=ipfs_hash + ) + + if ec == None: await db_call('increment_generated', user.id) @@ -88,8 +179,9 @@ class RedoButton(discord.ui.Button): binary = await db_call('get_last_binary_of', user.id) if not prompt: - await interaction.response.edit_message( - 'no last prompt found, do a txt2img cmd first!' + await status_msg.edit( + content='no last prompt found, do a txt2img cmd first!', + view=SkynetView(self.bot) ) return @@ -103,12 +195,15 @@ class RedoButton(discord.ui.Button): 'prompt': prompt, **user_config } - await work_request( + ec = await work_request( user, status_msg, 'redo', params, interaction, file_id=file_id, binary_data=binary ) + if ec == None: + await db_call('increment_generated', user.id) + class ConfigButton(discord.ui.Button): @@ -136,7 +231,29 @@ class ConfigButton(discord.ui.Button): await msg.reply(content=reply_txt, view=SkynetView(self.bot)) -class CoolButton(discord.ui.Button): +class StatsButton(discord.ui.Button): + + def __init__(self, label: str, style: discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style=style) + + async def callback(self, interaction): + db_call = self.bot.db_call + + user = interaction.user + + await db_call('get_or_create_user', user.id) + generated, joined, role = await db_call('get_user_stats', user.id) + + stats_str = f'```generated: {generated}\n' + stats_str += f'joined: {joined}\n' + stats_str += f'role: {role}\n```' + + await interaction.response.send_message( + content=stats_str, view=SkynetView(self.bot)) + + +class DonateButton(discord.ui.Button): def __init__(self, label: str, style: discord.ButtonStyle, bot): self.bot = bot @@ -144,7 +261,20 @@ class CoolButton(discord.ui.Button): async def callback(self, interaction): await interaction.response.send_message( - content='\n'.join(CLEAN_COOL_WORDS), + content=f'```\n{DONATION_INFO}```', + view=SkynetView(self.bot)) + + +class CoolButton(discord.ui.Button): + + def __init__(self, label: str, style: discord.ButtonStyle, bot): + self.bot = bot + super().__init__(label=label, style=style) + + async def callback(self, interaction): + clean_cool_word = '\n'.join(CLEAN_COOL_WORDS) + await interaction.response.send_message( + content=f'```{clean_cool_word}```', view=SkynetView(self.bot)) @@ -160,15 +290,14 @@ class HelpButton(discord.ui.Button): param = msg.content if param == 'a': - await msg.reply(content=HELP_TEXT, view=SkynetView(self.bot)) + await msg.reply(content=f'```{HELP_TEXT}```', view=SkynetView(self.bot)) else: if param in HELP_TOPICS: - await msg.reply(content=HELP_TOPICS[param], view=SkynetView(self.bot)) + await msg.reply(content=f'```{HELP_TOPICS[param]}```', view=SkynetView(self.bot)) else: - await msg.reply(content=HELP_UNKWNOWN_PARAM, view=SkynetView(self.bot)) - + await msg.reply(content=f'```{HELP_UNKWNOWN_PARAM}```', view=SkynetView(self.bot)) async def grab(prompt, interaction): diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py index 81724b9..1fd6618 100644 --- a/skynet/frontend/discord/utils.py +++ b/skynet/frontend/discord/utils.py @@ -38,27 +38,41 @@ def build_redo_menu(): return inline_keyboard -def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict) -> str: +def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict, embed) -> str: prompt = meta["prompt"] if len(prompt) > 256: prompt = prompt[:256] + + gen_str = f'generated by {user.name}\n' + gen_str += f'performed by {worker}\n' + gen_str += f'reward: {reward}\n' - meta_str = f'__by {user.name}__\n' - meta_str += f'*performed by {worker}*\n' - meta_str += f'__**reward: {reward}**__\n' + embed.add_field( + name='General Info', value=f'```{gen_str}```', inline=False) + # meta_str = f'__by {user.name}__\n' + # meta_str += f'*performed by {worker}*\n' + # meta_str += f'__**reward: {reward}**__\n' + embed.add_field(name='Prompt', value=f'```{prompt}\n```', inline=False) + + # meta_str = f'`prompt:` {prompt}\n' + + meta_str = f'seed: {meta["seed"]}\n' + meta_str += f'step: {meta["step"]}\n' + meta_str += f'guidance: {meta["guidance"]}\n' - meta_str += f'`prompt:` {prompt}\n' - meta_str += f'`seed: {meta["seed"]}`\n' - meta_str += f'`step: {meta["step"]}`\n' - meta_str += f'`guidance: {meta["guidance"]}`\n' if meta['strength']: - meta_str += f'`strength: {meta["strength"]}`\n' - meta_str += f'`algo: {meta["model"]}`\n' + meta_str += f'strength: {meta["strength"]}\n' + meta_str += f'algo: {meta["model"]}\n' if meta['upscaler']: - meta_str += f'`upscaler: {meta["upscaler"]}`\n' + meta_str += f'upscaler: {meta["upscaler"]}\n' + + embed.add_field(name='Parameters', value=f'```{meta_str}```', inline=False) + + foot_str = f'Made with Skynet v{VERSION}\n' + foot_str += f'JOIN THE SWARM: @skynetgpu' + + embed.set_footer(text=foot_str) - meta_str += f'__**Made with Skynet v{VERSION}**__\n' - meta_str += f'**JOIN THE SWARM: @skynetgpu**' return meta_str @@ -74,7 +88,7 @@ def generate_reply_caption( url=f'https://explorer.{DEFAULT_DOMAIN}/v2/explore/transaction/{tx_hash}', color=discord.Color.blue()) - meta_info = prepare_metainfo_caption(user, worker, reward, params) + meta_info = prepare_metainfo_caption(user, worker, reward, params, explorer_link) # why do we have this? final_msg = '\n'.join([ From 82ea4d57c407d6c23c0307d4a37e84c495e4302a Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 17:04:41 -0400 Subject: [PATCH 72/88] add error message for ipfs fail --- skynet/frontend/discord/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 7ab0f17..674b2a2 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -252,6 +252,7 @@ class SkynetDiscordFrontend: if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') + embed.add_field(name='Error', value=f'couldn\'t get ipfs hosted image [**here**]({ipfs_link})!') await message.edit(embed=embed, view=SkynetView(self)) else: logging.info(f'success! sending generated image') From 5ca350d5c0b32bef9f6e4f9ac8ee1ec0d7ca52e5 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 17:32:19 -0400 Subject: [PATCH 73/88] fix timeout message --- skynet/frontend/discord/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 674b2a2..7687c3b 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -223,10 +223,10 @@ class SkynetDiscordFrontend: if not ipfs_hash: - msg_text += f'\n[{timestamp_pretty()}] **timeout processing request**' + timeout_text = f'\n[{timestamp_pretty()}] **timeout processing request**' embed = discord.Embed( title='live updates', - description=msg_text, + description=timeout_text, color=discord.Color.blue()) await message.edit(embed=embed) From aa9cde50c798c8c0503dfcb3c13bd0111271baac Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 18:33:16 -0400 Subject: [PATCH 74/88] change the footer text --- skynet/frontend/discord/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py index 1fd6618..6af8c91 100644 --- a/skynet/frontend/discord/utils.py +++ b/skynet/frontend/discord/utils.py @@ -69,7 +69,7 @@ def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict, embed) embed.add_field(name='Parameters', value=f'```{meta_str}```', inline=False) foot_str = f'Made with Skynet v{VERSION}\n' - foot_str += f'JOIN THE SWARM: @skynetgpu' + foot_str += f'JOIN THE SWARM. Join our [**server**](https://discord.gg/JYM4YPMgK) for more info.' embed.set_footer(text=foot_str) From 9ad30ffc101b704f8bceb4ba26f50751243481c4 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 18:38:51 -0400 Subject: [PATCH 75/88] change the footer text again --- skynet/frontend/discord/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py index 6af8c91..e9f7935 100644 --- a/skynet/frontend/discord/utils.py +++ b/skynet/frontend/discord/utils.py @@ -68,8 +68,9 @@ def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict, embed) embed.add_field(name='Parameters', value=f'```{meta_str}```', inline=False) + embed.add_field(value=f'JOIN THE SWARM. Join our [**server**](https://discord.gg/JYM4YPMgK) for more info.', inline=False) + foot_str = f'Made with Skynet v{VERSION}\n' - foot_str += f'JOIN THE SWARM. Join our [**server**](https://discord.gg/JYM4YPMgK) for more info.' embed.set_footer(text=foot_str) From 1e94f4396534160739f1911cee0f1b55927e9bb2 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 18:41:56 -0400 Subject: [PATCH 76/88] change the footer text again, again --- skynet/frontend/discord/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py index e9f7935..931d954 100644 --- a/skynet/frontend/discord/utils.py +++ b/skynet/frontend/discord/utils.py @@ -68,9 +68,8 @@ def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict, embed) embed.add_field(name='Parameters', value=f'```{meta_str}```', inline=False) - embed.add_field(value=f'JOIN THE SWARM. Join our [**server**](https://discord.gg/JYM4YPMgK) for more info.', inline=False) - foot_str = f'Made with Skynet v{VERSION}\n' + foot_str += f'JOIN THE SWARM. For more info, join our server here: https://discord.gg/JYM4YPMgK' embed.set_footer(text=foot_str) From 3315b05888f268a3e5cb101d78c1df961f9e6ac5 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 18:46:07 -0400 Subject: [PATCH 77/88] change the footer text again, again, again --- skynet/frontend/discord/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py index 931d954..c033129 100644 --- a/skynet/frontend/discord/utils.py +++ b/skynet/frontend/discord/utils.py @@ -69,7 +69,7 @@ def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict, embed) embed.add_field(name='Parameters', value=f'```{meta_str}```', inline=False) foot_str = f'Made with Skynet v{VERSION}\n' - foot_str += f'JOIN THE SWARM. For more info, join our server here: https://discord.gg/JYM4YPMgK' + foot_str += f'JOIN THE SWARM.\nFor more info, join our server here: https://discord.gg/JYM4YPMgK' embed.set_footer(text=foot_str) From 70c13c242ad3d08b0be7ab226967d3763243b3d3 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 18:52:15 -0400 Subject: [PATCH 78/88] change the footer text, and increase timeout interval to 2 mins --- skynet/frontend/discord/__init__.py | 2 +- skynet/frontend/discord/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 7687c3b..91f9dcc 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -194,7 +194,7 @@ class SkynetDiscordFrontend: tx_hash = None ipfs_hash = None - for i in range(60): + for i in range(120): try: submits = await self.hyperion.aget_actions( account=self.account, diff --git a/skynet/frontend/discord/utils.py b/skynet/frontend/discord/utils.py index c033129..5e6522d 100644 --- a/skynet/frontend/discord/utils.py +++ b/skynet/frontend/discord/utils.py @@ -69,7 +69,7 @@ def prepare_metainfo_caption(user, worker: str, reward: str, meta: dict, embed) embed.add_field(name='Parameters', value=f'```{meta_str}```', inline=False) foot_str = f'Made with Skynet v{VERSION}\n' - foot_str += f'JOIN THE SWARM.\nFor more info, join our server here: https://discord.gg/JYM4YPMgK' + foot_str += f'JOIN THE SWARM: https://discord.gg/JYM4YPMgK' embed.set_footer(text=foot_str) From 469e90e65027a9102e280bc4261fb3c6d4123c07 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 18:57:28 -0400 Subject: [PATCH 79/88] change back the interval to 60, and double ipfs interval --- skynet/frontend/discord/__init__.py | 2 +- skynet/ipfs/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skynet/frontend/discord/__init__.py b/skynet/frontend/discord/__init__.py index 91f9dcc..7687c3b 100644 --- a/skynet/frontend/discord/__init__.py +++ b/skynet/frontend/discord/__init__.py @@ -194,7 +194,7 @@ class SkynetDiscordFrontend: tx_hash = None ipfs_hash = None - for i in range(120): + for i in range(60): try: submits = await self.hyperion.aget_actions( account=self.account, diff --git a/skynet/ipfs/__init__.py b/skynet/ipfs/__init__.py index bb4e0fe..1a0d4ef 100644 --- a/skynet/ipfs/__init__.py +++ b/skynet/ipfs/__init__.py @@ -27,7 +27,7 @@ class IPFSHTTP: async def get_ipfs_file(ipfs_link: str): logging.info(f'attempting to get image at {ipfs_link}') resp = None - for i in range(10): + for i in range(20): try: resp = await asks.get(ipfs_link, timeout=3) From d74bfb4c59b8038246b5cb344f92546d9eb853ff Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 19:09:11 -0400 Subject: [PATCH 80/88] add intro message --- skynet/frontend/discord/bot.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index ac8744f..afe2220 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -42,6 +42,17 @@ class DiscordBot(commands.Bot): for channel in guild.channels: if channel.name == "skynet": await channel.send('Skynet bot online', view=SkynetView(self.bot)) + intro_msg = await channel.send('Welcome to the Skynet discord bot.\n \ + Skynet is a decentralized compute layer, focused on \ + supporting AI paradigms. Skynet leverages blockchain \ + technology to manage work requests and fills. \ + We are currently featuring image generation and \ + support 11 different models. Get started with the \ + /help command, or just click on some buttons. \ + Here is an example command to generate an image: \ + /txt2img a big red tractor in a giant field of corn') + + await intro_msg.pin() print("\n==============") print("Logged in as") From 26684f7b83677fa99ecbb67a5a0c63d962cfb260 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 19:12:32 -0400 Subject: [PATCH 81/88] add intro message, edit --- skynet/frontend/discord/bot.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index afe2220..9a29f6d 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -42,16 +42,7 @@ class DiscordBot(commands.Bot): for channel in guild.channels: if channel.name == "skynet": await channel.send('Skynet bot online', view=SkynetView(self.bot)) - intro_msg = await channel.send('Welcome to the Skynet discord bot.\n \ - Skynet is a decentralized compute layer, focused on \ - supporting AI paradigms. Skynet leverages blockchain \ - technology to manage work requests and fills. \ - We are currently featuring image generation and \ - support 11 different models. Get started with the \ - /help command, or just click on some buttons. \ - Here is an example command to generate an image: \ - /txt2img a big red tractor in a giant field of corn') - + intro_msg = await channel.send('Welcome to the Skynet discord bot.\nSkynet is a decentralized compute layer, focused on supporting AI paradigms. Skynet leverages blockchain technology to manage work requests and fills. We are currently featuring image generation and support 11 different models. Get started with the /help command, or just click on some buttons. Here is an example command to generate an image: /txt2img a big red tractor in a giant field of corn') await intro_msg.pin() print("\n==============") From 751812ec52c619b3d44f8e45d7583ebcea07a92c Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 19:13:33 -0400 Subject: [PATCH 82/88] add intro message, edit, again --- skynet/frontend/discord/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index 9a29f6d..10f81f1 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -42,7 +42,7 @@ class DiscordBot(commands.Bot): for channel in guild.channels: if channel.name == "skynet": await channel.send('Skynet bot online', view=SkynetView(self.bot)) - intro_msg = await channel.send('Welcome to the Skynet discord bot.\nSkynet is a decentralized compute layer, focused on supporting AI paradigms. Skynet leverages blockchain technology to manage work requests and fills. We are currently featuring image generation and support 11 different models. Get started with the /help command, or just click on some buttons. Here is an example command to generate an image: /txt2img a big red tractor in a giant field of corn') + intro_msg = await channel.send('Welcome to the Skynet discord bot.\nSkynet is a decentralized compute layer, focused on supporting AI paradigms. Skynet leverages blockchain technology to manage work requests and fills. We are currently featuring image generation and support 11 different models. Get started with the /help command, or just click on some buttons.\nHere is an example command to generate an image: /txt2img a big red tractor in a giant field of corn') await intro_msg.pin() print("\n==============") From 965393907f7f70cc3f38dab264e8c4c9aa9f8ad6 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 19:14:39 -0400 Subject: [PATCH 83/88] add intro message, edit, again --- skynet/frontend/discord/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index 10f81f1..b8673c4 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -42,7 +42,7 @@ class DiscordBot(commands.Bot): for channel in guild.channels: if channel.name == "skynet": await channel.send('Skynet bot online', view=SkynetView(self.bot)) - intro_msg = await channel.send('Welcome to the Skynet discord bot.\nSkynet is a decentralized compute layer, focused on supporting AI paradigms. Skynet leverages blockchain technology to manage work requests and fills. We are currently featuring image generation and support 11 different models. Get started with the /help command, or just click on some buttons.\nHere is an example command to generate an image: /txt2img a big red tractor in a giant field of corn') + intro_msg = await channel.send('Welcome to the Skynet discord bot.\nSkynet is a decentralized compute layer, focused on supporting AI paradigms. Skynet leverages blockchain technology to manage work requests and fills. We are currently featuring image generation and support 11 different models. Get started with the /help command, or just click on some buttons. Here is an example command to generate an image:\n/txt2img a big red tractor in a giant field of corn') await intro_msg.pin() print("\n==============") From fd8ea3299a9d3e5befb79570e77975b2aca571f0 Mon Sep 17 00:00:00 2001 From: Konstantine Tsafatinos Date: Fri, 21 Jul 2023 21:37:31 -0400 Subject: [PATCH 84/88] update intro message --- skynet/frontend/discord/bot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skynet/frontend/discord/bot.py b/skynet/frontend/discord/bot.py index b8673c4..accc926 100644 --- a/skynet/frontend/discord/bot.py +++ b/skynet/frontend/discord/bot.py @@ -42,7 +42,8 @@ class DiscordBot(commands.Bot): for channel in guild.channels: if channel.name == "skynet": await channel.send('Skynet bot online', view=SkynetView(self.bot)) - intro_msg = await channel.send('Welcome to the Skynet discord bot.\nSkynet is a decentralized compute layer, focused on supporting AI paradigms. Skynet leverages blockchain technology to manage work requests and fills. We are currently featuring image generation and support 11 different models. Get started with the /help command, or just click on some buttons. Here is an example command to generate an image:\n/txt2img a big red tractor in a giant field of corn') + # intro_msg = await channel.send('Welcome to the Skynet discord bot.\nSkynet is a decentralized compute layer, focused on supporting AI paradigms. Skynet leverages blockchain technology to manage work requests and fills. We are currently featuring image generation and support 11 different models. Get started with the /help command, or just click on some buttons. Here is an example command to generate an image:\n/txt2img a big red tractor in a giant field of corn') + intro_msg = await channel.send("Welcome to Skynet's Discord Bot,\n\nSkynet operates as a decentralized compute layer, offering a wide array of support for diverse AI paradigms through the use of blockchain technology. Our present focus is image generation, powered by 11 distinct models.\n\nTo begin exploring, use the '/help' command or directly interact with the provided buttons. Here is an example command to generate an image:\n\n'/txt2img a big red tractor in a giant field of corn'") await intro_msg.pin() print("\n==============") From 89c413a612fcd89ccbb5291e4461a2decb35a159 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Sat, 22 Jul 2023 16:53:00 -0300 Subject: [PATCH 85/88] Bump version number, also telegram max image limit and disable in private for now --- skynet/constants.py | 5 +- skynet/frontend/telegram/__init__.py | 71 ++++++++++++++++------------ skynet/frontend/telegram/handlers.py | 9 ++++ 3 files changed, 55 insertions(+), 30 deletions(-) diff --git a/skynet/constants.py b/skynet/constants.py index 4dc1c48..b4fcafa 100755 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -1,6 +1,6 @@ #!/usr/bin/python -VERSION = '0.1a10' +VERSION = '0.1a11' DOCKER_RUNTIME_CUDA = 'skynet:runtime-cuda' @@ -172,3 +172,6 @@ CONFIG_ATTRS = [ DEFAULT_DOMAIN = 'skygpu.net' DEFAULT_IPFS_REMOTE = '/ip4/169.197.140.154/tcp/4001/p2p/12D3KooWKWogLFNEcNNMKnzU7Snrnuj84RZdMBg3sLiQSQc51oEv' + +TG_MAX_WIDTH = 1280 +TG_MAX_HEIGHT = 1280 diff --git a/skynet/frontend/telegram/__init__.py b/skynet/frontend/telegram/__init__.py index 33798c5..22d9420 100644 --- a/skynet/frontend/telegram/__init__.py +++ b/skynet/frontend/telegram/__init__.py @@ -1,10 +1,12 @@ #!/usr/bin/python -from json import JSONDecodeError +import io import random import logging import asyncio +from PIL import Image +from json import JSONDecodeError from decimal import Decimal from hashlib import sha256 from datetime import datetime @@ -66,7 +68,7 @@ class SkynetTelegramFrontend: self.ipfs_node = self._exit_stack.enter_context( open_ipfs_node()) - self.ipfs_node.connect(self.remote_ipfs_node) + # self.ipfs_node.connect(self.remote_ipfs_node) logging.info( f'connected to remote ipfs node: {self.remote_ipfs_node}') @@ -236,13 +238,13 @@ class SkynetTelegramFrontend: parse_mode='HTML' ) + caption = generate_reply_caption( + user, params, tx_hash, worker, reward) + # attempt to get the image and send it ipfs_link = f'https://ipfs.{DEFAULT_DOMAIN}/ipfs/{ipfs_hash}/image.png' resp = await get_ipfs_file(ipfs_link) - caption = generate_reply_caption( - user, params, tx_hash, worker, reward) - if not resp or resp.status_code != 200: logging.error(f'couldn\'t get ipfs hosted image at {ipfs_link}!') await self.update_status_message( @@ -251,29 +253,40 @@ class SkynetTelegramFrontend: reply_markup=build_redo_menu(), parse_mode='HTML' ) + return - else: - logging.info(f'success! sending generated image') - await self.bot.delete_message( - chat_id=status_msg.chat.id, message_id=status_msg.id) - if file_id: # img2img - await self.bot.send_media_group( - status_msg.chat.id, - media=[ - InputMediaPhoto(file_id), - InputMediaPhoto( - resp.raw, - caption=caption, - parse_mode='HTML' - ) - ], - ) + png_img = resp.raw + with Image.open(io.BytesIO(resp.raw)) as image: + w, h = image.size - else: # txt2img - await self.bot.send_photo( - status_msg.chat.id, - caption=caption, - photo=resp.raw, - reply_markup=build_redo_menu(), - parse_mode='HTML' - ) + if w > TG_MAX_WIDTH or h > TG_MAX_HEIGHT: + logging.warning(f'result is of size {image.size}') + image.thumbnail((TG_MAX_WIDTH, TG_MAX_HEIGHT)) + tmp_buf = io.BytesIO() + image.save(tmp_buf, format='PNG') + png_img = tmp_buf.getvalue() + + logging.info(f'success! sending generated image') + await self.bot.delete_message( + chat_id=status_msg.chat.id, message_id=status_msg.id) + if file_id: # img2img + await self.bot.send_media_group( + status_msg.chat.id, + media=[ + InputMediaPhoto(file_id), + InputMediaPhoto( + png_img, + caption=caption, + parse_mode='HTML' + ) + ], + ) + + else: # txt2img + await self.bot.send_photo( + status_msg.chat.id, + caption=caption, + photo=png_img, + reply_markup=build_redo_menu(), + parse_mode='HTML' + ) diff --git a/skynet/frontend/telegram/handlers.py b/skynet/frontend/telegram/handlers.py index 7e77880..c6982e5 100644 --- a/skynet/frontend/telegram/handlers.py +++ b/skynet/frontend/telegram/handlers.py @@ -118,6 +118,9 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): user = message.from_user chat = message.chat + if chat.type == 'private': + return + reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: reply_id = message.message_id @@ -174,6 +177,9 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): user = message.from_user chat = message.chat + if chat.type == 'private': + return + reply_id = None if chat.type == 'group' and chat.id == GROUP_ID: reply_id = message.message_id @@ -263,6 +269,9 @@ def create_handler_context(frontend: 'SkynetTelegramFrontend'): user = message.from_user chat = message.chat + if chat.type == 'private': + return + init_msg = 'started processing redo request...' if is_query: status_msg = await bot.send_message(chat.id, init_msg) From 4082adf184efd0e7c809cdf5437ff653f8137e78 Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Wed, 26 Jul 2023 16:44:46 -0300 Subject: [PATCH 86/88] Update stablexl 0.9 to 1.0 --- skynet/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skynet/constants.py b/skynet/constants.py index b4fcafa..f351c2e 100755 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -8,7 +8,7 @@ MODELS = { 'prompthero/openjourney': { 'short': 'midj'}, 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, 'stabilityai/stable-diffusion-2-1-base': { 'short': 'stable2'}, - 'snowkidy/stable-diffusion-xl-base-0.9': { 'short': 'stablexl'}, + 'snowkidy/stable-diffusion-xl-base-1.0': { 'short': 'stablexl'}, 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, 'hakurei/waifu-diffusion': { 'short': 'waifu'}, 'nitrosocke/Ghibli-Diffusion': { 'short': 'ghibli'}, From 440bb015cd32e1e7f56f6a68ac933eeb76906dca Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Thu, 27 Jul 2023 11:30:11 -0300 Subject: [PATCH 87/88] Fix stablexl pipeline --- skynet/constants.py | 2 +- skynet/utils.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/skynet/constants.py b/skynet/constants.py index f351c2e..37ccefd 100755 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -8,7 +8,7 @@ MODELS = { 'prompthero/openjourney': { 'short': 'midj'}, 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, 'stabilityai/stable-diffusion-2-1-base': { 'short': 'stable2'}, - 'snowkidy/stable-diffusion-xl-base-1.0': { 'short': 'stablexl'}, + 'stabilityai/stable-diffusion-xl-base-1.0': { 'short': 'stablexl'}, 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, 'hakurei/waifu-diffusion': { 'short': 'waifu'}, 'nitrosocke/Ghibli-Diffusion': { 'short': 'ghibli'}, diff --git a/skynet/utils.py b/skynet/utils.py index 79932a5..2dce173 100755 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -15,6 +15,8 @@ from PIL import Image from basicsr.archs.rrdbnet_arch import RRDBNet from diffusers import ( DiffusionPipeline, + StableDiffusionXLPipeline, + StableDiffusionXLImg2ImgPipeline, StableDiffusionPipeline, StableDiffusionImg2ImgPipeline, EulerAncestralDiscreteScheduler @@ -80,12 +82,16 @@ def pipeline_for(model: str, mem_fraction: float = 1.0, image=False) -> Diffusio if model == 'runwayml/stable-diffusion-v1-5': params['revision'] = 'fp16' - if image: - pipe_class = StableDiffusionImg2ImgPipeline - elif model == 'snowkidy/stable-diffusion-xl-base-0.9': - pipe_class = DiffusionPipeline + if model == 'stabilityai/stable-diffusion-xl-base-1.0': + if image: + pipe_class = StableDiffusionXLImg2ImgPipeline + else: + pipe_class = StableDiffusionXLPipeline else: - pipe_class = StableDiffusionPipeline + if image: + pipe_class = StableDiffusionImg2ImgPipeline + else: + pipe_class = StableDiffusionPipeline pipe = pipe_class.from_pretrained( model, **params) From 713884e192a6619a8f17a74a67b461eebf6ccb7f Mon Sep 17 00:00:00 2001 From: Guillermo Rodriguez Date: Thu, 27 Jul 2023 13:15:42 -0300 Subject: [PATCH 88/88] Provide both xl models --- skynet/constants.py | 1 + skynet/utils.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/skynet/constants.py b/skynet/constants.py index 37ccefd..743270f 100755 --- a/skynet/constants.py +++ b/skynet/constants.py @@ -8,6 +8,7 @@ MODELS = { 'prompthero/openjourney': { 'short': 'midj'}, 'runwayml/stable-diffusion-v1-5': { 'short': 'stable'}, 'stabilityai/stable-diffusion-2-1-base': { 'short': 'stable2'}, + 'snowkidy/stable-diffusion-xl-base-0.9': { 'short': 'stablexl0.9'}, 'stabilityai/stable-diffusion-xl-base-1.0': { 'short': 'stablexl'}, 'Linaqruf/anything-v3.0': { 'short': 'hdanime'}, 'hakurei/waifu-diffusion': { 'short': 'waifu'}, diff --git a/skynet/utils.py b/skynet/utils.py index 2dce173..2ec7a6c 100755 --- a/skynet/utils.py +++ b/skynet/utils.py @@ -82,7 +82,8 @@ def pipeline_for(model: str, mem_fraction: float = 1.0, image=False) -> Diffusio if model == 'runwayml/stable-diffusion-v1-5': params['revision'] = 'fp16' - if model == 'stabilityai/stable-diffusion-xl-base-1.0': + if (model == 'stabilityai/stable-diffusion-xl-base-1.0' or + model == 'snowkidy/stable-diffusion-xl-base-0.9'): if image: pipe_class = StableDiffusionXLImg2ImgPipeline else:

td_byVlC858qm_?tII8+&KHeO3L4?XEWZ&igd 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