From d352de4b5adacfdce8c8c9725e51360939652cca Mon Sep 17 00:00:00 2001 From: Andrios Date: Wed, 2 Sep 2020 15:26:22 -0300 Subject: [PATCH] fix(world) remove money issues from any system --- .gitignore | 40 ++++++ Dockerfile | 19 +++ README.md | 52 ++++++++ config/logback.xml | 52 ++++++++ docker-compose.yml | 13 ++ docs/dynamodb.json | 84 ++++++++++++ project.clj | 36 ++++++ src/decimals/analytics.clj | 17 +++ src/decimals/balances.clj | 74 +++++++++++ src/decimals/crypto.clj | 23 ++++ src/decimals/dynamodb.clj | 84 ++++++++++++ src/decimals/interceptors.clj | 193 ++++++++++++++++++++++++++++ src/decimals/security.clj | 64 +++++++++ src/decimals/specs.clj | 40 ++++++ src/decimals/transactions.clj | 107 +++++++++++++++ src/decimals/transport.clj | 50 +++++++ test/decimals/transactions_test.clj | 54 ++++++++ 17 files changed, 1002 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config/logback.xml create mode 100644 docker-compose.yml create mode 100644 docs/dynamodb.json create mode 100644 project.clj create mode 100644 src/decimals/analytics.clj create mode 100644 src/decimals/balances.clj create mode 100644 src/decimals/crypto.clj create mode 100644 src/decimals/dynamodb.clj create mode 100644 src/decimals/interceptors.clj create mode 100644 src/decimals/security.clj create mode 100644 src/decimals/specs.clj create mode 100644 src/decimals/transactions.clj create mode 100644 src/decimals/transport.clj create mode 100644 test/decimals/transactions_test.clj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9b96b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Project related + +# Java related +pom.xml +pom.xml.asc +*jar +*.class + +# Leiningen +classes/ +lib/ +native/ +checkouts/ +target/ +.lein-* +repl-port +.nrepl-port +.repl +profiles.clj + +# Temp Files +*.orig +*~ +.*.swp +.*.swo +*.tmp +*.bak + +# OS X +.DS_Store + +# Logging +*.log +/logs/ + +# Builds +out/ +build/ + +dynamodb_data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a03b22 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM clojure AS builder + +COPY project.clj /usr/src/app/ + +WORKDIR /usr/src/app + +RUN lein deps + +COPY . /usr/src/app/ + +RUN lein uberjar + +FROM openjdk:8-jdk-buster + +COPY --from=builder /usr/src/app/target/decimals-0.0.1-standalone.jar /decimals/app.jar + +EXPOSE 8910 + +CMD ["java", "-jar", "/decimals/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..eccbec6 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# decimals + +``` +jq -ncM 'while(true; .+1) | {method: "POST", header: {"x-api-key":["decimals"]}, url: "http://localhost:8890/v1/transactions", body: {amount: ., from: "Bob", to: "Alice", currency: "usd"} | @base64 }' | \ + vegeta attack -rate=50/s -lazy -format=json -duration=30s | \ + tee results.bin | \ + vegeta report +``` + +FIXME + +## Getting Started + +1. Start the application: `lein run` +2. Go to [localhost:8080](http://localhost:8080/) to see: `Hello World!` +3. Read your app's source code at src/decimals/service.clj. Explore the docs of functions + that define routes and responses. +4. Run your app's tests with `lein test`. Read the tests at test/decimals/service_test.clj. +5. Learn more! See the [Links section below](#links). + + +## Configuration + +To configure logging see config/logback.xml. By default, the app logs to stdout and logs/. +To learn more about configuring Logback, read its [documentation](http://logback.qos.ch/documentation.html). + + +## Developing your service + +1. Start a new REPL: `lein repl` +2. Start your service in dev-mode: `(def dev-serv (run-dev))` +3. Connect your editor to the running REPL session. + Re-evaluated code will be seen immediately in the service. + +### [Docker](https://www.docker.com/) container support + +1. Configure your service to accept incoming connections (edit service.clj and add ::http/host "0.0.0.0" ) +2. Build an uberjar of your service: `lein uberjar` +3. Build a Docker image: `sudo docker build -t decimals .` +4. Run your Docker image: `docker run -p 8080:8080 decimals` + +### [OSv](http://osv.io/) unikernel support with [Capstan](http://osv.io/capstan/) + +1. Build and run your image: `capstan run -f "8080:8080"` + +Once the image it built, it's cached. To delete the image and build a new one: + +1. `capstan rmi decimals; capstan build` + + +## Links +* [Other Pedestal examples](http://pedestal.io/samples) diff --git a/config/logback.xml b/config/logback.xml new file mode 100644 index 0000000..8234b6e --- /dev/null +++ b/config/logback.xml @@ -0,0 +1,52 @@ + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{io.pedestal} - %msg%n + + + + + logs/decimals-%d{yyyy-MM-dd}.%i.log + + 64 MB + + + + true + + + + + + + + %-5level %logger{36} %X{io.pedestal} - %msg%n + + + + INFO + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..33df480 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3' + +volumes: + dynamodb_data: + +services: + dynamodb: + image: amazon/dynamodb-local + command: -jar DynamoDBLocal.jar -sharedDb -dbPath /home/dynamodblocal/data/ + ports: + - "8000:8000" + volumes: + - ./dynamodb_data:/home/dynamodblocal/data diff --git a/docs/dynamodb.json b/docs/dynamodb.json new file mode 100644 index 0000000..f5266e3 --- /dev/null +++ b/docs/dynamodb.json @@ -0,0 +1,84 @@ +{ + "ModelName": "sequence", + "ModelMetadata": { + "Author": "", + "DateCreated": "Jun 24, 2020, 09:10 PM", + "DateLastModified": "Sep 02, 2020, 02:19 PM", + "Description": "", + "Version": "1.0" + }, + "DataModel": [ + { + "TableName": "sequence", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "PK", + "AttributeType": "S" + }, + "SortKey": { + "AttributeName": "SK", + "AttributeType": "S" + } + }, + "NonKeyAttributes": [ + { + "AttributeName": "GSI1_PK", + "AttributeType": "S" + }, + { + "AttributeName": "GSI1_SK", + "AttributeType": "S" + }, + { + "AttributeName": "currency", + "AttributeType": "S" + }, + { + "AttributeName": "timestamp", + "AttributeType": "S" + }, + { + "AttributeName": "LSI1_SK", + "AttributeType": "S" + } + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "LSI1", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "PK", + "AttributeType": "S" + }, + "SortKey": { + "AttributeName": "LSI1_SK", + "AttributeType": "S" + } + }, + "Projection": { + "ProjectionType": "ALL" + } + }, + { + "IndexName": "GSI1", + "KeyAttributes": { + "PartitionKey": { + "AttributeName": "GSI1_PK", + "AttributeType": "S" + }, + "SortKey": { + "AttributeName": "GSI1_SK", + "AttributeType": "S" + } + }, + "Projection": { + "ProjectionType": "ALL" + } + } + ], + "DataAccess": { + "MySql": {} + } + } + ] +} diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..218e488 --- /dev/null +++ b/project.clj @@ -0,0 +1,36 @@ +(defproject decimals "0.0.1" + :description "An immutable, scalable, minimalist ledger API" + :url "https://github.com/decimals/sequence" + :license {:name "Eclipse Public License" + :url "http://www.eclipse.org/legal/epl-v10.html"} + :dependencies [[org.clojure/clojure "1.10.1"] + [io.pedestal/pedestal.service "0.5.8"] + [io.pedestal/pedestal.jetty "0.5.8"] + + ;; custom + [org.clojure/data.json "1.0.0"] + [com.taoensso/faraday "1.11.0-alpha1"] + [clojure.java-time "0.3.2"] + [clj-http "3.10.1"] + [environ "1.2.0"] + [mount "0.1.16"] + [org.clojure/core.async "1.2.603"] + [metosin/spec-tools "0.10.3"] + [org.clojure/tools.logging "1.1.0"] + [circleci/analytics-clj "0.8.0"] + + [ch.qos.logback/logback-classic "1.2.3" :exclusions [org.slf4j/slf4j-api]] + [org.slf4j/jul-to-slf4j "1.7.26"] + [org.slf4j/jcl-over-slf4j "1.7.26"] + [org.slf4j/log4j-over-slf4j "1.7.26"]] + :plugins [[lein-environ "1.2.0"]] + :min-lein-version "2.0.0" + :resource-paths ["config", "resources"] + :profiles {:dev [:dev-common :dev-overrides] + :dev-common {:dependencies [[io.pedestal/pedestal.service-tools "0.5.8"] + [org.clojure/test.check "0.9.0"] + [nubank/mockfn "0.6.1"]] + :aliases {"run-dev" ["trampoline" "run" "-m" "decimals.transport/run-dev"]}} + :dev-overrides {} + :uberjar {:aot [decimals.transport]}} + :main ^{:skip-aot true} decimals.transport) diff --git a/src/decimals/analytics.clj b/src/decimals/analytics.clj new file mode 100644 index 0000000..1adc8f5 --- /dev/null +++ b/src/decimals/analytics.clj @@ -0,0 +1,17 @@ +(ns decimals.analytics + (:require [environ.core :refer [env]] + [circleci.analytics-clj.core :as a] + [mount.core :refer [defstate]] + [clojure.tools.logging :as log])) + +(defstate analytics :start (try + (a/initialize (env :segment-key)) + (catch Exception e (log/warnf "failed to initialize analytics: %s" (.message e))))) + +(defn track [context event traits] + (if-let [email (get-in context [:customer :email])] + (a/track analytics email event traits))) + +(defn identify [context] + (when-let [email (get-in context [:customer :email])] + (a/identify analytics email {:email email}))) diff --git a/src/decimals/balances.clj b/src/decimals/balances.clj new file mode 100644 index 0000000..054aff2 --- /dev/null +++ b/src/decimals/balances.clj @@ -0,0 +1,74 @@ +(ns decimals.balances + (:require [decimals.dynamodb :as db] + [clojure.tools.logging :as log] + [decimals.crypto :as crypto] + [clojure.data.json :as json] + [clojure.spec.alpha :as s] + [java-time :as t])) + +(defn ctx->id + ([context] + (conj {:public-key (get-in context [:customer :public-key])} + {:currency (get-in context [:tx :currency])} + {:account (get-in context [:request :query-params :account])})) + ([context party] + (conj {:public-key (get-in context [:customer :public-key])} + {:currency (get-in context [:tx :currency])} + {:account (get-in context [:tx party])}))) + +(defn id->pk [id] + (str (:public-key id) "#" (:account id))) + +(defn list-balances [query] + (when-let [balances (db/list-balances query)] + balances)) + +(defn balance [query] + (log/debug "querying " query) + (when-let [balance (db/query-balance (id->pk query) (:currency query))] + (if (s/valid? ::balance-tx balance) + balance + (log/warn "invalid balance in database: " balance (s/explain-data ::balance-tx balance))))) + +(defn account->genesis [account party] + (let [acc-id (party account) + currency (:currency account)] + {:id (str acc-id "#" currency) + :public-key (:public-key account) + :party party + :from acc-id + :to acc-id + :amount 0 + :currency currency + :date (str (t/instant)) + :timestamp (str (- (t/to-millis-from-epoch (t/instant)) 1)) + :type :genesis + :balance 0})) + +(defn context->account [context party] + (let [tx (:tx context) + cust (:customer context) + party (select-keys tx [party]) + currency (select-keys tx [:currency]) + pub-key (select-keys cust [:public-key]) + account (conj party pub-key currency)] + account)) + +(defn is-genesis [account] + (= (:public-key account) (:from account))) + +(defn genesis [context] + (let [tx (:tx context) + account (context->account context :from)] + + (when (is-genesis account) + (if (and (contains? context :from) (contains? (:from context) :balance)) + + (let [genesis-balance (get-in context [:from :balance]) + genesis-funds (assoc genesis-balance :balance (+ (:balance genesis-balance) + (* 2 (:amount tx))))] + genesis-funds) + + (let [genesis-tx (account->genesis account :from) + genesis-funds (assoc genesis-tx :balance (* 2 (:amount tx)))] + genesis-funds))))) diff --git a/src/decimals/crypto.clj b/src/decimals/crypto.clj new file mode 100644 index 0000000..b63ca43 --- /dev/null +++ b/src/decimals/crypto.clj @@ -0,0 +1,23 @@ +(ns decimals.crypto + (:import java.math.BigInteger + java.security.MessageDigest) + (:require + [clojure.data.json :as json] + )) + +(defn md5 + [^String s] + (->> s + .getBytes + (.digest (MessageDigest/getInstance "MD5")) + (BigInteger. 1) + (format "%032x"))) + +(defn map->md5 + [data] + (->> data + json/write-str + .getBytes + (.digest (MessageDigest/getInstance "MD5")) + (BigInteger. 1) + (format "%032x"))) diff --git a/src/decimals/dynamodb.clj b/src/decimals/dynamodb.clj new file mode 100644 index 0000000..e857182 --- /dev/null +++ b/src/decimals/dynamodb.clj @@ -0,0 +1,84 @@ +(ns decimals.dynamodb + (:import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder) + (:require [taoensso.faraday :as far] + [environ.core :refer [env]] + [clojure.tools.logging :as log])) + +;FIXME: persist connection +(def client-opts { + :access-key (env :aws-key-id) + :secret-key (env :aws-access-key) + :dynamodb-endpoint (env :dynamodb-endpoint) + }) + +(defn query [k] + (far/query client-opts :decimals + {:PK [:eq k]})) + +(defn query->pk [query] + (str (:public-key query) "#" (:account query))) + +(defn query-balance + [acc-id currency] + (let [balance (first (far/query client-opts :decimals + {:PK [:eq acc-id]} + {:query-filter {:currency [:eq currency]} + :index :LSI1 ;FIXME: use LSI instead of GSI as partition is the same + :order :desc + ;:limit 1 ;FIXME: currencies are not optmized at the top of the partition + }))] + balance)) + +(defn pagination [query config] + (if-let [after (:starting-after query)] + (if-let [sk (:timestamp (far/get-item client-opts + :decimals + {:PK (query->pk query) :SK (:starting-after query)}))] + (let [] + (log/debug "Paginated: " query config) + (assoc config :last-prim-kvs {:PK (query->pk query) + :SK (:starting-after query) + :GSI1_PK (query->pk query) + :GSI1_SK sk})) + config) + config)) + +(defn list-transactions [query] + (->> {:index :LSI1 ;FIXME: use LSI instead of GSI as partition is the same + :order :desc + :limit 20} + (pagination query) + (far/query client-opts :decimals {:PK [:eq (query->pk query)]}))) + +(defn list-with-genesis [query] + (let [pk (query->pk query)] + (log/debug "Querying " pk) + (when-let [gens (far/query client-opts :decimals + {:PK [:eq pk] + :SK [:begins-with (:account query)]} + {:order :desc + })] + (let [] + (log/debug "Got genesis " gens) + (->> gens + (map :currency) + (map #(query-balance pk %)))) + ))) + +(defn list-accounts [pk] + (log/debug "Querying " pk) + (far/query client-opts :decimals + {:PK [:eq pk]} + {:order :desc})) + +(defn list-balances [query] + (log/debug "Querying " query) + (if (contains? query :account) + (list-with-genesis query) + (list-accounts (:public-key query)))) + +(defn put [item] + (far/put-item client-opts :decimals item)) + +(defn transact-put [items] + (far/transact-write-items client-opts {:items items})) diff --git a/src/decimals/interceptors.clj b/src/decimals/interceptors.clj new file mode 100644 index 0000000..8d6e496 --- /dev/null +++ b/src/decimals/interceptors.clj @@ -0,0 +1,193 @@ +(ns decimals.interceptors + (:require [io.pedestal.http :as http] + [io.pedestal.http.route :as route] + [io.pedestal.interceptor.chain :as chain] + [clojure.data.json :as json] + [clojure.tools.logging :as log] + [clojure.walk] + [clojure.core.async :as async] + [spec-tools.core :as st] + [clojure.spec.alpha :as s] + [decimals.security :as sec] + [decimals.crypto :as crypto] + [decimals.transactions :as tx] + [decimals.braintree :as braintree] + [decimals.analytics :as analytics] + [decimals.balances :as b] + [decimals.cache :as c])) + +(def msg + {:generic { :message "Sorry, we had a problem. Please, try again or reach out."} + :funds { :message "Insufficient funds, check origin account balance."} + :creds { :message "Invalid credentials."}}) + +(defn response [status body & {:as headers}] + {:status status :body body :headers headers}) + +(def ok (partial response 200)) +(def created (partial response 201)) +(def accepted (partial response 202)) +(def badrequest (partial response 400)) +(def forbidden (partial response 401)) +(def not-found (partial response 404)) +(def server-error (partial response 500)) + +(defn req-meta [context] + (select-keys (:request context) [:path-info + :remote-addr + :request-method + :server-name + :transit-params + :uri + ])) + +(defn respond [context response] + (let [m (req-meta context)] + (case (:status response) + 200 (analytics/track context "Success" m) + 201 (analytics/track context "Created" m) + 400 (analytics/track context "Bad request" m) + 401 (analytics/track context "Unauthorized" m) + 500 (analytics/track context "Server Error" m))) + (chain/terminate (assoc context :response response))) + +(def genesis-balance + {:name :genesis-balance + :enter + (fn [context] + (if-let [genesis (b/genesis context)] + (assoc-in context [:from :balance] genesis) + context))}) + +(def check-balance + {:name :check-balance + :enter + (fn [context] + (log/debug "checking origin funds " (get-in context [:from :balance :balance]) (get-in context [:tx :amount])) + (if-let [balance (get-in context [:from :balance :balance])] + (when-let [amount (get-in context [:tx :amount])] + (if-not (>= balance amount) + (respond context (badrequest (:funds msg))) + context)) + (respond context (badrequest (:funds msg)))))}) + +(def hash-txs + {:name :hash-txs + :enter + (fn [context] + (if-let [ctx (tx/hash-txs context)] + ctx + (respond context (server-error {:message (:generic msg)}))))}) + +(def chain-txs + {:name :chain-tx + :enter + (fn [context] + (let [r (tx/chain context)] + (if-let [reply (:success r)] + (respond context (created (map #(st/select-spec ::tx/pub-transaction %) reply))) + (respond context (server-error {:message (:generic msg)})))))}) + +(def spec-tx + {:name :spec-tx + :enter + (fn [context] + (if-not (s/valid? ::tx/transaction (:tx context)) + (respond context (badrequest {:error (s/explain-data ::tx/transaction (:tx context))})) + context))}) + +(defn str->map [str] + (try + (-> str json/read-str clojure.walk/keywordize-keys) + (catch Exception e))) + +(def parse-tx + {:name :parse-tx + :enter + (fn [context] + (if-let [tx (str->map (slurp (:body (:request context))))] + (assoc context :tx tx) + (respond context (badrequest {:error "Malformed JSON."}))))}) + +(def account-queryparam + {:name :account-queryparam + :enter + (fn [context] + (if-let [acc-id (get-in context [:request :query-params :account])] + (let [id (b/ctx->id context)] + (log/debug "Querying account: " id) + (assoc context :account id)) + (let [pk (get-in context [:customer :public-key])] + (log/debug "Querying public-key " pk) + (assoc-in context [:account :public-key] pk))))}) + +(def transaction-queryparam + {:name :transaction-queryparam + :enter + (fn [context] + (if-let [tx-id (select-keys context [:request :path-params :transaction-id])] + (let [id (conj (select-keys context [:customer :public-key]) + tx-id)] + (log/debug "Querying transaction: " id) + (assoc context :tx-id id)) + (respond context (badrequest {:error "Missing transaction path parameter."}))))}) + +(def pagination + {:name :pagination + :enter + (fn [context] + (if-let [after (get-in context [:request :query-params :starting_after])] + (assoc context :starting-after after) + context))}) + +(def list-transactions + {:name :list-transactions + :enter + (fn [context] + (if-let [transactions (tx/list-transactions + (conj (:account context) + (select-keys context [:starting-after])))] + (respond context (ok (map #(st/select-spec ::tx/pub-transaction %) transactions))) + (respond context (not-found {:error "Account not found."}))))}) + +(def list-balances + {:name :list-balances + :enter + (fn [context] + (if-let [balances (b/list-balances (:account context))] + (respond context (ok (map #(st/select-spec ::b/pub-balance %) balances))); + (respond context (not-found {:error "Account not found."}))))}) + +(def auth + {:name :auth + :enter + (fn [context] + (if-let [customer (sec/apikey-auth context)] + (let [ctx (assoc context :customer customer)] + (analytics/identify ctx) + ctx) + (respond context (forbidden (:creds msg)))))}) + +(def from-balance + {:name :from-balance + :enter + (fn [context] + (if-let [from (b/balance (b/ctx->id context :from))] + (assoc-in context [:from :balance] from) + context))}) + +(def routes + (route/expand-routes + #{["/v1/transactions" :post + [http/json-body auth parse-tx spec-tx from-balance genesis-balance check-balance hash-txs chain-txs] + ;auth + :route-name :transactions-post] + ["/v1/transactions/:transaction-id" :get + [http/json-body auth transaction-queryparam] + :route-name :transactions-get] + ["/v1/transactions" :get + [http/json-body auth account-queryparam pagination list-transactions] + :route-name :transactions-list] + ["/v1/balances" :get + [http/json-body auth account-queryparam list-balances] + :route-name :balances-get]})) diff --git a/src/decimals/security.clj b/src/decimals/security.clj new file mode 100644 index 0000000..93ab8bb --- /dev/null +++ b/src/decimals/security.clj @@ -0,0 +1,64 @@ +(ns decimals.security + (:import java.security.MessageDigest + java.util.Base64) + (:require [clojure.tools.logging :as log] + [environ.core :refer [env]] + [clojure.walk] + [clojure.string :as str] + [clojure.data.json :as json])) + +(defn sha256 + [string] + (let [digest (.digest (MessageDigest/getInstance "SHA-256") (.getBytes string "UTF-8"))] + (apply str (map (partial format "%02x") digest)))) + +(defn match-keys [digest keys] + (->> keys + (filter #(= (:secret-key-hash %) digest)) + seq + first)) + +(defn authenticate + [k keys] + (log/debug "Matching: " k keys) + (when-let [key-digest (sha256 k)] + (when-let [account (match-keys key-digest keys)] + account))) + +(defn decode [to-decode] + (try + (String. (.decode (Base64/getDecoder) to-decode)) + (catch Exception e))) + +(defn remove-colon [s] + (subs s 0 (- (count s) 1))) + +(defn authz-header [context] + (get-in context [:request :headers "authorization"])) + +(defn header-key + [context] + (when-let [header (authz-header context)] + (-> header + (str/split #" ") + second + decode + remove-colon))) + +(defn str->map [str] + (try + (-> str json/read-str clojure.walk/keywordize-keys) + (catch Exception e (log/errorf "failde to parse keys %s" (.getMessage e))))) + + +(defn apikey-auth + [context] + (if-let [key (header-key context)] + (if-let [keys (str->map (env :keys))] + (if-let [customer (authenticate key keys)] + (let [] + (log/debug "Got customer: " customer) + customer) + (log/debug "Invalid credentials")) + (log/warn "Error parsing keys from env")) + (log/debug "Invalid Authz header: " (authz-header context)))) diff --git a/src/decimals/specs.clj b/src/decimals/specs.clj new file mode 100644 index 0000000..e835295 --- /dev/null +++ b/src/decimals/specs.clj @@ -0,0 +1,40 @@ +(ns decimals.specs + (:require [clojure.spec.alpha :as s] + [decimals.transactions :as tx] + [decimals.balances :as b])) + +(s/def ::tx/id (s/and string? #(> (count %) 1))) +(s/def ::tx/date (s/and string? #(> (count %) 1))) +(s/def ::tx/from (s/and string? #(> (count %) 1))) +(s/def ::tx/to (s/and string? #(> (count %) 1))) +(s/def ::tx/amount (s/and integer? #(>= % 0))) +(s/def ::tx/balance (s/and integer? #(>= % 0))) +(s/def ::tx/currency (s/and string? #(> (count %) 1))) +(s/def ::tx/public-key (s/and string? #(> (count %) 1))) + +(s/def ::tx/account (s/keys :req-un [(or ::tx/from ::tx/to)])) + +(s/def ::tx/transaction (s/keys :req-un [::tx/from + ::tx/to + ::tx/amount + ::tx/currency])) + +(s/def ::tx/pub-transaction (s/keys :req-un [::tx/id + ::tx/from + ::tx/to + ::tx/amount + ::tx/balance + ::tx/currency + ::tx/date])) + +(s/def ::b/account (s/and string? #(> (count %) 1))) + +(s/def ::b/balance-tx (s/keys :req-un [::tx/from + ::tx/to + ::tx/amount + ::tx/currency + ::tx/balance + ::tx/public-key + ::tx/date])) + +(s/def ::b/pub-balance (s/keys :req-un [::tx/balance ::tx/currency ::b/account])) diff --git a/src/decimals/transactions.clj b/src/decimals/transactions.clj new file mode 100644 index 0000000..a978d06 --- /dev/null +++ b/src/decimals/transactions.clj @@ -0,0 +1,107 @@ +(ns decimals.transactions + (:require [decimals.dynamodb :as db] + [clojure.tools.logging :as log] + [decimals.crypto :as crypto] + [decimals.balances :as b] + [spec-tools.core :as st] + [clojure.data.json :as json] + [clojure.spec.alpha :as s] + [java-time :as t])) + +(defn account->id [account party] + (str (:public-key account) "#" ((keyword party) account))) + +(defn tx-get [id] + (db/query id)) + +(defn item->tuple [item] + (log/debug "putting " item) + [:put item]) + +(defn doc->put [doc] + (assoc {:table-name :decimals} + :item doc + :cond-expr "attribute_not_exists(#SK)" + :expr-attr-names {"#SK" (:id doc)})) + +(defn tx->doc [tx] + (conj tx {:PK (account->id tx (:party tx)) + :SK (:id tx) + :GSI1_PK (:public-key tx) + :GSI1_SK ((keyword (:party tx)) tx) + :LSI1_SK (:timestamp tx)})) + +(defn chain [context] + (let [from-balance (get-in context [:from :balance]) + from-tx (get-in context [:from :tx]) + to-balance (get-in context [:to :balance]) + to-tx (get-in context [:to :tx]) + txs (list from-balance from-tx to-balance to-tx)] + + (let [items (->> txs + ;(filter map?) ;FIXME: fail in invalid data is present + (map tx->doc) + (map doc->put) + (map item->tuple))] + + (if-let [created (db/transact-put items)] + (assoc context :success [from-tx to-tx]) + (assoc context :error :internal))))) + +(defn context->account [context party] + (let [tx (:tx context) + cust (:customer context) + party (select-keys tx [party]) + pub-key (select-keys cust [:public-key]) + currency (select-keys tx [:currency]) + account (conj party pub-key currency)] + (if (s/valid? ::account account) + account + (log/warn (s/explain ::account account))))) + +(defn party-funds [context party] + (when-let [query (b/ctx->id context party)] + (when-let [balance (b/balance query)] + (if (s/valid? ::b/balance-tx balance) + (let [] + (log/debug "got balance: " balance) + balance) + (log/warn "invalid balance in database: " balance (s/explain-data ::b/balance-tx balance)))))) + +(defn hash-txs [context] + (let [to-account (context->account context :to) + from-account (context->account context :from)] + (when-let [to (cond ;FIXME: call the each function only once (as-> ?) + (party-funds context :to) (party-funds context :to) + (b/account->genesis to-account :to) (b/account->genesis to-account :to))] + (let [tx (:tx context) + from (get-in context [:from :balance]) + from-tx (conj tx + {:id (crypto/map->md5 (st/select-spec ::pub-transaction from)) + :public-key (:public-key from-account) + :party :from + :account (:from from-account) + :balance (- (:balance from) (:amount tx)) + :date (str (t/instant)) + :timestamp (str (t/to-millis-from-epoch (t/instant))) + :type :debit}) + to-tx (conj tx + {:id (crypto/map->md5 (st/select-spec ::pub-transaction to)) + :public-key (:public-key to-account) + :party :to + :account (:to to-account) + :balance (+ (:balance to) (:amount tx)) + :date (str (t/instant)) + :timestamp (str (t/to-millis-from-epoch (t/instant))) + :type :credit})] + (-> context + (assoc-in [:to :balance] to) + (assoc-in [:to :tx] to-tx) + (assoc-in [:from :tx] from-tx)))))) + +(defn list-transactions [query] + (when-let [transactions (db/list-transactions query)] + (let [] + (log/debug transactions)) + transactions + )) diff --git a/src/decimals/transport.clj b/src/decimals/transport.clj new file mode 100644 index 0000000..7621905 --- /dev/null +++ b/src/decimals/transport.clj @@ -0,0 +1,50 @@ +(ns decimals.transport + (:gen-class) ; for -main method in uberjar + (:require [io.pedestal.http :as http] + [io.pedestal.http.route :as route] + [clojure.spec.gen.alpha :as gen] + [clojure.tools.logging :as log] + [environ.core :refer [env]] + [mount.core :as mount] + [decimals.interceptors :as i] + [decimals.analytics :as a] + [decimals.specs] + )) + +(def service-map + {::http/routes i/routes + ::http/allowed-origins {:allowed-origins + ["https://decimals.stoplight.io"] + :methods "GET,POST"} + ::http/type :jetty + ::http/host "0.0.0.0" + ::http/port 8910}) + +(defn start [] + (mount/start) + (http/start (http/create-server service-map))) + +(defn -main + "The entry-point for 'lein run'" + [& args] + (println "\nCreating your server...") + (start)) + +;; For interactive development +(defonce server (atom nil)) + +(defn start-dev [] + (mount/start) + (reset! server + (http/start (http/create-server + (assoc service-map + ::http/join? false))))) + +(defn stop-dev [] + (http/stop @server)) + +(defn restart [] + (stop-dev) + (start-dev)) + +;(restart) diff --git a/test/decimals/transactions_test.clj b/test/decimals/transactions_test.clj new file mode 100644 index 0000000..ec70d8a --- /dev/null +++ b/test/decimals/transactions_test.clj @@ -0,0 +1,54 @@ +(ns decimals.transactions-test + (:require [clojure.test :refer :all] + [clojure.spec.alpha :as s] + [clojure.data.json :as json] + [mockfn.macros :as mfn] + [clojure.spec.gen.alpha :as gen] + [decimals.dynamodb :as db] + [decimals.balances :as b] + [decimals.transactions :as tx])) + +(deftest hash-txs-test + "assert that values add up for a transaction" + (let [customer {:pulic-key "123"} + tx {:from "Alice", :to "Bob", :amount 10, :currency "usd"} + from-balance {:from "Genesis", + :to "Alice", + :amount 100, + :balance 100, + :currency "usd", + :public-key "123"} + context {:tx tx + :from { :balance from-balance} + :customer customer}] + + ; new destination balance + (mfn/providing [(b/balance {:public-key nil, :currency "usd", :account "Bob"}) nil] + (if-let [result (tx/hash-txs context)] + (let [tx-amount (get-in result [:tx :amount]) + from-balance (get-in result [:from :balance :balance]) + from-tx (get-in result [:from :tx :balance]) + to-balance (get-in result [:to :balance :balance]) + to-tx (get-in result [:to :tx :balance])] + (is (= from-tx (- from-balance tx-amount))) + (is (= to-tx (+ to-balance tx-amount)))) + (is true false))) + + ; existing destination balance + (mfn/providing [(b/balance {:public-key nil, :currency "usd", :account "Bob"}) + {:from "Matt", + :to "Bob", + :amount 100, + :balance 100, + :currency "usd", + :date "123" + :public-key "123"}] + (if-let [result (tx/hash-txs context)] + (let [tx-amount (get-in result [:tx :amount]) + from-balance (get-in result [:from :balance :balance]) + from-tx (get-in result [:from :tx :balance]) + to-balance (get-in result [:to :balance :balance]) + to-tx (get-in result [:to :tx :balance])] + (is (= from-tx (- from-balance tx-amount))) + (is (= to-tx (+ to-balance tx-amount)))) + (is true false)))))