├── contracts ├── .gitignore ├── Makefile └── payout.cpp ├── payer_daemon ├── .gitignore ├── package.json ├── payer_tables.sql └── payer_daemon.js ├── server_side ├── .gitignore ├── package.json └── payout_server.js ├── examples └── pony │ ├── ponypayer_dbsetup.sql │ ├── test_load │ ├── load01.sql │ └── load02.sql │ └── README.md ├── testing ├── telos_testnet_batch.sh └── telos_testnet.txt └── README.md /contracts/.gitignore: -------------------------------------------------------------------------------- 1 | *.abi 2 | *.wasm 3 | -------------------------------------------------------------------------------- /payer_daemon/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | config 4 | -------------------------------------------------------------------------------- /server_side/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | config 4 | -------------------------------------------------------------------------------- /examples/pony/ponypayer_dbsetup.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE ponypayer; 2 | 3 | CREATE USER 'ponypayer'@'localhost' IDENTIFIED BY 'ijnew228dfvds'; 4 | GRANT ALL ON ponypayer.* TO 'ponypayer'@'localhost'; 5 | 6 | 7 | -------------------------------------------------------------------------------- /contracts/Makefile: -------------------------------------------------------------------------------- 1 | CONTRACT=payout 2 | 3 | all: $(CONTRACT).wasm $(CONTRACT).abi 4 | 5 | %.wasm: %.cpp 6 | eosio-cpp -I. -o $@ $< 7 | 8 | %.abi: %.cpp 9 | eosio-abigen -contract=$(CONTRACT) --output=$@ $< 10 | 11 | clean: 12 | rm -f $(CONTRACT).wasm $(CONTRACT).abi 13 | -------------------------------------------------------------------------------- /server_side/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payout_server", 3 | "version": "0.0.1", 4 | "description": "Server-side daemon for EOSIO Payout Engine", 5 | "license": "Apache-2.0", 6 | "author": { 7 | "name": "cc32d9 | EOS Amsterdam", 8 | "url": "https://github.com/cc32d9" 9 | }, 10 | "engines": { 11 | "node": ">= 12.0.0" 12 | }, 13 | "main": "payout_server.js", 14 | "dependencies": { 15 | "config": ">=3.3.3", 16 | "node-fetch": ">= 2.6.0", 17 | "eosjs": ">=20.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /payer_daemon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payer_example", 3 | "version": "0.0.1", 4 | "description": "Example payer script for EOSIO Payout Engine", 5 | "license": "Apache-2.0", 6 | "author": { 7 | "name": "cc32d9 | EOS Amsterdam", 8 | "url": "https://github.com/cc32d9" 9 | }, 10 | "engines": { 11 | "node": ">= 12.0.0" 12 | }, 13 | "main": "payer_daemon.js", 14 | "dependencies": { 15 | "config": ">=3.3.3", 16 | "node-fetch": ">= 2.6.0", 17 | "eosjs": ">=20.0.0", 18 | "mariadb": ">=2.5.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /payer_daemon/payer_tables.sql: -------------------------------------------------------------------------------- 1 | /* this table contains valid blockchain accounts */ 2 | CREATE TABLE VALID_ACCOUNTS 3 | ( 4 | account VARCHAR(13) PRIMARY KEY 5 | ) ENGINE=InnoDB; 6 | 7 | 8 | 9 | CREATE TABLE PAYMENTS 10 | ( 11 | payment_uuid VARCHAR(36) PRIMARY KEY, 12 | tstamp DATETIME NOT NULL, 13 | recipient VARCHAR(13) NOT NULL, 14 | amount DOUBLE PRECISION NOT NULL 15 | ) ENGINE=InnoDB; 16 | 17 | CREATE INDEX PAYMENTS_I01 ON PAYMENTS (recipient, tstamp); 18 | CREATE INDEX PAYMENTS_I02 ON PAYMENTS (tstamp); 19 | 20 | 21 | CREATE TABLE PAYMENT_MEMO 22 | ( 23 | recipient VARCHAR(13) PRIMARY KEY, 24 | memo VARCHAR(256) NOT NULL 25 | ) ENGINE=InnoDB; 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /examples/pony/test_load/load01.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO PAYMENTS (payment_uuid, tstamp, recipient, amount) 2 | VALUES 3 | (UUID(), NOW(), 'captaincrypt', 0.5), 4 | (UUID(), NOW(), 'cc32dninexxx', 0.5), 5 | (UUID(), NOW(), '...m1..scash', 0.5), 6 | (UUID(), NOW(), '.aghami.game', 0.5), 7 | (UUID(), NOW(), '.iw4nl.scash', 0.5), 8 | (UUID(), NOW(), '1.seeds', 0.5), 9 | (UUID(), NOW(), '111111111111', 0.5), 10 | (UUID(), NOW(), '111111111112', 0.5), 11 | (UUID(), NOW(), '111111111113', 0.5), 12 | (UUID(), NOW(), '111111111114', 0.5), 13 | (UUID(), NOW(), '111111111115', 0.5), 14 | (UUID(), NOW(), '1111111111tb', 0.5), 15 | (UUID(), NOW(), '1111111111tf', 0.5), 16 | (UUID(), NOW(), '1111111111tr', 0.5), 17 | (UUID(), NOW(), '1111111111tv', 0.5), 18 | (UUID(), NOW(), '111111ghhhhh', 0.5), 19 | (UUID(), NOW(), '111111gyftie', 0.5), 20 | (UUID(), NOW(), '1111223233r3', 0.5), 21 | (UUID(), NOW(), '11gyftieobfa', 0.5), 22 | (UUID(), NOW(), '11teloscrew1', 0.5), 23 | (UUID(), NOW(), '12223333erty', 0.5), 24 | (UUID(), NOW(), '123123123123', 0.5), 25 | (UUID(), NOW(), '12321213213w', 0.5), 26 | (UUID(), NOW(), '123412341234', 0.5), 27 | (UUID(), NOW(), '123412341235', 0.5), 28 | (UUID(), NOW(), '12341234123q', 0.5), 29 | (UUID(), NOW(), '12341234qwer', 0.5), 30 | (UUID(), NOW(), '123451232153', 0.5), 31 | (UUID(), NOW(), '12345abcdefg', 0.5), 32 | (UUID(), NOW(), '1234qwer4321', 0.5), 33 | (UUID(), NOW(), '123bigqudo21', 0.5), 34 | (UUID(), NOW(), '123bigqudo22', 0.5), 35 | (UUID(), NOW(), '123singapore', 0.5), 36 | (UUID(), NOW(), '13cx1xiazksf', 0.5), 37 | (UUID(), NOW(), '13smartscash', 0.5), 38 | (UUID(), NOW(), '14smartscash', 0.5), 39 | (UUID(), NOW(), '15av554cqwsz', 0.5), 40 | (UUID(), NOW(), '15smartscash', 0.5), 41 | (UUID(), NOW(), '1aqkjhstcqcn', 0.5), 42 | (UUID(), NOW(), '1be15ckmijtl', 0.5), 43 | (UUID(), NOW(), '1behukdeoav4', 0.5), 44 | (UUID(), NOW(), '1cwjpvjwnpty', 0.5), 45 | (UUID(), NOW(), '1deh2qoae2na', 0.5), 46 | (UUID(), NOW(), '1e2telosa5ct', 0.5), 47 | (UUID(), NOW(), '1eostheworld', 0.5); 48 | -------------------------------------------------------------------------------- /testing/telos_testnet_batch.sh: -------------------------------------------------------------------------------- 1 | # Telos testnet 2 | 3 | alias tTcleos='cleos -v -u https://testnet.persiantelos.com' 4 | 5 | 6 | tTcleos push action payoutengine newschedule '["payoutpayer1", "payer1sc1", "eosio.token", "1.0000 TLOS", "welcome to payer1sc1"]' -p payoutpayer1 7 | sleep 2 8 | 9 | tTcleos push action payoutengine newschedule '["payoutpayer1", "payer1sc2", "eosio.token", "1.0000 TLOS", "Welcome to payer1sc2"]' -p payoutpayer1 10 | sleep 2 11 | 12 | tTcleos push action payoutengine newschedule '["payoutpayer2", "payer2sc1", "eosio.token", "1.0000 TLOS", "Welcome to payer2sc1"]' -p payoutpayer2 13 | sleep 2 14 | 15 | tTcleos push action payoutengine newschedule '["payoutpayer2", "payer2sc2", "btc.ptokens", "0.00000000 PBTC", "Welcome to payer2sc2"]' -p payoutpayer2 16 | sleep 2 17 | 18 | 19 | tTcleos transfer payoutpayer1 payoutengine "20.0000 TLOS" 20 | sleep 2 21 | 22 | tTcleos push action btc.ptokens transfer '["payoutpayer2", "payoutengine", "0.01000000 PBTC", "Deposit"]' -p payoutpayer2 23 | sleep 2 24 | 25 | tTcleos transfer payoutpayer2 payoutengine "20.0000 TLOS" 26 | sleep 2 27 | 28 | 29 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "3.0000 TLOS"], ["payoutrcpt12", "4.0000 TLOS"], ["payoutrcpt13", "3.0000 TLOS"]]]' -p payoutpayer1 30 | sleep 2 31 | 32 | tTcleos push action payoutengine book '["payer1sc2", [["payoutrcpt11", "0.0100 TLOS"], ["payoutrcpt12", "0.0100 TLOS"], ["payoutrcpt13", "0.0100 TLOS"], ["payoutrcpt14", "0.0100 TLOS"]]]' -p payoutpayer1 33 | sleep 2 34 | 35 | tTcleos push action payoutengine book '["payer2sc1", [["payoutrcpt11", "0.0010 TLOS"], ["payoutrcpt12", "0.0010 TLOS"], ["payoutrcpt13", "0.0010 TLOS"], ["payoutrcpt14", "0.0010 TLOS"]]]' -p payoutpayer2 36 | sleep 2 37 | 38 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt12", "0.00010000 PBTC"], ["payoutrcpt13", "0.00010000 PBTC"], ["payoutrcpt14", "0.00010000 PBTC"]]]' -p payoutpayer2 39 | sleep 2 40 | 41 | tTcleos push action payoutengine approveacc '[["payoutrcpt11","payoutrcpt12","payoutrcpt13"]]' -p payoutadmin1 42 | sleep 2 43 | 44 | 45 | tTcleos push action payoutengine runpayouts '[30]' -p payoutrcpt11 46 | sleep 2 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/pony/README.md: -------------------------------------------------------------------------------- 1 | # Payout Engine client example 2 | 3 | ## Test Token on Telos Testnet 4 | 5 | ``` 6 | Token contract: ponytokenxxx 7 | Symbol: 4,PONY 8 | Max supply: 10000000.0000 PONY 9 | 10 | Mint account: ponymintmint 11 | Treasury account: ponytreasure 12 | Payer account: ponypayerxxx 13 | 14 | 15 | alias tTcleos='cleos -v -u https://testnet.persiantelos.com' 16 | 17 | # Assuming EOSIO 2.x and CDT 1.7.0 packages are installed, see 18 | # https://github.com/EOSIO/eos/releases 19 | # https://github.com/EOSIO/eosio.cdt/releases 20 | 21 | # Assuming the private keys are imported in a wallet and it's unlocked. 22 | 23 | # compile the token contract 24 | mkdir /opt/src 25 | cd /opt/src 26 | git clone https://github.com/EOSIO/eosio.contracts.git 27 | cd eosio.contracts/contracts/eosio.token/src 28 | eosio-cpp -I ../include/ eosio.token.cpp 29 | 30 | # upload the token contract to the blockchain account 31 | tTcleos system buyram ponytokenxxx ponytokenxxx -k 100 32 | tTcleos set contract ponytokenxxx . eosio.token.wasm eosio.token.abi 33 | 34 | # we delegate the "create" action to ponymintmint, so that it can create new currencies 35 | tTcleos set account permission ponytokenxxx create '{"threshold": 1, "accounts":[{"permission":{"actor":"ponymintmint","permission":"active"},"weight":1}], "waits":[] }' active -p ponytokenxxx@active 36 | tTcleos set action permission ponytokenxxx ponytokenxxx create create -p ponytokenxxx@active 37 | 38 | # make the token contract immutable 39 | tTcleos set account permission ponytokenxxx active '{"threshold": 1, "accounts":[{"permission":{"actor":"eosio","permission":"active"},"weight":1}], "waits":[] }' owner -p ponytokenxxx@owner 40 | tTcleos set account permission ponytokenxxx owner '{"threshold": 1, "accounts":[{"permission":{"actor":"eosio","permission":"active"},"weight":1}], "waits":[] }' -p ponytokenxxx@owner 41 | 42 | 43 | # Create the token 44 | tTcleos push action ponytokenxxx create '["ponymintmint", "10000000.0000 PONY"]' -p ponytokenxxx@create 45 | 46 | # Issue 1M tokens and send them to treasury 47 | tTcleos push action ponytokenxxx issue '["ponymintmint", "1000000.0000 PONY", ""]' -p ponymintmint@active 48 | tTcleos push action ponytokenxxx transfer '["ponymintmint", "ponytreasure", "1000000.0000 PONY", "treasury issuance"]' -p ponymintmint 49 | 50 | # allocate 100k tokens to the payer account 51 | tTcleos push action ponytokenxxx transfer '["ponytreasure", "ponypayerxxx", "100000.0000 PONY", "preparing for payout"]' -p ponytreasure 52 | ``` 53 | 54 | ## Setting up the schedule 55 | 56 | ``` 57 | # buy enough RAM for the payments 58 | tTcleos system buyram ponypayerxxx ponypayerxxx -k 1000 59 | 60 | # register the schedule 61 | tTcleos push action payoutengine newschedule '["ponypayerxxx", "pony", "ponytokenxxx", "1.0000 PONY", "A pony!"]' -p ponypayerxxx 62 | 63 | # top-up the payer engine 64 | tTcleos push action ponytokenxxx transfer '["ponypayerxxx", "payoutengine", "10000.0000 PONY", "top-up"]' -p ponypayerxxx 65 | ``` 66 | 67 | 68 | 69 | ## Payer server environment 70 | 71 | ``` 72 | apt update && apt install -y mariadb-server git curl 73 | 74 | curl -sL https://deb.nodesource.com/setup_14.x | bash - 75 | apt-get install -y nodejs 76 | 77 | 78 | cd /opt 79 | git clone https://github.com/cc32d9/eosio_payout.git 80 | cd eosio_payout 81 | 82 | mysql < examples/pony/ponypayer_dbsetup.sql 83 | mysql --database ponypayer < payer_daemon/payer_tables.sql 84 | 85 | cd payer_daemon 86 | npm install 87 | 88 | mkdir config 89 | cat >config/default.json <<'EOT' 90 | { 91 | "url": "https://testnet.persiantelos.com", 92 | "engineacc": "payoutengine", 93 | "payeracc": "ponypayerxxx", 94 | "payerkey": "5*************************************", 95 | 96 | "book_batch_size": 30, 97 | 98 | "schedule": { 99 | "name": "pony", 100 | "currency": "PONY", 101 | "currency_precision": 4 102 | }, 103 | 104 | "mariadb_server": { 105 | "host": "localhost", 106 | "user": "ponypayer", 107 | "password": "ijnew228dfvds", 108 | "database": "ponypayer", 109 | "connectionLimit": 5 110 | }, 111 | 112 | "timer": { 113 | "keepalive": 3600000, 114 | "check_accounts": 20000, 115 | "fullscan": 20000, 116 | "partialscan": 5000 117 | } 118 | } 119 | EOT 120 | 121 | node payer_daemon.js 122 | ``` 123 | 124 | The node process will scan the PAYMENTS table periodically and book 125 | new payments if needed. 126 | 127 | -------------------------------------------------------------------------------- /examples/pony/test_load/load02.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO PAYMENTS (payment_uuid, tstamp, recipient, amount) 2 | VALUES 3 | (UUID(), NOW(), 'data.seedsx', 0.5), 4 | (UUID(), NOW(), 'datab5t13551', 0.5), 5 | (UUID(), NOW(), 'dateloscrew1', 0.5), 6 | (UUID(), NOW(), 'dateloscrew2', 0.5), 7 | (UUID(), NOW(), 'dateloscrew3', 0.5), 8 | (UUID(), NOW(), 'dateloscrew4', 0.5), 9 | (UUID(), NOW(), 'daves.trees', 0.5), 10 | (UUID(), NOW(), 'david13.qbe', 0.5), 11 | (UUID(), NOW(), 'davidbea.qbe', 0.5), 12 | (UUID(), NOW(), 'davidds1.qbe', 0.5), 13 | (UUID(), NOW(), 'daviddss.qbe', 0.5), 14 | (UUID(), NOW(), 'davidfin.qbe', 0.5), 15 | (UUID(), NOW(), 'davidjo1.qbe', 0.5), 16 | (UUID(), NOW(), 'davidjos.qbe', 0.5), 17 | (UUID(), NOW(), 'davidte1.qbe', 0.5), 18 | (UUID(), NOW(), 'davidtes.qbe', 0.5), 19 | (UUID(), NOW(), 'davidwilly12', 0.5), 20 | (UUID(), NOW(), 'daviinkirida', 0.5), 21 | (UUID(), NOW(), 'dbdjdjsjajai', 0.5), 22 | (UUID(), NOW(), 'dbksvjdvjdvj', 0.5), 23 | (UUID(), NOW(), 'dbmcy4ecinma', 0.5), 24 | (UUID(), NOW(), 'dcascalheira', 0.5), 25 | (UUID(), NOW(), 'dce5rn3fsl13', 0.5), 26 | (UUID(), NOW(), 'dcfasdfasdfa', 0.5), 27 | (UUID(), NOW(), 'dcoqhzjjrm1g', 0.5), 28 | (UUID(), NOW(), 'dd3ry4te5sqy', 0.5), 29 | (UUID(), NOW(), 'ddddd.qbe', 0.5), 30 | (UUID(), NOW(), 'dddddd.qbe', 0.5), 31 | (UUID(), NOW(), 'dddddddd.qbe', 0.5), 32 | (UUID(), NOW(), 'dddddfffffff', 0.5), 33 | (UUID(), NOW(), 'ddjj.qbe', 0.5), 34 | (UUID(), NOW(), 'ddqsdhuziukf', 0.5), 35 | (UUID(), NOW(), 'deadly452243', 0.5), 36 | (UUID(), NOW(), 'deadly455434', 0.5), 37 | (UUID(), NOW(), 'deadsquirrel', 0.5), 38 | (UUID(), NOW(), 'decide', 0.5), 39 | (UUID(), NOW(), 'decidetoken1', 0.5), 40 | (UUID(), NOW(), 'decideworker', 0.5), 41 | (UUID(), NOW(), 'deermann1234', 0.5), 42 | (UUID(), NOW(), 'deivis241433', 0.5), 43 | (UUID(), NOW(), 'deivis251515', 0.5), 44 | (UUID(), NOW(), 'deivis432311', 0.5), 45 | (UUID(), NOW(), 'deivis541153', 0.5), 46 | (UUID(), NOW(), 'dkzbrdsphk1q', 0.5), 47 | (UUID(), NOW(), 'dlupp2thltup', 0.5), 48 | (UUID(), NOW(), 'dmaildotcobp', 0.5), 49 | (UUID(), NOW(), 'dmarketplace', 0.5), 50 | (UUID(), NOW(), 'dnair2211221', 0.5), 51 | (UUID(), NOW(), 'dnalini11545', 0.5), 52 | (UUID(), NOW(), 'dnaz3ti5plun', 0.5), 53 | (UUID(), NOW(), 'dnlcyan25255', 0.5), 54 | (UUID(), NOW(), 'dnndndnfbbdb', 0.5), 55 | (UUID(), NOW(), 'dnskdjfnfnfn', 0.5), 56 | (UUID(), NOW(), 'docs.hypha', 0.5), 57 | (UUID(), NOW(), 'docucust111a', 0.5), 58 | (UUID(), NOW(), 'docucust222a', 0.5), 59 | (UUID(), NOW(), 'domcwm1nblkk', 0.5), 60 | (UUID(), NOW(), 'donaldtr.qbe', 0.5), 61 | (UUID(), NOW(), 'donbeebs1234', 0.5), 62 | (UUID(), NOW(), 'dongwakim123', 0.5), 63 | (UUID(), NOW(), 'donsilas2222', 0.5), 64 | (UUID(), NOW(), 'dontbeajerry', 0.5), 65 | (UUID(), NOW(), 'doqwejdoiqwj', 0.5), 66 | (UUID(), NOW(), 'doris.gba', 0.5), 67 | (UUID(), NOW(), 'douglas.tf', 0.5), 68 | (UUID(), NOW(), 'dougs.trees', 0.5), 69 | (UUID(), NOW(), 'dougweisb435', 0.5), 70 | (UUID(), NOW(), 'dowbgnzrscr5', 0.5), 71 | (UUID(), NOW(), 'dposclubprod', 0.5), 72 | (UUID(), NOW(), 'dr', 0.5), 73 | (UUID(), NOW(), 'drapdaindry1', 0.5), 74 | (UUID(), NOW(), 'drapdaindryp', 0.5), 75 | (UUID(), NOW(), 'draptaindryk', 0.5), 76 | (UUID(), NOW(), 'draptaindryp', 0.5), 77 | (UUID(), NOW(), 'drealm', 0.5), 78 | (UUID(), NOW(), 'dredonsj.qbe', 0.5), 79 | (UUID(), NOW(), 'drkvqov3xug3', 0.5), 80 | (UUID(), NOW(), 'dsaddsasdsew', 0.5), 81 | (UUID(), NOW(), 'dsasfdvcbvnb', 0.5), 82 | (UUID(), NOW(), 'dsdsadsa2241', 0.5), 83 | (UUID(), NOW(), 'dsrgfwrg.qbe', 0.5), 84 | (UUID(), NOW(), 'dstorbilling', 0.5), 85 | (UUID(), NOW(), 'dtgibexlena4', 0.5), 86 | (UUID(), NOW(), 'dtueanhl3534', 0.5), 87 | (UUID(), NOW(), 'du4eu4roakco', 0.5), 88 | (UUID(), NOW(), 'duanecare512', 0.5), 89 | (UUID(), NOW(), 'ffscmhaveacc', 0.5), 90 | (UUID(), NOW(), 'ffscmscatter', 0.5), 91 | (UUID(), NOW(), 'ffscmsquirel', 0.5), 92 | (UUID(), NOW(), 'fgbzztb4ggbh', 0.5), 93 | (UUID(), NOW(), 'fh5uhnofjkhy', 0.5), 94 | (UUID(), NOW(), 'fhgbem.trees', 0.5), 95 | (UUID(), NOW(), 'fhsjjdjcbdbn', 0.5), 96 | (UUID(), NOW(), 'fieldy.tf', 0.5), 97 | (UUID(), NOW(), 'fifjfirlfjtj', 0.5), 98 | (UUID(), NOW(), 'fiforkrkifkr', 0.5), 99 | (UUID(), NOW(), 'figos1342413', 0.5), 100 | (UUID(), NOW(), 'filehashfact', 0.5), 101 | (UUID(), NOW(), 'filipe533541', 0.5), 102 | (UUID(), NOW(), 'finalfanta13', 0.5), 103 | (UUID(), NOW(), 'finaltestacc', 0.5), 104 | (UUID(), NOW(), 'financexxxxx', 0.5), 105 | (UUID(), NOW(), 'fire15523322', 0.5), 106 | (UUID(), NOW(), 'firebaseboy1', 0.5), 107 | (UUID(), NOW(), 'firebaseboy2', 0.5), 108 | (UUID(), NOW(), 'firebaseboy3', 0.5), 109 | (UUID(), NOW(), 'firebasetest', 0.5), 110 | (UUID(), NOW(), 'fiscalesvote', 0.5), 111 | (UUID(), NOW(), 'fivehundreda', 0.5), 112 | (UUID(), NOW(), 'fjdjhdhfhfhd', 0.5), 113 | (UUID(), NOW(), 'fjfjfjfjfjci', 0.5), 114 | (UUID(), NOW(), 'fjfjfjfjfjcj', 0.5), 115 | (UUID(), NOW(), 'fkfkiffkfkfk', 0.5), 116 | (UUID(), NOW(), 'fkkfnaneoo22', 0.5), 117 | (UUID(), NOW(), 'flipipsolut1', 0.5), 118 | (UUID(), NOW(), 'floridaman42', 0.5), 119 | (UUID(), NOW(), 'flycicada154', 0.5), 120 | (UUID(), NOW(), 'fmfkfkfjfklf', 0.5), 121 | (UUID(), NOW(), 'fnbclzfaniwv', 0.5), 122 | (UUID(), NOW(), 'foflexitytls', 0.5), 123 | (UUID(), NOW(), 'fondlvuitton', 0.5), 124 | (UUID(), NOW(), 'forku1323524', 0.5), 125 | (UUID(), NOW(), 'forku1445535', 0.5), 126 | (UUID(), NOW(), 'forku1512455', 0.5), 127 | (UUID(), NOW(), 'forku2215332', 0.5), 128 | (UUID(), NOW(), 'forku2245552', 0.5), 129 | (UUID(), NOW(), 'forku2321254', 0.5), 130 | (UUID(), NOW(), 'forku2515342', 0.5), 131 | (UUID(), NOW(), 'forku3115155', 0.5), 132 | (UUID(), NOW(), 'g42damjyguge', 0.5), 133 | (UUID(), NOW(), 'g42damjzgage', 0.5), 134 | (UUID(), NOW(), 'g42damjzgene', 0.5), 135 | (UUID(), NOW(), 'g42damqgenes', 0.5), 136 | (UUID(), NOW(), 'g42damrqgage', 0.5), 137 | (UUID(), NOW(), 'g42damrqgmge', 0.5), 138 | (UUID(), NOW(), 'g42damrqgqge', 0.5), 139 | (UUID(), NOW(), 'g42damrsg4ge', 0.5), 140 | (UUID(), NOW(), 'g42damrsgmge', 0.5), 141 | (UUID(), NOW(), 'g42damrsgqge', 0.5), 142 | (UUID(), NOW(), 'g42damrugage', 0.5), 143 | (UUID(), NOW(), 'g42damrugege', 0.5), 144 | (UUID(), NOW(), 'g42damruhege', 0.5), 145 | (UUID(), NOW(), 'g42damrwg4ge', 0.5), 146 | (UUID(), NOW(), 'g42damrygage', 0.5), 147 | (UUID(), NOW(), 'g42damrzgyge', 0.5), 148 | (UUID(), NOW(), 'g42damrzhege', 0.5), 149 | (UUID(), NOW(), 'g42damygenes', 0.5), 150 | (UUID(), NOW(), 'g42damzqgene', 0.5), 151 | (UUID(), NOW(), 'g42damzqgmge', 0.5), 152 | (UUID(), NOW(), 'g42damzrgene', 0.5), 153 | (UUID(), NOW(), 'g42damzsguge', 0.5), 154 | (UUID(), NOW(), 'g42damzthege', 0.5), 155 | (UUID(), NOW(), 'g42damzuhage', 0.5), 156 | (UUID(), NOW(), 'g42damzvgege', 0.5), 157 | (UUID(), NOW(), 'g42damzvgene', 0.5), 158 | (UUID(), NOW(), 'g42damzvgige', 0.5), 159 | (UUID(), NOW(), 'g42damzwgene', 0.5), 160 | (UUID(), NOW(), 'g42damzxgige', 0.5), 161 | (UUID(), NOW(), 'g42damzygene', 0.5), 162 | (UUID(), NOW(), 'g42damzygige', 0.5), 163 | (UUID(), NOW(), 'g42damzzgene', 0.5), 164 | (UUID(), NOW(), 'g42damzzgyge', 0.5), 165 | (UUID(), NOW(), 'g42danagenes', 0.5), 166 | (UUID(), NOW(), 'g42danbrg4ge', 0.5), 167 | (UUID(), NOW(), 'g42danbrhage', 0.5), 168 | (UUID(), NOW(), 'g42danbsgmge', 0.5), 169 | (UUID(), NOW(), 'g42danbtgene', 0.5), 170 | (UUID(), NOW(), 'g42danbvhage', 0.5), 171 | (UUID(), NOW(), 'g42danbzgege', 0.5), 172 | (UUID(), NOW(), 'g42danigenes', 0.5), 173 | (UUID(), NOW(), 'g42danjrg4ge', 0.5), 174 | (UUID(), NOW(), 'g42danjsgege', 0.5), 175 | (UUID(), NOW(), 'g42demrvgene', 0.5), 176 | (UUID(), NOW(), 'g42demrvgmge', 0.5), 177 | (UUID(), NOW(), 'g42demrwgege', 0.5), 178 | (UUID(), NOW(), 'g42demrwgene', 0.5), 179 | (UUID(), NOW(), 'g42demrwgige', 0.5), 180 | (UUID(), NOW(), 'g42demrwgqge', 0.5), 181 | (UUID(), NOW(), 'g42demrxgage', 0.5), 182 | (UUID(), NOW(), 'g42demrxgyge', 0.5), 183 | (UUID(), NOW(), 'g42demrxhege', 0.5), 184 | (UUID(), NOW(), 'g42demrygage', 0.5), 185 | (UUID(), NOW(), 'g42demzqgmge', 0.5), 186 | (UUID(), NOW(), 'g42demzrgene', 0.5), 187 | (UUID(), NOW(), 'g42demzrgqge', 0.5), 188 | (UUID(), NOW(), 'g42demzshage', 0.5), 189 | (UUID(), NOW(), 'g42demzshege', 0.5), 190 | (UUID(), NOW(), 'g42demztgqge', 0.5), 191 | (UUID(), NOW(), 'g42demzthege', 0.5), 192 | (UUID(), NOW(), 'g42demzug4ge', 0.5), 193 | (UUID(), NOW(), 'g42demzuhege', 0.5), 194 | (UUID(), NOW(), 'g42demzvgage', 0.5), 195 | (UUID(), NOW(), 'g42demzvhage', 0.5), 196 | (UUID(), NOW(), 'g42demzvhege', 0.5), 197 | (UUID(), NOW(), 'g42demzxgege', 0.5), 198 | (UUID(), NOW(), 'g42demzxgene', 0.5), 199 | (UUID(), NOW(), 'g42demzxgmge', 0.5), 200 | (UUID(), NOW(), 'g42demzzgege', 0.5), 201 | (UUID(), NOW(), 'g42denbqgige', 0.5), 202 | (UUID(), NOW(), 'g42denbqgmge', 0.5), 203 | (UUID(), NOW(), 'g42denbqgqge', 0.5), 204 | (UUID(), NOW(), 'g42denbsgage', 0.5), 205 | (UUID(), NOW(), 'g42denbsgqge', 0.5), 206 | (UUID(), NOW(), 'g42denbugqge', 0.5), 207 | (UUID(), NOW(), 'g42denbvhege', 0.5), 208 | (UUID(), NOW(), 'g42denbxg4ge', 0.5), 209 | (UUID(), NOW(), 'g42denbyhage', 0.5), 210 | (UUID(), NOW(), 'g42denbzgqge', 0.5), 211 | (UUID(), NOW(), 'g42denjqgige', 0.5), 212 | (UUID(), NOW(), 'g42denjrgege', 0.5), 213 | (UUID(), NOW(), 'g42denjrhege', 0.5), 214 | (UUID(), NOW(), 'g42denjtgage', 0.5), 215 | (UUID(), NOW(), 'g42denjugyge', 0.5), 216 | (UUID(), NOW(), 'g42denjwgage', 0.5), 217 | (UUID(), NOW(), 'g42denjxgege', 0.5); 218 | -------------------------------------------------------------------------------- /server_side/payout_server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | Copyright 2020 cc32d9@gmail.com 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | 20 | const config = require('config'); 21 | const fetch = require('node-fetch'); 22 | 23 | const { Api, JsonRpc, RpcError } = require('eosjs'); 24 | const { JsSignatureProvider } = require('eosjs/dist/eosjs-jssig'); 25 | const { TextEncoder, TextDecoder } = require('util'); 26 | 27 | 28 | 29 | const url = config.get('url'); 30 | const engineacc = config.get('engineacc'); 31 | const adminacc = config.get('adminacc'); 32 | const adminkey = config.get('adminkey'); 33 | const runpayouts_limit = config.get('limit.runpayouts'); 34 | const approvals_limit = config.get('limit.approvals'); 35 | 36 | const timer_keepalive = config.get('timer.keepalive'); 37 | const timer_check_dues = config.get('timer.check_dues'); 38 | const timer_check_dues_followup = config.get('timer.check_dues_followup'); 39 | const timer_check_unapproved = config.get('timer.check_unapproved'); 40 | const timer_check_approved = config.get('timer.check_approved'); 41 | 42 | const timer_recheck_hash = config.get('timer.recheck_hash'); 43 | const timer_push_approved_list = config.get('timer.push_approved_list'); 44 | 45 | 46 | const sigProvider = new JsSignatureProvider([adminkey]); 47 | const rpc = new JsonRpc(url, { fetch }); 48 | const api = new Api({rpc: rpc, signatureProvider: sigProvider, 49 | textDecoder: new TextDecoder(), textEncoder: new TextEncoder()}); 50 | 51 | console.log("Started payout server using " + url); 52 | //console.log(config.util.getConfigSources()); 53 | 54 | var last_revision = 0; 55 | var has_code_map = new Map(); 56 | var approved_list = new Array(); 57 | 58 | setInterval(keepaalive, timer_keepalive); 59 | setInterval(check_dues, timer_check_dues); 60 | setInterval(check_unapproved, timer_check_unapproved); 61 | setInterval(check_approved, timer_check_approved); 62 | setInterval(push_approved_list, timer_push_approved_list); 63 | 64 | 65 | async function keepaalive() { 66 | console.log('keepalive'); 67 | } 68 | 69 | 70 | async function check_dues() { 71 | // console.log("check_dues() started"); 72 | 73 | if( await has_open_dues() ) { 74 | // console.log("due payments found"); 75 | let new_revision = await get_revision(); 76 | if( new_revision != last_revision ) { 77 | console.log("found an updated revision: " + new_revision); 78 | last_revision = new_revision; 79 | runpayouts(); 80 | setTimeout(check_dues, timer_check_dues_followup); 81 | } 82 | else { 83 | // console.log("revision has not updated"); 84 | } 85 | } 86 | else { 87 | // console.log("no due payments found"); 88 | } 89 | } 90 | 91 | 92 | async function check_unapproved() { 93 | // console.log("check_unapproved() started"); 94 | let now = new Date(); 95 | fetch_approved(false, async function (acc) { 96 | if( !has_code_map.has(acc) || now >= has_code_map.get(acc) + timer_recheck_hash ) { 97 | let hc = await has_code(acc); 98 | if( !hc ) { 99 | approved_list.push(acc); 100 | } 101 | else { 102 | has_code_map.set(acc, now); 103 | } 104 | } 105 | }); 106 | } 107 | 108 | 109 | async function push_approved_list() { 110 | if( approved_list.length > 0 ) { 111 | let clean = approved_list.slice(0, approvals_limit); 112 | approved_list.splice(0, approvals_limit); 113 | console.log("approving " + clean.length + " accounts"); 114 | await approve(true, clean); 115 | } 116 | } 117 | 118 | 119 | async function check_approved() { 120 | // console.log("check_approved() started"); 121 | let now = new Date(); 122 | fetch_approved(true, async function (acc) { 123 | let hc = await has_code(acc); 124 | if( hc ) { 125 | has_code_map.set(acc, now); 126 | approve(false, [acc]); 127 | } 128 | }); 129 | } 130 | 131 | 132 | 133 | 134 | 135 | // checks if there any schedules with due payments 136 | async function has_open_dues() { 137 | let response = await fetch(url + '/v1/chain/get_table_rows', { 138 | method: 'post', 139 | body: JSON.stringify({ 140 | json: 'true', 141 | code: engineacc, 142 | scope: '0', 143 | table: 'schedules', 144 | index_position: '2', 145 | key_type: 'i64', 146 | lower_bound: 1, 147 | limit: 1 148 | }), 149 | headers: { 'Content-Type': 'application/json' }, 150 | }); 151 | 152 | let data = await response.json(); 153 | return (data.rows.length > 0) ? true : false; 154 | } 155 | 156 | 157 | async function get_revision() { 158 | let response = await fetch(url + '/v1/chain/get_table_rows', { 159 | method: 'post', 160 | body: JSON.stringify({ 161 | json: 'true', 162 | code: engineacc, 163 | scope: '0', 164 | table: 'props', 165 | index_position: '1', 166 | key_type: 'name', 167 | lower_bound: "revision", 168 | limit: 1 169 | }), 170 | headers: { 'Content-Type': 'application/json' }, 171 | }); 172 | 173 | let data = await response.json(); 174 | return (data.rows.length > 0) ? data.rows[0].val_uint : 0; 175 | } 176 | 177 | 178 | async function runpayouts() { 179 | try { 180 | const result = await api.transact( 181 | { 182 | actions: 183 | [ 184 | { 185 | account: engineacc, 186 | name: 'runpayouts', 187 | authorization: [{ 188 | actor: adminacc, 189 | permission: 'active'} ], 190 | data: { 191 | count: runpayouts_limit + Math.floor(Math.random() * 10) 192 | }, 193 | } 194 | ] 195 | }, 196 | { 197 | blocksBehind: 100, 198 | expireSeconds: 600 199 | } 200 | ); 201 | console.info('runpayouts transaction ID: ', result.transaction_id); 202 | } catch (e) { 203 | console.error('ERROR: ' + e); 204 | } 205 | } 206 | 207 | 208 | async function fetch_approved(check_approved, callback, idx_low=1) { 209 | let response = await fetch(url + '/v1/chain/get_table_rows', { 210 | method: 'post', 211 | body: JSON.stringify({ 212 | json: 'true', 213 | code: engineacc, 214 | scope: '0', 215 | table: 'approvals', 216 | index_position: check_approved?'2':'3', 217 | key_type: 'i64', 218 | lower_bound: idx_low 219 | }), 220 | headers: { 'Content-Type': 'application/json' }, 221 | }); 222 | 223 | let data = await response.json(); 224 | if( data ) { 225 | data.rows.forEach(function(row) { 226 | callback(row.account); 227 | }); 228 | 229 | if( data.more ) { 230 | return fetch_approved(check_approved, callback, data.next_key); 231 | } 232 | } 233 | 234 | return; 235 | } 236 | 237 | 238 | async function has_code(acc) { 239 | let response = await fetch(url + '/v1/chain/get_code_hash', { 240 | method: 'post', 241 | body: JSON.stringify({ 242 | json: 'true', 243 | account_name: acc 244 | }), 245 | headers: { 'Content-Type': 'application/json' }, 246 | }); 247 | 248 | let data = await response.json(); 249 | let ret = true; 250 | if( data.code_hash == '0000000000000000000000000000000000000000000000000000000000000000' ) { 251 | ret = false; 252 | } 253 | 254 | console.log("account " + acc + " has code: " + ret); 255 | return ret; 256 | } 257 | 258 | 259 | async function approve(do_approve, accounts) { 260 | try { 261 | let action = do_approve ? 'approveacc':'unapproveacc'; 262 | const result = await api.transact( 263 | { 264 | actions: 265 | [ 266 | { 267 | account: engineacc, 268 | name: action, 269 | authorization: [{ 270 | actor: adminacc, 271 | permission: 'active'} ], 272 | data: { 273 | accounts: accounts 274 | }, 275 | } 276 | ] 277 | }, 278 | { 279 | blocksBehind: 10, 280 | expireSeconds: 60 281 | } 282 | ); 283 | console.info(action + '{' + accounts.length + '} transaction ID: ', result.transaction_id); 284 | } catch (e) { 285 | console.error('ERROR: ' + e); 286 | } 287 | 288 | } 289 | 290 | 291 | 292 | 293 | /* 294 | Local Variables: 295 | mode: javascript 296 | indent-tabs-mode: nil 297 | End: 298 | */ 299 | -------------------------------------------------------------------------------- /testing/telos_testnet.txt: -------------------------------------------------------------------------------- 1 | # Telos testnet 2 | 3 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutadmin1 EOS5znzpWwmMwWcvCL3YKpBMfoS2jc8Jjo5Dp2XQDw8yLuauh4AHC 4 | 5 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutfees11 EOS5znzpWwmMwWcvCL3YKpBMfoS2jc8Jjo5Dp2XQDw8yLuauh4AHC 6 | 7 | 8 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutpayer1 EOS8BdYSA89k6gvSgm2RM7mySQX4vL1kcW4HAvuLvMy6pVHpUok9L 9 | 10 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutpayer2 EOS8BdYSA89k6gvSgm2RM7mySQX4vL1kcW4HAvuLvMy6pVHpUok9L 11 | 12 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutpayer3 EOS8BdYSA89k6gvSgm2RM7mySQX4vL1kcW4HAvuLvMy6pVHpUok9L 13 | 14 | 15 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutrcpt11 EOS8MBCm1iqESGhkyDFazJoE4XSyeqcVTkCar3yxWMpPJmpEQgUrj 16 | 17 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutrcpt12 EOS8MBCm1iqESGhkyDFazJoE4XSyeqcVTkCar3yxWMpPJmpEQgUrj 18 | 19 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutrcpt13 EOS8MBCm1iqESGhkyDFazJoE4XSyeqcVTkCar3yxWMpPJmpEQgUrj 20 | 21 | tTcleos system newaccount --buy-ram-kbytes 20 --stake-net "1 TLOS" --stake-cpu "1 TLOS" cc32dninexxx payoutrcpt14 EOS8MBCm1iqESGhkyDFazJoE4XSyeqcVTkCar3yxWMpPJmpEQgUrj 22 | 23 | 24 | ######### admin 25 | 26 | tTcleos push action payoutengine setadmin '["payoutadmin1"]' -p payoutadmin1 27 | # error: missing authority of payoutengine 28 | 29 | tTcleos push action payoutengine setfee '["payoutfees11", "5"]' -p payoutadmin1 30 | # error: missing authority of payoutengine 31 | 32 | tTcleos push action payoutengine setadmin '["payoutadmin1"]' -p payoutengine 33 | # ok 34 | 35 | tTcleos push action payoutengine setadmin '["payoutadmin1"]' -p payoutengine 36 | # error: missing authority of payoutadmin1 37 | 38 | tTcleos push action payoutengine setfee '["payoutfees11", "5"]' -p payoutadmin1 39 | # ok 40 | 41 | 42 | tTcleos push action payoutengine approveacc '[["payoutrcpt14"]]' -p payoutadmin1 43 | # error: Account not found in approval list 44 | 45 | tTcleos push action payoutengine unapproveacc '[["payoutrcpt14"]]' -p payoutadmin1 46 | # error: Account not found in approval list 47 | 48 | tTcleos push action payoutengine newschedule '["payoutpayer1", "payer1sc1", "eosio.token", "1.0000 TLOS", "Welcome to the test"]' -p payoutadmin1 49 | # error: missing authority of payoutpayer1 50 | 51 | tTcleos push action payoutengine newschedule '["payoutpayer1", "payer1sc1", "eosio.token", "1.0000 TLOS", "Welcome to the test"]' -p payoutpayer1 52 | # ok 53 | 54 | tTcleos push action payoutengine newschedule '["payoutpayer1", "payer1sc1", "eosio.token", "1.0000 TLOS", "Welcome to the test"]' -p payoutpayer1 55 | # error: A schedule with this name already exists 56 | 57 | 58 | tTcleos push action payoutengine newschedule '["payoutpayer1", "payer1sc2", "eosio.token", "1.00 TLOS", "Welcome to the test"]' -p payoutpayer1 59 | # error: Wrong currency precision 60 | 61 | tTcleos push action payoutengine newschedule '["payoutpayer1", "payer1sc2", "eosio.token", "1.0000 TLOS", "Welcome to the test"]' -p payoutpayer1 62 | # ok 63 | 64 | tTcleos push action payoutengine newschedule '["payoutpayer2", "payer1sc2", "eosio.token", "1.0000 TLOS", "Welcome to the test"]' -p payoutpayer2 65 | # error: A schedule with this name already exists 66 | 67 | tTcleos push action payoutengine newschedule '["payoutpayer2", "payer2sc1", "eosio.token", "1.0000 TLOS", "Welcome to the test"]' -p payoutpayer2 68 | # ok 69 | 70 | tTcleos transfer payoutpayer3 payoutengine "10.0000 TLOS" 71 | # error: There are no schedules with this currency for this payer 72 | 73 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "1.0000 TLOS"], ["payoutrcpt12", "1.0000 TLOS"]]]' -p payoutpayer2 74 | # error: missing authority of payoutpayer1 75 | 76 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "1.0000 TLOS"], ["payoutrcpt12", "1.0000 TLOS"]]]' -p payoutpayer1 77 | # error: Insufficient funds on the deposit 78 | 79 | tTcleos transfer payoutpayer1 payoutengine "10.0000 TLOS" 80 | # ok 81 | # payoutfees11 received 0.0500 TLOS 82 | # payoutengine received 9.9500 TLOS 83 | # payoutpayer1 funds deposited: "9.9500 TLOS" 84 | 85 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "1.0000 TLOS"], ["payoutrcpt12", "1.0000 TLOS"]]]' -p payoutpayer1 86 | # ok 87 | # payoutpayer1 funds: "dues": "2.0000 TLOS", "deposited": "9.9500 TLOS" 88 | 89 | tTcleos transfer payoutpayer1 payoutengine "10.0000 TLOS" 90 | # ok 91 | # payoutfees11 received 0.0500 TLOS 92 | # payoutengine received 9.9500 TLOS 93 | # payoutpayer1 funds "dues": "2.0000 TLOS", "deposited": "19.9000 TLOS" 94 | 95 | tTcleos push action payoutengine runpayouts '[10]' -p payoutpayer2 96 | # ok (no transfers) 97 | # props: lastschedule=payer1sc1 98 | 99 | tTcleos push action payoutengine runpayouts '[10]' -p payoutpayer2 100 | # error: Nothing to do 101 | 102 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "1.0000 TLOS"], ["payoutrcpt12", "1.0000 TLOS"], ["payoutrcptxx", "1.0000 TLOS"]]]' -p payoutpayer1 103 | # error: New total must be bigger than the old total 104 | 105 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "2.0000 TLOS"], ["payoutrcpt12", "2.0000 TLOS"], ["payoutrcptxx", "1.0000 TLOS"]]]' -p payoutpayer1 106 | # error: recipient account does not exist 107 | 108 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "2.0000 TLOS"], ["payoutrcpt12", "2.0000 TLOS"], ["payoutrcpt13", "2.0000 TLOS"]]]' -p payoutpayer1 109 | # ok 110 | # funds: "dues": "6.0000 TLOS", "deposited": "19.9000 TLOS" 111 | # recipients: 3x "booked_total": 20000, "paid_total": 0 112 | # approvals: 3 accounts, approved=0 113 | 114 | 115 | tTcleos push action payoutengine unapproveacc '[["payoutrcpt12"]]' -p payoutadmin1 116 | # error: Nothing to do 117 | 118 | tTcleos push action payoutengine approveacc '[["payoutrcpt12"]]' -p payoutadmin1 119 | # ok 120 | # approvals: 1 acc approved 121 | 122 | 123 | tTcleos push action payoutengine runpayouts '[10]' -p payoutpayer2 124 | # ok 125 | # transfer {"from":"payoutengine","to":"payoutrcpt12","quantity":"2.0000 TLOS","memo":"Welcome to the test"} 126 | # recipients: "account": "payoutrcpt12", "booked_total": 20000, "paid_total": 20000 127 | # funds: "dues": "4.0000 TLOS", "deposited": "17.9000 TLOS" 128 | 129 | tTcleos push action payoutengine runpayouts '[10]' -p payoutpayer2 130 | # error: Nothing to do 131 | 132 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "3.0000 TLOS"], ["payoutrcpt12", "3.0000 TLOS"], ["payoutrcpt13", "20.0000 TLOS"]]]' -p payoutpayer1 133 | # error: Insufficient funds on the deposit 134 | 135 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt12", "3.0000 TLOS"]]]' -p payoutpayer1 136 | # ok 137 | # funds: "dues": "5.0000 TLOS", "deposited": "17.9000 TLOS" 138 | # recipients: "account": "payoutrcpt12", "booked_total": 30000, "paid_total": 20000 139 | 140 | tTcleos push action payoutengine runpayouts '[10]' -p payoutpayer2 141 | # ok 142 | # transfer {"from":"payoutengine","to":"payoutrcpt12","quantity":"1.0000 TLOS","memo":"Welcome to the test"} 143 | # recipients: "account": "payoutrcpt12", "booked_total": 30000, "paid_total": 30000 144 | # funds: "dues": "4.0000 TLOS", "deposited": "16.9000 TLOS" 145 | 146 | 147 | tTcleos push action payoutengine approveacc '[["payoutrcpt11","payoutrcpt12","payoutrcpt13"]]' -p payoutadmin1 148 | # ok 149 | 150 | tTcleos push action payoutengine runpayouts '[10]' -p payoutpayer2 151 | # ok 152 | # transfer {"from":"payoutengine","to":"payoutrcpt11","quantity":"2.0000 TLOS","memo":"Welcome to the test"} 153 | # transfer {"from":"payoutengine","to":"payoutrcpt13","quantity":"2.0000 TLOS","memo":"Welcome to the test"} 154 | # recipienns: all paid 155 | # funds: "dues": "0.0000 TLOS", "deposited": "12.9000 TLOS" 156 | 157 | 158 | # RAM tests with additional token 159 | # quota: 701.4 KiB used: 499.9 KiB 160 | 161 | tTcleos push action payoutengine newschedule '["payoutpayer2", "payer2sc2", "btc.ptokens", "0.0000 PBTC", "PBTC test"]' -p payoutpayer2 162 | # error: Wrong currency precision 163 | 164 | tTcleos push action payoutengine newschedule '["payoutpayer2", "payer2sc2", "btc.ptokens", "0.00000000 PBTC", "PBTC test"]' -p payoutpayer2 165 | # ok 166 | # RAM unchanged 167 | 168 | tTcleos push action btc.ptokens transfer '["payoutpayer2", "payoutengine", "0.01000000 PBTC", "Deposit"]' -p payoutpayer2 169 | # ok 170 | # quota: 701.4 KiB used: 500.3 KiB 171 | 172 | 173 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt11", "3.0000 TLOS"]]]' -p payoutpayer2 174 | # error Invalid currency symbol 175 | 176 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt11", "3.0000 PBTC"]]]' -p payoutpayer2 177 | # error Invalid currency symbol 178 | 179 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt11", "3.00000000 PBTC"]]]' -p payoutpayer2 180 | # error Insufficient funds on the deposit 181 | 182 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt11", "0.00010000 PBTC"]]]' -p payoutpayer2 183 | # ok 184 | 185 | tTcleos push action payoutengine claim '["payer2sc2", "payoutrcpt11"]' -p payoutpayer2 186 | # ok 187 | # transfer {"from":"payoutengine","to":"payoutrcpt11","quantity":"0.00010000 PBTC","memo":"PBTC test"} 188 | # quota: 701.4 KiB used: 500.5 KiB 189 | 190 | tTcleos push action payoutengine claim '["payer2sc2", "payoutrcpt11"]' -p payoutpayer2 191 | # error: All dues are paid already 192 | 193 | 194 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt11", "0.00020000 PBTC"]]]' -p payoutpayer2 195 | # ok 196 | 197 | tTcleos push action payoutengine runpayouts '[10]' -p payoutpayer2 198 | # ok 199 | # transfer {"from":"payoutengine","to":"payoutrcpt11","quantity":"0.00010000 PBTC","memo":"PBTC test"} 200 | 201 | 202 | ### run multiple schedules 203 | 204 | tTcleos transfer payoutpayer2 payoutengine "20.0000 TLOS" 205 | # ok 206 | # funds: "deposited": "19.9000 TLOS", "deposited": "0.00975000 PBTC" 207 | 208 | 209 | tTcleos push action payoutengine book '["payer1sc1", [["payoutrcpt11", "3.0000 TLOS"], ["payoutrcpt12", "4.0000 TLOS"], ["payoutrcpt13", "3.0000 TLOS"]]]' -p payoutpayer1 210 | # ok 211 | 212 | tTcleos push action payoutengine book '["payer1sc2", [["payoutrcpt11", "0.0100 TLOS"], ["payoutrcpt12", "0.0100 TLOS"], ["payoutrcpt13", "0.0100 TLOS"], ["payoutrcpt14", "0.0100 TLOS"]]]' -p payoutpayer1 213 | # ok 214 | 215 | tTcleos push action payoutengine book '["payer2sc1", [["payoutrcpt11", "0.0010 TLOS"], ["payoutrcpt12", "0.0010 TLOS"], ["payoutrcpt13", "0.0010 TLOS"], ["payoutrcpt14", "0.0010 TLOS"]]]' -p payoutpayer2 216 | 217 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt12", "0.00010000 PBTC"], ["payoutrcpt13", "0.00010000 PBTC"], ["payoutrcpt14", "0.00010000 PBTC"]]]' -p payoutpayer2 218 | 219 | tTcleos push action payoutengine runpayouts '[10]' -p payoutrcpt11 220 | # ok 221 | 222 | 223 | tTcleos push action payoutengine bookm '["payer2sc2", [["payoutrcpt12", "0.00031000 PBTC", "hello1"], ["payoutrcpt13", "0.00031000 PBTC", "hello2"], ["payoutrcpt14", "0.00031000 PBTC", "hello3"]]]' -p payoutpayer2 224 | 225 | tTcleos push action payoutengine book '["payer2sc2", [["payoutrcpt12", "0.00040000 PBTC"], ["payoutrcpt13", "0.00040000 PBTC"], ["payoutrcpt14", "0.00040000 PBTC"]]]' -p payoutpayer2 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EOSIO Payout Engine 2 | 3 | Anyone who built automated payments on an EOSIO blockchain knows the 4 | problem well: the payments may fail for a number of reasons, such as 5 | insufficient CPU or RAM on sender account, or the transaction gets 6 | dropped by a microfork. 7 | 8 | The Payout Engine solves this problem. Whenever there's a failure, it 9 | guarantees to retry and never double-spend. 10 | 11 | The engine is available for any project that needs to automate 12 | payments, such as e-commerce, dividends, salary, etc. 13 | 14 | The engine consists of the following components: 15 | 16 | * The payout smart contract that registers the bookings and keeps 17 | track of outgoing payments; 18 | 19 | * The server-side daemon does a number of background jobs: it verifies 20 | if a recipient runs a smart contract and blocks the transfers; 21 | checks if new payments are due, and executes the payouts; 22 | 23 | * The payment management script on the payer side: it compares some 24 | internal database with booked payments, and updates the smart 25 | contract when necessary. 26 | 27 | 28 | ## Roles and definitions 29 | 30 | The following roles are defined for blockchain accounts: 31 | 32 | * admin: it can approve or disapprove recipient accounts. This is 33 | needed in order to avoid paying to smart contracts which may reject 34 | the payment or abuse our CPU resource. 35 | 36 | * fees collector: whenever the payer transfers a deposit to the payout 37 | contract, 0.5% of the deposit amount is sent automatically to the 38 | fee collection account. This money is used to support the hosting 39 | and development of the engine. 40 | 41 | * payer: whenever wants to distribute tokens through the engine, is 42 | registering themselves as a payer account with one or several 43 | schedules. 44 | 45 | * recipient: the account that needs to receive payments from the 46 | payer. The system allows any number of recipients. 47 | 48 | 49 | A *schedule* is an object that defines a relationship between a payer 50 | and recipients. It has the following attributes: 51 | 52 | * Payer account: this account is allowed to deposit funds into the 53 | contract and book the outgoing payments. 54 | 55 | * Schedule name: a string of up to 12 symbols, consisting of letters 56 | a-z, numbers 1-5 and dots. The only constraint is that the names 57 | should be unique. 58 | 59 | * Token contract and currency: all payments within a schedule are made 60 | in one token that is defined when the schedule is created. 61 | 62 | * Memo string: this string will be used as transfer memo in outgoing 63 | payments. This string cannot be changed after the schedule is 64 | created. 65 | 66 | 67 | ## Engine workflow 68 | 69 | 1. Payer creates a schedule by executing the `newschedule` action, 70 | specifying the name, currency, and memo string. A payer can create 71 | multiple schedules in different or the same currency. 72 | 73 | 2. Payer places a deposit in the same currency as specified in the 74 | schedule. The contract deducts automatically 0.5% fee from it. 75 | 76 | 3. Payer books the outgoing payments by calling the `book` action. The 77 | action takes the schedule name and a list of (recipient, newtotal) 78 | tuples. Recipient should be a valid blockchain account, and newtotal 79 | is the total amount of tokens that the receiver should get in the 80 | whole history. So, it's an always growing number for the same 81 | recipient. The booking cannot exceed the deposited funds. If the payer 82 | needs to book more, a new deposit is required. 83 | 84 | 4. The admin process checks if the new recipients run any smart 85 | contracts, and approves them if they don't run any. The ones 86 | unapproved can claim the payments when needed. 87 | 88 | 5. The admin process checks all due amounts and sends out payments to 89 | the recipients. The smart contract registers all amounts it sends, so 90 | only the difference between newtotal and total paid amount is sent 91 | out. 92 | 93 | Whenever there's a transaction failure, the payer will book again with 94 | the same or updated newtotal. Also the admin script will retry if its 95 | transaction fails. 96 | 97 | The automatic payouts are performed in round-robin sequence: one due 98 | payment from a schedule is sent, then the next schedule is 99 | selected. This guarantees that a massive schedule does not block the 100 | less frequent ones. 101 | 102 | The payer's RAM quota is charged for creating all memory structures 103 | related to the schedule. Currently the contract allocates the RAM for 104 | recipient token balances if the recipient didn't have such token, but 105 | it will change in the future, and the payer will be charged for such 106 | expenses. 107 | 108 | 109 | ## Production deployment 110 | 111 | The account `payoutengine` is deployed on the following EOSIO blockchains: 112 | 113 | * EOS Mainnet 114 | 115 | * Telos Mainnet 116 | 117 | * WAX Mainnet 118 | 119 | * Jungle Testnet 120 | 121 | * Telos Testnet 122 | 123 | * WAX Testnet 124 | 125 | 126 | For the time being, the contract is managed by `cc32dninexxx` 127 | account. Later on, the management will be transferred to a multisig 128 | among well-known organizations. 129 | 130 | 131 | ## Smart contract actions and tables 132 | 133 | Only actions and tables for user calling are listed below. 134 | 135 | 136 | ### action: `newschedule` 137 | 138 | The action is called once to initiate a schedule. The payer is charged for RAM. 139 | 140 | ``` 141 | ACTION newschedule(name payer, name schedule_name, name tkcontract, asset currency, string memo) 142 | ``` 143 | 144 | * `payer`: the account that creates a new schedule, and this account will be a designated payer for it. 145 | 146 | * `schedule_name`: a unique name for the schedule. Letters `a-z`, 147 | numbers `1-5` and dots (`.`) are allowed, up to 12 characters. 148 | 149 | * `tkcontract`: token contract (e.g. `eosio.token` for the system 150 | token). 151 | 152 | * `currency`: token symbol and precision (e.g. `0.0000 EOS`). 153 | 154 | * `memo`: a string up to 256 characters long that will be used as 155 | default memo string in outbound token transfers. 156 | 157 | 158 | ### transfer handler 159 | 160 | Normal token transfers are accepted by contract. it only accepts them 161 | from payers which have registered schedules, and only in the token 162 | currency that is specified in a schedule. The deposited token can be 163 | spent by calling `book` or `bookm` actions. 164 | 165 | 166 | ### action: `book` 167 | 168 | The action books new due amounts toward recipients for a specific 169 | schedule. Only the payer registered with the schedule is allowed to 170 | execute it. At least one amount should be higher than a previously 171 | booked total for a given recipient. It is recommended to look up 172 | `booked_total` in `recipients` table for a particular recipient, and 173 | only send the `book` action if the new due amount is higher than 174 | `booked_total`. 175 | 176 | ``` 177 | struct book_record { 178 | name recipient; 179 | asset new_total; 180 | }; 181 | 182 | ACTION book(name schedule_name, vector records) 183 | ``` 184 | 185 | * `schedule_name`: a schedule name previously registered with 186 | `newschedule`. 187 | 188 | * `records`: a vector of structures with the following fields: 189 | 190 | * `recipient`: a valid EOSIO account that will receive the payment; 191 | 192 | * `new_total`: total amount of token that the recipient should 193 | receive throughout its lifetime (so this amount should only grow 194 | with each action call). 195 | 196 | 197 | ### action: `bookm` 198 | 199 | The action is similar to `book`, but the structures have an additional 200 | field, `memo`, which specifies the message that will be used in 201 | outgoing transfer for this recipient. 202 | 203 | ``` 204 | struct bookm_record { 205 | name recipient; 206 | asset new_total; 207 | string memo; 208 | }; 209 | 210 | ACTION bookm(name schedule_name, vector records) 211 | ``` 212 | 213 | 214 | ### action: `claim` 215 | 216 | Accounts that have smart contracts are by default blocked from 217 | automatic outgoing payments. Such recipients can initiate the transfer 218 | by calling the `claim` action. There is no authorization, so any 219 | account can call it to start the transfer for another recipient. 220 | 221 | Recipients can also call this to speed up the outgoing transfers if 222 | the automatic dispatcher is too busy. 223 | 224 | ``` 225 | ACTION claim(name schedule_name, name recipient) 226 | ``` 227 | 228 | * `schedule_name`: a valid schedule name; 229 | 230 | * `recipient`: the recipient account that will receive a payment if 231 | there is an outstanding due amount, 232 | 233 | 234 | ### table: `recipients` 235 | 236 | This table is the only one that payer needs to look up in order to 237 | optimize the bookings. The scope is set to the schedule name, and 238 | recipient name is the primary index. `booked_total` is an integer, so 239 | it's the currency amount multiplied by a power of 10 corresponding to 240 | the token precision. 241 | 242 | Once the payer recognizes that `booked_total` is lower than the total 243 | due amount for a recipient, it's time to execute the `book` action. 244 | 245 | ``` 246 | // payment recipients, scope=scheme_name 247 | struct [[eosio::table("recipients")]] recipient { 248 | name account; 249 | uint64_t booked_total; // asset amount 250 | uint64_t paid_total; 251 | 252 | auto primary_key()const { return account.value; } 253 | // index for iterating through open dues 254 | uint64_t by_dues()const { return (booked_total > paid_total) ? account.value:0; } 255 | }; 256 | 257 | typedef eosio::multi_index< 258 | name("recipients"), recipient, 259 | indexed_by> 260 | > recipients; 261 | ``` 262 | 263 | ### table: `funds` 264 | 265 | This table is useful for monitoring the remaining token balance for a 266 | payer. Scope is set to the payer account, and the primary key is a 267 | running integer. 268 | 269 | `deposited` indicates the available funds. It's decreased every time 270 | an outgoing payment is made from corresponding payer and in 271 | corresponding currency. 272 | 273 | `dues` indicates the amount that is booked to be sent out to 274 | recipients. 275 | 276 | ``` 277 | // assets belonging to the payer. scope=payer 278 | struct [[eosio::table("funds")]] fundsrow { 279 | uint64_t id; 280 | name tkcontract; 281 | asset currency; 282 | asset dues; // how much we need to send to recipients 283 | asset deposited; // how much we can spend 284 | 285 | auto primary_key()const { return id; } 286 | uint128_t by_token()const { return token_index_val(tkcontract, currency); } 287 | }; 288 | 289 | typedef eosio::multi_index< 290 | name("funds"), fundsrow, 291 | indexed_by> 292 | > funds; 293 | ``` 294 | 295 | 296 | ## Payer daemon script 297 | 298 | The folder `payer_daemon` in Git repository contains a Nodejs script 299 | that can be used for automating the outgoing payments. It checks the 300 | PAYMENTS table periodically and compares if the sum of payments for 301 | each account is larger than the booked amount, and then books the 302 | corresponding amounts at the payer engine. 303 | 304 | See [the PONY token example](examples/pony/) that demonstrates the 305 | payer script on telos testnet. 306 | 307 | 308 | 309 | 310 | 311 | 312 | ## Copyright and License 313 | 314 | Copyright 2020 cc32d9@gmail.com 315 | 316 | Licensed under the Apache License, Version 2.0 (the "License"); 317 | you may not use this file except in compliance with the License. 318 | You may obtain a copy of the License at 319 | 320 | http://www.apache.org/licenses/LICENSE-2.0 321 | 322 | Unless required by applicable law or agreed to in writing, software 323 | distributed under the License is distributed on an "AS IS" BASIS, 324 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 325 | See the License for the specific language governing permissions and 326 | limitations under the License. 327 | 328 | 329 | ## Donations and paid service 330 | 331 | ETH address: `0x7137bfe007B15F05d3BF7819d28419EAFCD6501E` 332 | 333 | EOS account: `cc32dninexxx` 334 | -------------------------------------------------------------------------------- /payer_daemon/payer_daemon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | Copyright 2020 cc32d9@gmail.com 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | 20 | const config = require('config'); 21 | const fetch = require('node-fetch'); 22 | const mariadb = require('mariadb'); 23 | 24 | const { Api, JsonRpc, RpcError } = require('eosjs'); 25 | const { JsSignatureProvider } = require('eosjs/dist/eosjs-jssig'); 26 | const { TextEncoder, TextDecoder } = require('util'); 27 | 28 | 29 | 30 | const url = config.get('url'); 31 | const engineacc = config.get('engineacc'); 32 | const payeracc = config.get('payeracc'); 33 | const payerkey = config.get('payerkey'); 34 | 35 | const book_batch_size = config.get('book_batch_size'); 36 | 37 | const schedule_name = config.get('schedule.name'); 38 | const currency = config.get('schedule.currency'); 39 | const currency_precision = config.get('schedule.currency_precision'); 40 | const currency_multiplier = 10**currency_precision; 41 | 42 | const pool = mariadb.createPool(config.get('mariadb_server')); 43 | 44 | const timer_keepalive = config.get('timer.keepalive'); // this one prints the keepalive message to console 45 | const timer_check_accounts = config.get('timer.check_accounts'); // this one checks if new EOSIO accounts exist on chain 46 | const timer_fullscan = config.get('timer.fullscan'); // this one scans all recipients 47 | const timer_partialscan = config.get('timer.partialscan'); // this one scans only recipients with new payments 48 | 49 | 50 | const sigProvider = new JsSignatureProvider([payerkey]); 51 | const rpc = new JsonRpc(url, { fetch }); 52 | const api = new Api({rpc: rpc, signatureProvider: sigProvider, 53 | textDecoder: new TextDecoder(), textEncoder: new TextEncoder()}); 54 | 55 | console.log("Started payer daemon using " + url); 56 | console.log("Schedule name: " + schedule_name); 57 | 58 | var full_scan_running = false; 59 | var latest_payment_timestamp = 0; 60 | var book_batch = new Array(); 61 | 62 | setInterval(keepaalive, timer_keepalive); 63 | setTimeout(check_accounts, timer_check_accounts); 64 | setTimeout(partialscan, timer_partialscan); 65 | 66 | fullscan(); 67 | 68 | 69 | async function keepaalive() { 70 | console.log('keepalive'); 71 | } 72 | 73 | 74 | async function fullscan() { 75 | full_scan_running = true; 76 | // console.log("fullscan started"); 77 | 78 | let booked_totals = new Map(); 79 | let counter = 0; 80 | let conn; 81 | 82 | try { 83 | await fetch_booked_totals(booked_totals); 84 | 85 | conn = await pool.getConnection(); 86 | conn.queryStream 87 | ("SELECT PAYMENTS.recipient AS recipient, SUM(amount) AS due, UNIX_TIMESTAMP(MAX(tstamp)) AS ts, memo " + 88 | "FROM PAYMENTS " + 89 | "INNER JOIN VALID_ACCOUNTS ON PAYMENTS.recipient=VALID_ACCOUNTS.account " + 90 | "LEFT JOIN PAYMENT_MEMO ON PAYMENTS.recipient=PAYMENT_MEMO.recipient " + 91 | "GROUP BY recipient") 92 | .on("error", err => { 93 | console.error(err); 94 | }) 95 | .on("data", row => { 96 | if( ! booked_totals.has(row.recipient) || 97 | row.due > booked_totals.get(row.recipient) ) { 98 | 99 | counter++; 100 | 101 | if( row.ts > latest_payment_timestamp ) { 102 | latest_payment_timestamp = row.ts; 103 | } 104 | 105 | if( row.memo === null ) { 106 | row.memo = ''; 107 | } 108 | 109 | push_book_batch(row.recipient, row.due, row.memo); 110 | } 111 | }) 112 | .on("end", () => { 113 | if( book_batch.length > 0 ) { 114 | send_book_tx(book_batch.slice()); 115 | book_batch = new Array(); 116 | } 117 | if( counter > 0 ) { 118 | console.log("fullscan: sent " + counter + " bookings"); 119 | } 120 | }); 121 | } catch (err) { 122 | console.error(err); 123 | } finally { 124 | if (conn) conn.release(); //release to pool 125 | } 126 | 127 | full_scan_running = false; 128 | setTimeout(fullscan, timer_fullscan); 129 | } 130 | 131 | 132 | 133 | async function partialscan() { 134 | if( !full_scan_running && latest_payment_timestamp > 0 ) { 135 | 136 | let counter = 0; 137 | let conn; 138 | 139 | try { 140 | conn = await pool.getConnection(); 141 | conn.queryStream 142 | ("SELECT PAYMENTS.recipient AS recipient, SUM(amount) AS due, UNIX_TIMESTAMP(MAX(tstamp)) AS ts, memo " + 143 | "FROM PAYMENTS " + 144 | "INNER JOIN (SELECT DISTINCT recipient AS recipient FROM PAYMENTS " + 145 | " WHERE tstamp > FROM_UNIXTIME(" + latest_payment_timestamp + ")) X ON PAYMENTS.recipient=X.recipient " + 146 | "INNER JOIN VALID_ACCOUNTS ON PAYMENTS.recipient=VALID_ACCOUNTS.account " + 147 | "LEFT JOIN PAYMENT_MEMO ON PAYMENTS.recipient=PAYMENT_MEMO.recipient " + 148 | "GROUP BY recipient") 149 | .on("error", err => { 150 | console.error(err); 151 | }) 152 | .on("data", row => { 153 | counter++; 154 | if( row.ts > latest_payment_timestamp ) { 155 | latest_payment_timestamp = row.ts; 156 | } 157 | 158 | if( row.memo === null ) { 159 | row.memo = ''; 160 | } 161 | 162 | check_and_book({recipient: row.recipient, 163 | due: parseFloat(row.due), 164 | memo: row.memo}); 165 | }) 166 | .on("end", () => { 167 | if( book_batch.length > 0 ) { 168 | send_book_tx(book_batch.slice()); 169 | book_batch = new Array(); 170 | } 171 | if( counter > 0 ) { 172 | console.log("partialscan: found " + counter + " new payments"); 173 | } 174 | }); 175 | } catch (err) { 176 | console.error(err); 177 | } finally { 178 | if (conn) conn.release(); //release to pool 179 | } 180 | } 181 | 182 | setTimeout(partialscan, timer_partialscan); 183 | } 184 | 185 | 186 | async function check_accounts() { 187 | // console.log("check_accounts started"); 188 | let conn; 189 | try { 190 | let counter = 0; 191 | conn = await pool.getConnection(); 192 | let rows = await conn.query 193 | ("SELECT DISTINCT recipient AS recipient " + 194 | "FROM PAYMENTS " + 195 | "LEFT JOIN VALID_ACCOUNTS ON PAYMENTS.recipient=VALID_ACCOUNTS.account " + 196 | "WHERE VALID_ACCOUNTS.account IS NULL"); 197 | 198 | for( row of rows ) { 199 | if( await check_if_valid_account(row.recipient) ) { 200 | await conn.query("INSERT INTO VALID_ACCOUNTS (account) VALUES (?)", [row.recipient]); 201 | counter++; 202 | } 203 | else { 204 | console.log("check_accounts: " + row.recipient + " is not a valid account"); 205 | } 206 | } 207 | 208 | if( rows.length > 0 && counter > 0 ) { 209 | console.log("check_accounts: found " + counter + " new valid accounts out of " + rows.length); 210 | } 211 | } catch (err) { 212 | console.error(err); 213 | } finally { 214 | if (conn) conn.release(); //release to pool 215 | } 216 | 217 | setTimeout(check_accounts, timer_check_accounts); 218 | } 219 | 220 | 221 | 222 | 223 | function push_book_batch(recipient, due, memo) { 224 | book_batch.push({recipient: recipient, 225 | new_total: parseFloat(due).toFixed(currency_precision) + " " + currency, 226 | memo: memo}); 227 | if( book_batch.length >= book_batch_size ) { 228 | send_book_tx(book_batch.slice()); 229 | book_batch = new Array(); 230 | } 231 | } 232 | 233 | 234 | 235 | async function fetch_booked_totals(totals_map, nextkey='0') { 236 | let response = await fetch(url + '/v1/chain/get_table_rows', { 237 | method: 'post', 238 | body: JSON.stringify({ 239 | json: 'true', 240 | code: engineacc, 241 | scope: schedule_name, 242 | table: 'recipients', 243 | key_type: 'i64', 244 | lower_bound: nextkey 245 | }), 246 | headers: { 'Content-Type': 'application/json' }, 247 | }); 248 | 249 | let data = await response.json(); 250 | if( data.rows ) { 251 | data.rows.forEach(function(row) { 252 | let amount = parseInt(row.booked_total)/currency_multiplier; 253 | totals_map.set(row.account, amount.toFixed(currency_precision)); 254 | }); 255 | } 256 | 257 | if( data.more ) { 258 | return fetch_booked_totals(totals_map, data.next_key); 259 | } 260 | 261 | // console.log("fetch_booked_totals: retrieved " + totals_map.size + " balances"); 262 | return; 263 | } 264 | 265 | 266 | async function check_and_book(row) { 267 | let response = await fetch(url + '/v1/chain/get_table_rows', { 268 | method: 'post', 269 | body: JSON.stringify({ 270 | json: 'true', 271 | code: engineacc, 272 | scope: schedule_name, 273 | table: 'recipients', 274 | key_type: 'name', 275 | lower_bound: row.recipient, 276 | limit: 1 277 | }), 278 | headers: { 'Content-Type': 'application/json' }, 279 | }); 280 | 281 | let data = await response.json(); 282 | let amount = 0; 283 | if( data.rows.length > 0 ) { 284 | amount = parseInt(data.rows[0].booked_total)/currency_multiplier; 285 | } 286 | 287 | if( row.due > amount ) { 288 | push_book_batch(row.recipient, row.due, row.memo); 289 | } 290 | } 291 | 292 | 293 | async function send_book_tx(rows) { 294 | try { 295 | rows.forEach(function(row) { 296 | console.log("booking " + row.new_total + " for " + row.recipient + ", memo: " + row.memo); 297 | }); 298 | 299 | const result = await api.transact( 300 | { 301 | actions: 302 | [ 303 | { 304 | account: engineacc, 305 | name: 'bookm', 306 | authorization: [{ 307 | actor: payeracc, 308 | permission: 'active'} ], 309 | data: { 310 | schedule_name: schedule_name, 311 | records: rows 312 | }, 313 | } 314 | ] 315 | }, 316 | { 317 | blocksBehind: 10, 318 | expireSeconds: 60 319 | } 320 | ); 321 | console.info("bookm transaction ID: " + result.transaction_id); 322 | } catch (e) { 323 | console.error('ERROR: ' + e); 324 | } 325 | } 326 | 327 | 328 | async function check_if_valid_account(acc) { 329 | let response = await fetch(url + '/v1/chain/get_account', { 330 | method: 'post', 331 | body: JSON.stringify({ 332 | account_name: acc, 333 | }), 334 | headers: { 'Content-Type': 'application/json' }, 335 | }); 336 | 337 | return response.ok ? true:false; 338 | } 339 | 340 | 341 | 342 | /* 343 | Local Variables: 344 | mode: javascript 345 | indent-tabs-mode: nil 346 | End: 347 | */ 348 | -------------------------------------------------------------------------------- /contracts/payout.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 cc32d9@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #include 24 | 25 | 26 | using namespace eosio; 27 | using std::string; 28 | using std::vector; 29 | 30 | CONTRACT payout : public eosio::contract { 31 | public: 32 | 33 | const uint16_t MAX_WALK = 30; // maximum walk through unapproved accounts 34 | 35 | payout( name self, name code, datastream ds ): 36 | contract(self, code, ds) 37 | {} 38 | 39 | // sets the admin account 40 | ACTION setadmin(name newadmin) 41 | { 42 | req_admin(); 43 | check(is_account(newadmin), "admin account does not exist"); 44 | set_name_prop(name("adminacc"), newadmin); 45 | } 46 | 47 | // the account will receive the fee from every incoming deposit 48 | ACTION setfee(name feeacc, uint16_t permille) 49 | { 50 | req_admin(); 51 | check(is_account(feeacc), "fee account does not exist"); 52 | check(permille < 1000, "permille must be less than 1000"); 53 | set_name_prop(name("feeacc"), feeacc); 54 | set_uint_prop(name("feepermille"), permille); 55 | } 56 | 57 | 58 | // new payout recipients need to be approved for automatic payments 59 | ACTION approveacc(vector accounts) 60 | { 61 | req_admin(); 62 | bool done_something = false; 63 | approvals appr(_self, 0); 64 | for( name& acc: accounts ) { 65 | auto itr = appr.find(acc.value); 66 | check(itr != appr.end(), "Account not found in approval list"); 67 | if( !itr->approved ) { 68 | appr.modify( *itr, same_payer, [&]( auto& row ) { 69 | row.approved = 1; 70 | }); 71 | done_something = true; 72 | } 73 | } 74 | check(done_something, "Nothing to do"); 75 | inc_revision(); 76 | } 77 | 78 | 79 | ACTION unapproveacc(vector accounts) 80 | { 81 | req_admin(); 82 | bool done_something = false; 83 | approvals appr(_self, 0); 84 | for( name& acc: accounts ) { 85 | auto itr = appr.find(acc.value); 86 | check(itr != appr.end(), "Account not found in approval list"); 87 | if( itr->approved ) { 88 | appr.modify( *itr, same_payer, [&]( auto& row ) { 89 | row.approved = 0; 90 | }); 91 | done_something = true; 92 | } 93 | } 94 | check(done_something, "Nothing to do"); 95 | inc_revision(); 96 | } 97 | 98 | 99 | ACTION newschedule(name payer, name schedule_name, name tkcontract, asset currency, string memo) 100 | { 101 | require_auth(payer); 102 | 103 | check(schedule_name != name(""), "Schedule name cannot be empty"); 104 | schedules sched(_self, 0); 105 | auto scitr = sched.find(schedule_name.value); 106 | check(scitr == sched.end(), "A schedule with this name already exists"); 107 | check(is_account(tkcontract), "Token contract account does not exist"); 108 | check(currency.is_valid(), "invalid currency" ); 109 | 110 | check(memo.size() <= 256, "memo has more than 256 bytes"); 111 | 112 | // validate that such token exists 113 | { 114 | stats_table statstbl(tkcontract, currency.symbol.code().raw()); 115 | auto statsitr = statstbl.find(currency.symbol.code().raw()); 116 | check(statsitr != statstbl.end(), "This currency symbol does not exist"); 117 | check(statsitr->supply.symbol.precision() == currency.symbol.precision(), "Wrong currency precision"); 118 | } 119 | 120 | currency.amount = 0; 121 | 122 | funds fnd(_self, payer.value); 123 | auto fndidx = fnd.get_index(); 124 | auto fnditr = fndidx.find(token_index_val(tkcontract, currency)); 125 | if( fnditr == fndidx.end() ) { 126 | // register a new currency in payer funds 127 | fnd.emplace(payer, [&]( auto& row ) { 128 | row.id = fnd.available_primary_key(); 129 | row.tkcontract = tkcontract; 130 | row.currency = currency; 131 | row.dues = currency; 132 | row.deposited = currency; 133 | }); 134 | } 135 | 136 | sched.emplace(payer, [&]( auto& row ) { 137 | row.schedule_name = schedule_name; 138 | row.payer = payer; 139 | row.tkcontract = tkcontract; 140 | row.currency = currency; 141 | row.memo = memo; 142 | row.dues = currency; 143 | row.last_processed.value = 1; // the dues index position 144 | }); 145 | inc_revision(); 146 | } 147 | 148 | 149 | // record new totals for recipients 150 | 151 | struct book_record { 152 | name recipient; 153 | asset new_total; 154 | }; 155 | 156 | ACTION book(name schedule_name, vector records) 157 | { 158 | schedules sched(_self, 0); 159 | auto scitr = sched.find(schedule_name.value); 160 | check(scitr != sched.end(), "Cannot find the schedule"); 161 | 162 | name payer = scitr->payer; 163 | require_auth(payer); 164 | 165 | asset new_dues = scitr->currency; 166 | approvals appr(_self, 0); 167 | 168 | bool done_something = false; 169 | recipients rcpts(_self, schedule_name.value); 170 | 171 | for( auto& rec: records ) { 172 | 173 | check(rec.new_total.symbol == scitr->currency.symbol, "Invalid currency symbol"); 174 | auto rcitr = rcpts.find(rec.recipient.value); 175 | 176 | if( rcitr == rcpts.end() ) { 177 | // register a new recipient 178 | check(is_account(rec.recipient), "recipient account does not exist"); 179 | new_dues += rec.new_total; 180 | rcpts.emplace(payer, [&]( auto& row ) { 181 | row.account = rec.recipient; 182 | row.booked_total = rec.new_total.amount; 183 | row.paid_total = 0; 184 | }); 185 | 186 | // new recipients go to unapproved list unless approved in another schedule 187 | if( appr.find(rec.recipient.value) == appr.end() ) { 188 | appr.emplace(payer, [&]( auto& row ) { 189 | row.account = rec.recipient; 190 | row.approved = 0; 191 | }); 192 | } 193 | 194 | done_something = true; 195 | } 196 | else if( rec.new_total.amount > rcitr->booked_total ) { 197 | // update an existing recipient if the new_total is higher than before 198 | new_dues.amount += (rec.new_total.amount - rcitr->booked_total); 199 | rcpts.modify( *rcitr, same_payer, [&]( auto& row ) { 200 | row.booked_total = rec.new_total.amount; 201 | }); 202 | done_something = true; 203 | } 204 | } 205 | 206 | check(done_something, "No totals were updated"); 207 | 208 | funds fnd(_self, payer.value); 209 | auto fndidx = fnd.get_index(); 210 | auto fnditr = fndidx.find(token_index_val(scitr->tkcontract, scitr->currency)); 211 | check(fnditr != fndidx.end(), "This must never happen 1"); 212 | 213 | check(fnditr->deposited >= fnditr->dues + new_dues, "Insufficient funds on the deposit"); 214 | 215 | sched.modify( *scitr, same_payer, [&]( auto& row ) { 216 | row.dues += new_dues; 217 | }); 218 | 219 | fnd.modify( *fnditr, same_payer, [&]( auto& row ) { 220 | row.dues += new_dues; 221 | }); 222 | inc_revision(); 223 | } 224 | 225 | 226 | struct bookm_record { 227 | name recipient; 228 | asset new_total; 229 | string memo; 230 | }; 231 | 232 | ACTION bookm(name schedule_name, vector records) 233 | { 234 | vector newrecords; 235 | for( auto& rec: records ) { 236 | newrecords.push_back({.recipient = rec.recipient, .new_total = rec.new_total}); 237 | } 238 | 239 | book(schedule_name, newrecords); 240 | 241 | schedules sched(_self, 0); 242 | auto scitr = sched.find(schedule_name.value); 243 | check(scitr != sched.end(), "This must never happen 5"); 244 | name payer = scitr->payer; 245 | 246 | memos memtbl(_self, schedule_name.value); 247 | for( auto& rec: records ) { 248 | check(rec.memo.size() <= 256, "memo has more than 256 bytes"); 249 | auto memitr = memtbl.find(rec.recipient.value); 250 | if( memitr == memtbl.end() ) { 251 | memtbl.emplace(payer, [&]( auto& row ) { 252 | row.account = rec.recipient; 253 | row.memo = rec.memo; 254 | }); 255 | } 256 | else if( memitr->memo != rec.memo ) { 257 | memtbl.modify( *memitr, payer, [&]( auto& row ) { 258 | row.memo = rec.memo; 259 | }); 260 | } 261 | } 262 | } 263 | 264 | 265 | // recipient can claim the transfer instead of waiting for automatic job (no authorization needed) 266 | ACTION claim(name schedule_name, name recipient) 267 | { 268 | schedules sched(_self, 0); 269 | auto scitr = sched.find(schedule_name.value); 270 | check(scitr != sched.end(), "Cannot find the schedule"); 271 | 272 | recipients rcpts(_self, schedule_name.value); 273 | auto rcitr = rcpts.find(recipient.value); 274 | check(rcitr != rcpts.end(), "Cannot find this recipient in the schedule"); 275 | check(rcitr->booked_total > rcitr->paid_total, "All dues are paid already"); 276 | 277 | _pay_due(schedule_name, recipient); 278 | } 279 | 280 | 281 | // payout job that serves up to so many payments (no authorization needed) 282 | ACTION runpayouts(uint16_t count) 283 | { 284 | bool done_something = false; 285 | approvals appr(_self, 0); 286 | 287 | uint16_t sched_loopcount = 0; 288 | bool paid_something_in_last_loop = false; 289 | 290 | while( count-- > 0 ) { 291 | name last_schedule = get_name_prop(name("lastschedule")); 292 | name old_last_schedule = last_schedule; 293 | last_schedule.value++; 294 | 295 | schedules sched(_self, 0); 296 | auto schedidx = sched.get_index(); 297 | auto scitr = schedidx.lower_bound(last_schedule.value); 298 | if( scitr == schedidx.end() ) { 299 | // we're at the end of active schedules 300 | last_schedule.value = 0; 301 | if( sched_loopcount > 1 && !paid_something_in_last_loop ) { 302 | // nothing to do more, abort the loop 303 | count = 0; 304 | } 305 | sched_loopcount++; 306 | } 307 | else { 308 | paid_something_in_last_loop = false; 309 | name schedule_name = scitr->schedule_name; 310 | last_schedule = schedule_name; 311 | uint64_t last_processed = scitr->last_processed.value; 312 | uint64_t old_last_processed = last_processed; 313 | recipients rcpts(_self, schedule_name.value); 314 | auto rcdidx = rcpts.get_index(); 315 | auto rcitr = rcdidx.lower_bound(last_processed); 316 | 317 | uint16_t walk_limit = MAX_WALK; 318 | bool will_pay = false; 319 | name pay_to; 320 | 321 | while( !will_pay && walk_limit-- > 0 ) { 322 | if( rcitr == rcdidx.end() ) { 323 | // end of recpients list 324 | last_processed = 1; 325 | walk_limit = 0; //abort the loop 326 | } 327 | else { 328 | last_processed = rcitr->account.value; 329 | auto apritr = appr.find(rcitr->account.value); 330 | check(apritr != appr.end(), "This should never happen 2"); 331 | if( apritr->approved ) { 332 | will_pay = true; 333 | pay_to = rcitr->account; 334 | } 335 | else { 336 | // this is an unapproved account, skip to the next 337 | rcitr++; 338 | } 339 | } 340 | } 341 | 342 | if( last_processed != old_last_processed ) { 343 | sched.modify( *scitr, same_payer, [&]( auto& row ) { 344 | row.last_processed.value = last_processed; 345 | }); 346 | done_something = true; 347 | } 348 | 349 | // _pay_due modifies the same table outside of this index, so we execute it last. 350 | if( will_pay ) { 351 | _pay_due(schedule_name, pay_to); 352 | paid_something_in_last_loop = true; 353 | } 354 | } 355 | 356 | if( last_schedule != old_last_schedule ) { 357 | set_name_prop(name("lastschedule"), last_schedule); 358 | done_something = true; 359 | } 360 | } 361 | 362 | check(done_something, "Nothing to do"); 363 | } 364 | 365 | [[eosio::on_notify("*::transfer")]] void on_payment (name from, name to, asset quantity, string memo) { 366 | if(to == _self) { 367 | funds fnd(_self, from.value); 368 | auto fndidx = fnd.get_index(); 369 | auto fnditr = fndidx.find(token_index_val(name{get_first_receiver()}, quantity)); 370 | check(fnditr != fndidx.end(), "There are no schedules with this currency for this payer"); 371 | check(quantity.amount > 0, "expected a positive amount"); 372 | 373 | uint64_t feepermille = get_uint_prop(name("feepermille")); 374 | name feeacc = get_name_prop(name("feeacc")); 375 | 376 | if( feepermille > 0 && feeacc != name("") ) { 377 | asset fee = quantity * feepermille / 1000; 378 | quantity -= fee; 379 | extended_asset xfee(fee, fnditr->tkcontract); 380 | send_payment(feeacc, xfee, "fees"); 381 | } 382 | 383 | fnd.modify( *fnditr, same_payer, [&]( auto& row ) { 384 | row.deposited += quantity; 385 | }); 386 | inc_revision(); 387 | } 388 | } 389 | /* 390 | // for testing purposes only! 391 | ACTION wipeall(uint16_t count) 392 | { 393 | require_auth(_self); 394 | 395 | schedules sched(_self, 0); 396 | auto scitr = sched.begin(); 397 | 398 | while( scitr != sched.end() && count-- > 0 ) { 399 | 400 | funds fnd(_self, scitr->payer.value); 401 | auto fnditr = fnd.begin(); 402 | while( fnditr != fnd.end() ) { 403 | fnditr = fnd.erase(fnditr); 404 | } 405 | 406 | recipients rcpts(_self, scitr->schedule_name.value); 407 | auto rcitr = rcpts.begin(); 408 | while( rcitr != rcpts.end() ) { 409 | rcitr = rcpts.erase(rcitr); 410 | } 411 | 412 | scitr = sched.erase(scitr); 413 | } 414 | 415 | { 416 | approvals appr(_self, 0); 417 | auto itr = appr.begin(); 418 | while( itr != appr.end() ) { 419 | itr = appr.erase(itr); 420 | } 421 | } 422 | 423 | { 424 | props p(_self, 0); 425 | auto itr = p.begin(); 426 | while( itr != p.end() ) { 427 | itr = p.erase(itr); 428 | } 429 | } 430 | } 431 | */ 432 | private: 433 | 434 | // properties table for keeping contract settings 435 | 436 | struct [[eosio::table("props")]] prop { 437 | name key; 438 | name val_name; 439 | uint64_t val_uint = 0; 440 | auto primary_key()const { return key.value; } 441 | }; 442 | 443 | typedef eosio::multi_index< 444 | name("props"), prop> props; 445 | 446 | void set_name_prop(name key, name value) 447 | { 448 | props p(_self, 0); 449 | auto itr = p.find(key.value); 450 | if( itr != p.end() ) { 451 | p.modify( *itr, same_payer, [&]( auto& row ) { 452 | row.val_name = value; 453 | }); 454 | } 455 | else { 456 | p.emplace(_self, [&]( auto& row ) { 457 | row.key = key; 458 | row.val_name = value; 459 | }); 460 | } 461 | } 462 | 463 | name get_name_prop(name key) 464 | { 465 | props p(_self, 0); 466 | auto itr = p.find(key.value); 467 | if( itr != p.end() ) { 468 | return itr->val_name; 469 | } 470 | else { 471 | p.emplace(_self, [&]( auto& row ) { 472 | row.key = key; 473 | }); 474 | return name(); 475 | } 476 | } 477 | 478 | 479 | void set_uint_prop(name key, uint64_t value) 480 | { 481 | props p(_self, 0); 482 | auto itr = p.find(key.value); 483 | if( itr != p.end() ) { 484 | p.modify( *itr, same_payer, [&]( auto& row ) { 485 | row.val_uint = value; 486 | }); 487 | } 488 | else { 489 | p.emplace(_self, [&]( auto& row ) { 490 | row.key = key; 491 | row.val_uint = value; 492 | }); 493 | } 494 | } 495 | 496 | uint64_t get_uint_prop(name key) 497 | { 498 | props p(_self, 0); 499 | auto itr = p.find(key.value); 500 | if( itr != p.end() ) { 501 | return itr->val_uint; 502 | } 503 | else { 504 | p.emplace(_self, [&]( auto& row ) { 505 | row.key = key; 506 | }); 507 | return 0; 508 | } 509 | } 510 | 511 | void inc_uint_prop(name key) 512 | { 513 | props p(_self, 0); 514 | auto itr = p.find(key.value); 515 | if( itr != p.end() ) { 516 | p.modify( *itr, same_payer, [&]( auto& row ) { 517 | row.val_uint++; 518 | }); 519 | } 520 | else { 521 | p.emplace(_self, [&]( auto& row ) { 522 | row.key = key; 523 | row.val_uint = 1; 524 | }); 525 | } 526 | } 527 | 528 | inline void inc_revision() { 529 | inc_uint_prop(name("revision")); 530 | } 531 | 532 | // table recipients approval 533 | 534 | struct [[eosio::table("approvals")]] approval { 535 | name account; 536 | uint8_t approved; 537 | 538 | auto primary_key()const { return account.value; } 539 | uint64_t by_approved()const { return approved ? account.value:0; } 540 | uint64_t by_unapproved()const { return (!approved) ? account.value:0; } 541 | }; 542 | 543 | typedef eosio::multi_index< 544 | name("approvals"), approval, 545 | indexed_by>, 546 | indexed_by> 547 | > approvals; 548 | 549 | 550 | 551 | // all registered schedules 552 | struct [[eosio::table("schedules")]] schedule { 553 | name schedule_name; 554 | name payer; 555 | name tkcontract; 556 | asset currency; 557 | string memo; 558 | asset dues; // how much we need to send to recipients 559 | name last_processed; // last paid recipient 560 | 561 | auto primary_key()const { return schedule_name.value; } 562 | // index for iterating through active schedules 563 | uint64_t by_dues()const { return (dues.amount > 0) ? schedule_name.value:0; } 564 | }; 565 | 566 | typedef eosio::multi_index< 567 | name("schedules"), schedule, 568 | indexed_by> 569 | > schedules; 570 | 571 | 572 | static inline uint128_t token_index_val(name tkcontract, asset currency) 573 | { 574 | return ((uint128_t)tkcontract.value << 64)|(uint128_t)currency.symbol.raw(); 575 | } 576 | 577 | // assets belonging to the payer. scope=payer 578 | struct [[eosio::table("funds")]] fundsrow { 579 | uint64_t id; 580 | name tkcontract; 581 | asset currency; 582 | asset dues; // how much we need to send to recipients 583 | asset deposited; // how much we can spend 584 | 585 | auto primary_key()const { return id; } 586 | uint128_t by_token()const { return token_index_val(tkcontract, currency); } 587 | }; 588 | 589 | typedef eosio::multi_index< 590 | name("funds"), fundsrow, 591 | indexed_by> 592 | > funds; 593 | 594 | 595 | // payment recipients, scope=scheme_name 596 | struct [[eosio::table("recipients")]] recipient { 597 | name account; 598 | uint64_t booked_total; // asset amount 599 | uint64_t paid_total; 600 | 601 | auto primary_key()const { return account.value; } 602 | // index for iterating through open dues 603 | uint64_t by_dues()const { return (booked_total > paid_total) ? account.value:0; } 604 | }; 605 | 606 | typedef eosio::multi_index< 607 | name("recipients"), recipient, 608 | indexed_by> 609 | > recipients; 610 | 611 | 612 | // optional transfer memos for recipients. scope=schedule 613 | struct [[eosio::table("memos")]] memo { 614 | name account; 615 | string memo; 616 | 617 | auto primary_key()const { return account.value; } 618 | }; 619 | 620 | typedef eosio::multi_index memos; 621 | 622 | 623 | 624 | void req_admin() 625 | { 626 | name admin = get_name_prop(name("adminacc")); 627 | if( admin == name("") ) { 628 | require_auth(_self); 629 | } 630 | else { 631 | require_auth(admin); 632 | } 633 | } 634 | 635 | 636 | struct transfer_args 637 | { 638 | name from; 639 | name to; 640 | asset quantity; 641 | string memo; 642 | }; 643 | 644 | void send_payment(name recipient, const extended_asset& x, const string memo) 645 | { 646 | action 647 | { 648 | permission_level{_self, name("active")}, 649 | x.contract, 650 | name("transfer"), 651 | transfer_args { 652 | .from=_self, .to=recipient, 653 | .quantity=x.quantity, .memo=memo 654 | } 655 | }.send(); 656 | } 657 | 658 | 659 | // does not check any validity, just performs a transfer and bookeeping 660 | void _pay_due(name schedule_name, name recipient) { 661 | schedules sched(_self, 0); 662 | auto scitr = sched.find(schedule_name.value); 663 | check(scitr != sched.end(), "This must never happen 3"); 664 | 665 | funds fnd(_self, scitr->payer.value); 666 | auto fndidx = fnd.get_index(); 667 | auto fnditr = fndidx.find(token_index_val(scitr->tkcontract, scitr->currency)); 668 | check(fnditr != fndidx.end(), "This must never happen 4"); 669 | 670 | recipients rcpts(_self, schedule_name.value); 671 | auto rcitr = rcpts.find(recipient.value); 672 | 673 | string memo; 674 | memos memtbl(_self, schedule_name.value); 675 | auto memitr = memtbl.find(recipient.value); 676 | if( memitr == memtbl.end() ) { 677 | memo = scitr->memo; 678 | } 679 | else { 680 | memo = memitr->memo; 681 | memtbl.erase(memitr); 682 | } 683 | 684 | asset due(rcitr->booked_total - rcitr->paid_total, scitr->currency.symbol); 685 | extended_asset pay(due, scitr->tkcontract); 686 | send_payment(recipient, pay, memo); 687 | 688 | rcpts.modify( *rcitr, same_payer, [&]( auto& row ) { 689 | row.paid_total = row.booked_total; 690 | }); 691 | 692 | sched.modify( *scitr, same_payer, [&]( auto& row ) { 693 | row.dues -= due; 694 | }); 695 | 696 | fnd.modify( *fnditr, same_payer, [&]( auto& row ) { 697 | row.dues -= due; 698 | row.deposited -= due; 699 | }); 700 | inc_revision(); 701 | } 702 | 703 | // eosio.token structure 704 | struct currency_stats { 705 | asset supply; 706 | asset max_supply; 707 | name issuer; 708 | 709 | uint64_t primary_key()const { return supply.symbol.code().raw(); } 710 | }; 711 | 712 | typedef eosio::multi_index<"stat"_n, currency_stats> stats_table; 713 | }; 714 | --------------------------------------------------------------------------------