├── tests
├── __init__.py
├── conftest.py
├── utils.py
└── test_integration.py
├── requirements.txt
├── client
├── NotFound.html
├── main.js
├── toast.js
├── Json.html
├── QR.html
├── LuaCode.html
├── EventRow.html
├── List.html
├── MultiField.html
├── Markdown.html
├── Call.html
├── Create.html
├── App.html
├── accountStore.js
├── Home.html
├── Account.html
├── View.html
└── Docs.html
├── static
├── bet.png
├── icon.png
├── lnurlpayicon.png
├── Inconsolata-Bold.ttf
├── terms.txt
├── index.html
├── rings.svg
└── global.css
├── .babelrc
├── .gitignore
├── pytest.ini
├── runlua
├── helpers.go
├── keybase.go
├── contract_lua_functions.go
├── cmd
│ └── main.go
└── runlua.go
├── package.json
├── Makefile
├── admin.go
├── rollup.config.js
├── account_functions.go
├── go.mod
├── stream.go
├── types
└── types.go
├── contract_functions.go
├── postgres.sql
├── lightning.go
├── helpers.go
├── call_handlers.go
├── payment_receive.go
├── contract_handlers.go
├── lnurlpay.go
├── github.go
├── main.go
├── call_functions.go
└── account_handlers.go
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | pylightning
3 | bitcoin-requests
4 | sseclient-py
5 |
--------------------------------------------------------------------------------
/client/NotFound.html:
--------------------------------------------------------------------------------
1 | Page not found!
2 |
3 | go back to home!
4 |
--------------------------------------------------------------------------------
/static/bet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/etleneum/master/static/bet.png
--------------------------------------------------------------------------------
/static/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/etleneum/master/static/icon.png
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/static/lnurlpayicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/etleneum/master/static/lnurlpayicon.png
--------------------------------------------------------------------------------
/static/Inconsolata-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melvincarvalho/etleneum/master/static/Inconsolata-Bold.ttf
--------------------------------------------------------------------------------
/client/main.js:
--------------------------------------------------------------------------------
1 | /** @format */
2 |
3 | import App from './App.html'
4 |
5 | const app = new App({
6 | target: document.getElementById('app')
7 | })
8 |
9 | export default app
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.env
2 | *.swp
3 | *.swo
4 | etleneum
5 | node_modules
6 | lib
7 | bindata.go
8 | .merlin
9 | venv
10 | __pycache__
11 | browserify-cache.json
12 | runcall
13 | static/*bundle*
14 | .pyre
15 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | testpaths = tests
3 | log_cli = true
4 | log_cli_level = DEBUG
5 | log_cli_date_format = %H:%M:%S
6 | log_cli_format = %(asctime)s %(levelname)s %(message)s
7 | addopts = -s -vv -x --show-capture=no
8 |
--------------------------------------------------------------------------------
/static/terms.txt:
--------------------------------------------------------------------------------
1 | Terms of Service
2 |
3 | All satoshis sent to this website are to be considered donations.
4 | Any functionality that allows you to withdraw satoshis from this website can be suspended at any time.
5 |
6 | Privacy Policy
7 |
8 | The only data that is collected is the data you send during method calls that is shown publicly on the website.
9 | Identities aren't represented internally by anything else than the public key supplied by your authentication agent and the short id shown in the website.
10 |
--------------------------------------------------------------------------------
/client/toast.js:
--------------------------------------------------------------------------------
1 | /** @format */
2 |
3 | import Toastify from 'toastify-js'
4 |
5 | function toast(type, html, duration) {
6 | Toastify({
7 | text: html,
8 | duration: 7000,
9 | close: duration > 10000,
10 | gravity: 'top',
11 | positionLeft: false,
12 | className: `toast-${type}`
13 | }).showToast()
14 | }
15 |
16 | export const info = (html, d = 7000) => toast('info', html, d)
17 | export const warning = (html, d = 7000) => toast('warning', html, d)
18 | export const error = (html, d = 7000) => toast('error', html, d)
19 | export const success = (html, d = 7000) => toast('success', html, d)
20 |
--------------------------------------------------------------------------------
/runlua/helpers.go:
--------------------------------------------------------------------------------
1 | package runlua
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strconv"
7 | "strings"
8 | )
9 |
10 | var reNumber = regexp.MustCompile("\\d+")
11 |
12 | func stackTraceWithCode(stacktrace string, code string) string {
13 | var result []string
14 |
15 | stlines := strings.Split(stacktrace, "\n")
16 | lines := strings.Split(code, "\n")
17 | // result = append(result, stlines[0])
18 |
19 | for i := 0; i < len(stlines); i++ {
20 | stline := stlines[i]
21 | result = append(result, stline)
22 |
23 | snum := reNumber.FindString(stline)
24 | if snum != "" {
25 | num, _ := strconv.Atoi(snum)
26 | for i, line := range lines {
27 | line = fmt.Sprintf("%3d %s", i+1, line)
28 | if i+1 > num-3 && i+1 < num+3 {
29 | result = append(result, line)
30 | }
31 | }
32 | }
33 | }
34 |
35 | return strings.Join(result, "\n")
36 | }
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "@rollup/plugin-commonjs": "^11.0.2",
4 | "@rollup/plugin-inject": "^4.0.1",
5 | "@rollup/plugin-json": "^4.0.2",
6 | "@rollup/plugin-node-resolve": "^7.1.1",
7 | "prettier-plugin-svelte": "^0.7.0",
8 | "rollup": "^2.2.0",
9 | "rollup-plugin-shim": "^1.0.0",
10 | "rollup-plugin-svelte": "^5.1.1",
11 | "rollup-plugin-terser": "^4.0.4",
12 | "svelte": "^3.20.1"
13 | },
14 | "dependencies": {
15 | "bech32": "^1.1.3",
16 | "buffer": "^5.5.0",
17 | "dom-json-tree": "^1.0.7",
18 | "flua": "^0.2.2",
19 | "highlight.js": "^9.18.1",
20 | "hmac": "^1.0.1",
21 | "kjua": "^0.6.0",
22 | "markdown-it": "^10.0.0",
23 | "promise-window": "^1.2.1",
24 | "punycode": "^2.1.1",
25 | "sha.js": "^2.4.11",
26 | "svelte-spa-router": "^1.3.0",
27 | "toastify-js": "^1.7.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | all: etleneum runcall
2 |
3 | etleneum: $(shell find . -name "*.go") bindata.go
4 | go build -ldflags="-s -w" -o ./etleneum
5 |
6 | runcall: runlua/runlua.go runlua/cmd/main.go
7 | cd runlua/cmd && go build -o ../../runcall
8 |
9 | bindata.go: static/bundle.js static/index.html static/global.css static/bundle.css
10 | go-bindata -o bindata.go static/...
11 |
12 | static/bundle.js: $(shell find client)
13 | ./node_modules/.bin/rollup -c
14 |
15 | deploy_test: etleneum
16 | ssh root@nusakan-58 'systemctl stop etleneum-test'
17 | scp etleneum nusakan-58:etleneum-test/etleneum
18 | ssh root@nusakan-58 'systemctl start etleneum-test'
19 |
20 | deploy: etleneum
21 | scp etleneum aspidiske-402:.lightning/plugins/etleneum-new
22 | ssh aspidiske-402 'lightning/cli/lightning-cli plugin stop etleneum; mv .lightning/plugins/etleneum-new .lightning/plugins/etleneum; lightning/cli/lightning-cli plugin start $$HOME/.lightning/plugins/etleneum'
23 |
--------------------------------------------------------------------------------
/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
etleneum
11 |
12 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
34 |
--------------------------------------------------------------------------------
/client/Json.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/admin.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/sha256"
5 | "encoding/hex"
6 | "fmt"
7 | "net/http"
8 |
9 | "github.com/gorilla/mux"
10 | )
11 |
12 | func handleDecodeScid(w http.ResponseWriter, r *http.Request) {
13 | scid := mux.Vars(r)["scid"]
14 |
15 | uscid, err := decodeShortChannelId(scid)
16 | if err != nil {
17 | http.Error(w, err.Error(), 400)
18 | return
19 | }
20 |
21 | callid, ok := parseShortChannelId(uscid)
22 | if !ok {
23 | http.Error(w, "couldn't parse, not a call id.", 400)
24 | return
25 | }
26 |
27 | returnCallDetails(w, callid)
28 | }
29 |
30 | func handleCallDetails(w http.ResponseWriter, r *http.Request) {
31 | callid := mux.Vars(r)["callid"]
32 | returnCallDetails(w, callid)
33 | }
34 |
35 | func returnCallDetails(w http.ResponseWriter, callid string) {
36 | scid := makeShortChannelId(callid)
37 | preimage := makePreimage(callid)
38 | hash := sha256.Sum256(preimage)
39 |
40 | fmt.Fprintf(w, `
41 | call: %s
42 | short_channel_id: %s
43 | preimage: %s
44 | hash: %s
45 |
46 | `, callid,
47 | encodeShortChannelId(scid),
48 | hex.EncodeToString(preimage),
49 | hex.EncodeToString(hash[:]),
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/client/QR.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
38 |
39 |
52 |
53 |
57 |
--------------------------------------------------------------------------------
/client/LuaCode.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
28 |
29 |
44 |
45 |
46 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/client/EventRow.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
34 |
35 |
36 |
37 |
43 |
{call.time.split('T').join(' ').replace(/\..*/, '')}
44 |
{call.caller || ''}
45 |
46 |
47 |
{call.method}({parseInt(call.msatoshi/1000)}sat)
48 |
49 |
50 |
51 | {#each call.transfers as transfer}
52 |
53 | {parseInt(transfer.msatoshi/1000)}sat
54 | {transfer.direction === 'out' ? 'to' : 'from'}
55 | {transfer.counterparty}
56 |
57 | {/each}
58 |
59 |
60 |
--------------------------------------------------------------------------------
/client/List.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
19 |
20 |
42 |
43 |
44 | {#if !contracts}
45 |
loading
46 | {:else} {#each contracts as ct}
47 |
48 |
49 |
50 |
51 | id
52 | {ct.id}
53 |
54 |
55 | satoshi
56 | {parseInt(ct.funds / 1000)}
57 |
58 |
59 | calls
60 | {ct.ncalls}
61 |
62 |
63 |
64 | {/each} {/if}
65 |
66 |
--------------------------------------------------------------------------------
/client/MultiField.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
44 |
45 |
54 |
55 |
56 | ⇋
57 | {#if type == 'text-line'}
58 |
59 | {:else if type == 'text-area'}
60 |
61 | {:else if type == 'number'}
62 |
63 | {:else if type == 'bool'}
64 |
65 | {/if}
66 |
67 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | /** @format */
2 |
3 | import shim from 'rollup-plugin-shim'
4 | import json from '@rollup/plugin-json'
5 | import svelte from 'rollup-plugin-svelte'
6 | import resolve from '@rollup/plugin-node-resolve'
7 | import commonjs from '@rollup/plugin-commonjs'
8 | import inject from '@rollup/plugin-inject'
9 | import {terser} from 'rollup-plugin-terser'
10 |
11 | const production = !!process.env.PRODUCTION
12 |
13 | export default {
14 | input: 'client/main.js',
15 | output: {
16 | sourcemap: true,
17 | format: 'iife',
18 | name: 'app',
19 | file: 'static/bundle.js'
20 | },
21 | plugins: [
22 | json(),
23 |
24 | shim({
25 | fengari: 'export default window.fengari'
26 | }),
27 |
28 | svelte({
29 | // enable run-time checks when not in production
30 | dev: !production,
31 | // we'll extract any component CSS out into
32 | // a separate file — better for performance
33 | css: css => {
34 | css.write('static/bundle.css')
35 | }
36 | }),
37 |
38 | // If you have external dependencies installed from
39 | // npm, you'll most likely need these plugins. In
40 | // some cases you'll need additional configuration —
41 | // consult the documentation for details:
42 | // https://github.com/rollup/rollup-plugin-commonjs
43 | resolve({
44 | browser: true,
45 | dedupe: importee =>
46 | importee === 'svelte' || importee.startsWith('svelte/'),
47 | preferBuiltins: false
48 | }),
49 |
50 | commonjs(),
51 |
52 | inject({
53 | Buffer: ['buffer', 'Buffer']
54 | }),
55 |
56 | // If we're building for production (npm run build
57 | // instead of npm run dev), minify
58 | production && terser()
59 | ],
60 | watch: {
61 | clearScreen: false
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/account_functions.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "database/sql"
7 | "encoding/hex"
8 | "encoding/json"
9 | "fmt"
10 | "sort"
11 |
12 | "github.com/fiatjaf/etleneum/types"
13 | "gopkg.in/antage/eventsource.v1"
14 | )
15 |
16 | func getAccountSecret(account string) string {
17 | hash := sha256.Sum256([]byte(account + "-" + s.SecretKey))
18 | return hex.EncodeToString(hash[:])
19 | }
20 |
21 | func hmacCall(call *types.Call) []byte {
22 | mac := hmac.New(sha256.New, []byte(getAccountSecret(call.Caller)))
23 | mac.Write([]byte(callHmacString(call)))
24 | return mac.Sum(nil)
25 | }
26 |
27 | func callHmacString(call *types.Call) (res string) {
28 | res = fmt.Sprintf("%s:%s:%d,", call.ContractId, call.Method, call.Msatoshi)
29 |
30 | var payload map[string]interface{}
31 | json.Unmarshal(call.Payload, &payload)
32 |
33 | // sort keys
34 | keys := make([]string, len(payload))
35 | i := 0
36 | for k, _ := range payload {
37 | keys[i] = k
38 | i++
39 | }
40 | sort.Strings(keys)
41 |
42 | // add key-values
43 | for _, k := range keys {
44 | v := payload[k]
45 | res += fmt.Sprintf("%s=%v", k, v)
46 | res += ","
47 | }
48 |
49 | return
50 | }
51 |
52 | func notifyHistory(es eventsource.EventSource, accountId string) {
53 | var history []types.AccountHistoryEntry
54 | err := pg.Select(&history,
55 | `SELECT `+types.ACCOUNTHISTORYFIELDS+`
56 | FROM account_history WHERE account_id = $1`,
57 | accountId)
58 | if err != nil && err != sql.ErrNoRows {
59 | log.Error().Err(err).Str("id", accountId).
60 | Msg("failed to load account history from session")
61 | return
62 | } else if err != sql.ErrNoRows {
63 | es.SendEventMessage("[]", "history", "")
64 | } else {
65 | jhistory, _ := json.Marshal(history)
66 | es.SendEventMessage(string(jhistory), "history", "")
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/fiatjaf/etleneum
2 |
3 | go 1.14
4 |
5 | require (
6 | github.com/aarzilli/golua v0.0.0-20190714183732-fc27908ace94
7 | github.com/btcsuite/btcd v0.20.1-beta.0.20200515232429-9f0179fd2c46
8 | github.com/btcsuite/golangcrypto v0.0.0-20150304025918-53f62d9b43e8 // indirect
9 | github.com/elazarl/go-bindata-assetfs v1.0.0
10 | github.com/fiatjaf/go-lnurl v1.0.0
11 | github.com/fiatjaf/hashbow v1.0.0
12 | github.com/fiatjaf/lightningd-gjson-rpc v1.0.0
13 | github.com/fiatjaf/ln-decodepay v1.0.0
14 | github.com/fiatjaf/lunatico v1.0.0
15 | github.com/fogleman/gg v1.3.0
16 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
17 | github.com/google/go-github v17.0.0+incompatible
18 | github.com/google/go-querystring v1.0.0 // indirect
19 | github.com/gorilla/mux v1.7.4
20 | github.com/itchyny/gojq v0.10.3
21 | github.com/jmoiron/sqlx v1.2.0
22 | github.com/joho/godotenv v1.3.0
23 | github.com/kelseyhightower/envconfig v1.4.0
24 | github.com/lib/pq v1.7.0
25 | github.com/lightningnetwork/lnd v0.10.1-beta
26 | github.com/lucasb-eyer/go-colorful v1.0.3 // indirect
27 | github.com/lucsky/cuid v1.0.2
28 | github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
29 | github.com/roasbeef/btcd v0.0.0-20180418012700-a03db407e40d // indirect
30 | github.com/roasbeef/btcutil v0.0.0-20180406014609-dfb640c57141 // indirect
31 | github.com/rs/cors v1.7.0
32 | github.com/rs/zerolog v1.19.0
33 | github.com/sergi/go-diff v1.1.0 // indirect
34 | github.com/tidwall/gjson v1.6.0
35 | github.com/yudai/gojsondiff v1.0.0
36 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
37 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
38 | golang.org/x/image v0.0.0-20200618115811-c13761719519 // indirect
39 | gopkg.in/antage/eventsource.v1 v1.0.0-20150318155416-803f4c5af225
40 | gopkg.in/redis.v5 v5.2.9
41 | gopkg.in/urfave/cli.v1 v1.20.0
42 | )
43 |
--------------------------------------------------------------------------------
/static/rings.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
14 |
18 |
19 |
20 |
25 |
29 |
33 |
34 |
35 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/static/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --lightgrey: #efeaec;
3 | --grey: #9ea3b0;
4 | --softblue: #586ba4;
5 | --darkblue: #324376;
6 | --yellow: #f5dd90;
7 | }
8 |
9 | * {
10 | box-sizing: border-box;
11 | }
12 | html {
13 | margin: 0;
14 | padding: 0;
15 | font-family: monospace;
16 | font-size: 150%;
17 | }
18 | body {
19 | width: 1050px;
20 | padding: 40px 20px;
21 | margin: auto;
22 | }
23 | a {
24 | cursor: pointer;
25 | color: var(--darkblue);
26 | text-decoration: none;
27 | }
28 | a:hover {
29 | text-decoration: underline;
30 | }
31 | textarea {
32 | background: transparent;
33 | white-space: pre-wrap;
34 | word-wrap: break-word;
35 | width: 100%;
36 | }
37 | input,
38 | textarea {
39 | padding: 9px;
40 | border: 1px solid;
41 | background: #fff;
42 | }
43 | .center {
44 | display: flex;
45 | flex-direction: column;
46 | justify-content: center;
47 | align-items: center;
48 | }
49 |
50 | .toastify { max-width: 100%; }
51 | .toastify pre {
52 | white-space: pre-wrap;
53 | word-wrap: break-word;
54 | }
55 | .toast-info { background: #726edb !important; }
56 | .toast-warning { background: #cfdb6e !important; }
57 | .toast-error { background: #db6e6e!important; }
58 | .toast-success { background: #6edb91!important; }
59 |
60 | .djt-Content { text-align: left; }
61 | .djt-Property_Key,
62 | .djt-Property_Type {
63 | white-space: pre-wrap;
64 | word-wrap: break-word;
65 | word-break: break-all;
66 | }
67 |
68 | article {
69 | background: var(--lightgrey);
70 | padding: 3px 9px;
71 | transition: 300ms ease-in background-color;
72 | }
73 | article:hover { background-color: white; }
74 | article a {
75 | color: #25aa7b;
76 | text-decoration: underline;
77 | }
78 | article a.header-anchor {
79 | text-decoration: none;
80 | }
81 | article code,
82 | article pre {
83 | background: #ffeaea;
84 | }
85 | article code {
86 | padding: 3px;
87 | line-height: calc(1em + 10px);
88 | }
89 | article pre { padding: 8px; }
90 |
--------------------------------------------------------------------------------
/client/Markdown.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
52 |
53 |
76 |
77 |
81 |
82 | ↫
83 |
84 |
85 |
--------------------------------------------------------------------------------
/stream.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/gorilla/mux"
9 | "gopkg.in/antage/eventsource.v1"
10 | )
11 |
12 | type ctevent struct {
13 | Id string `json:"id"`
14 | ContractId string `json:"contract_id,omitempty"`
15 | Method string `json:"method,omitempty"`
16 | Message string `json:"message,omitempty"`
17 | Kind string `json:"kind,omitempty"`
18 | }
19 |
20 | func dispatchContractEvent(contractId string, ev ctevent, typ string) {
21 | jpayload, _ := json.Marshal(ev)
22 | payload := string(jpayload)
23 |
24 | if ies, ok := contractstreams.Get(contractId); ok {
25 | ies.(eventsource.EventSource).SendEventMessage(payload, typ, "")
26 | }
27 | }
28 |
29 | func contractStream(w http.ResponseWriter, r *http.Request) {
30 | ctid := mux.Vars(r)["ctid"]
31 |
32 | var es eventsource.EventSource
33 | ies, ok := contractstreams.Get(ctid)
34 |
35 | if !ok {
36 | es = eventsource.New(
37 | &eventsource.Settings{
38 | Timeout: 5 * time.Second,
39 | CloseOnTimeout: true,
40 | IdleTimeout: 1 * time.Minute,
41 | },
42 | func(r *http.Request) [][]byte {
43 | return [][]byte{
44 | []byte("X-Accel-Buffering: no"),
45 | []byte("Cache-Control: no-cache"),
46 | []byte("Content-Type: text/event-stream"),
47 | []byte("Connection: keep-alive"),
48 | []byte("Access-Control-Allow-Origin: *"),
49 | }
50 | },
51 | )
52 | go func() {
53 | for {
54 | time.Sleep(25 * time.Second)
55 | es.SendEventMessage("", "keepalive", "")
56 | }
57 | }()
58 | contractstreams.Set(ctid, es)
59 | } else {
60 | es = ies.(eventsource.EventSource)
61 | }
62 |
63 | go func() {
64 | time.Sleep(1 * time.Second)
65 | es.SendRetryMessage(3 * time.Second)
66 | }()
67 |
68 | es.ServeHTTP(w, r)
69 | }
70 |
71 | type callPrinter struct {
72 | ContractId string
73 | CallId string
74 | Method string
75 | }
76 |
77 | func (cp *callPrinter) Write(data []byte) (n int, err error) {
78 | dispatchContractEvent(cp.ContractId, ctevent{cp.CallId, cp.ContractId, cp.Method, string(data), "print"}, "call-run-event")
79 | return len(data), nil
80 | }
81 |
--------------------------------------------------------------------------------
/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/jmoiron/sqlx/types"
7 | )
8 |
9 | type Contract struct {
10 | Id string `db:"id" json:"id"` // used in the invoice label
11 | Code string `db:"code" json:"code,omitempty"`
12 | Name string `db:"name" json:"name"`
13 | Readme string `db:"readme" json:"readme"`
14 | State types.JSONText `db:"state" json:"state,omitempty"`
15 | CreatedAt time.Time `db:"created_at" json:"created_at"`
16 |
17 | Funds int64 `db:"funds" json:"funds"` // contract balance in msats
18 | NCalls int `db:"ncalls" json:"ncalls,omitempty"`
19 | Methods []Method `db:"-" json:"methods"`
20 | }
21 |
22 | type Method struct {
23 | Name string `json:"name"`
24 | Params []string `json:"params"`
25 | Auth bool `json:"auth"`
26 | }
27 |
28 | const CONTRACTFIELDS = "id, code, name, readme, state, created_at"
29 |
30 | type Call struct {
31 | Id string `db:"id" json:"id"` // used in the invoice label
32 | Time time.Time `db:"time" json:"time"`
33 | ContractId string `db:"contract_id" json:"contract_id"`
34 | Method string `db:"method" json:"method"`
35 | Payload types.JSONText `db:"payload" json:"payload"`
36 | Msatoshi int64 `db:"msatoshi" json:"msatoshi"` // msats to be added to the contract
37 | Cost int64 `db:"cost" json:"cost"` // msats to be paid to the platform
38 | Caller string `db:"caller" json:"caller"`
39 | Diff string `db:"diff" json:"diff"`
40 | Transfers types.JSONText `db:"transfers" json:"transfers"`
41 | Ran bool `db:"ran" json:"ran"`
42 | }
43 |
44 | const CALLFIELDS = "id, time, contract_id, method, payload, msatoshi, cost, coalesce(caller, '') AS caller, transfers(id, contract_id) AS transfers, true AS ran"
45 |
46 | type Account struct {
47 | Id string `db:"id" json:"id"`
48 | Balance int64 `db:"balance" json:"balance"`
49 | }
50 |
51 | const ACCOUNTFIELDS = "id, balance(id)"
52 |
53 | type AccountHistoryEntry struct {
54 | Time time.Time `db:"time" json:"time"`
55 | Msatoshi int64 `db:"msatoshi" json:"msatoshi"`
56 | Counterparty string `db:"counterparty" json:"counterparty"`
57 | }
58 |
59 | const ACCOUNTHISTORYFIELDS = "time, msatoshi, counterparty"
60 |
61 | type StuffBeingCreated struct {
62 | Id string `json:"id"`
63 | Invoice string `json:"invoice"`
64 | }
65 |
--------------------------------------------------------------------------------
/client/Call.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
22 |
44 |
45 |
46 | {call.id} | etleneum call
47 |
48 |
49 | {#if !call.contract_id}
50 | loading
51 | {:else}
52 |
53 |
54 |
55 | call {call.id}
56 |
57 |
58 |
59 | id
60 | {call.id}
61 |
62 |
63 | contract
64 | {call.contract_id}
65 |
66 |
67 | time
68 | {call.time.split('T').join(' ').replace(/\..*/, '')}
69 |
70 |
71 | caller
72 | {call.caller}
73 |
74 |
75 | method
76 | {call.method}
77 |
78 |
79 | payload
80 |
81 |
82 |
83 | diff
84 | {call.diff}
85 |
86 |
87 | transfers
88 |
89 |
90 | {#each call.transfers as transfer}
91 |
92 | {parseInt(transfer.msatoshi/1000)}sat
93 | {transfer.direction === 'out' ? 'to' : 'from'}
94 | {transfer.counterparty}
95 |
96 | {/each}
97 |
98 |
99 |
100 |
101 |
102 |
103 | {/if}
104 |
--------------------------------------------------------------------------------
/client/Create.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
62 |
63 |
89 |
90 | {#if invoice}
91 |
92 |
93 |
pay to enable the contract
94 |
95 | {:else}
96 |
102 | {/if}
103 |
--------------------------------------------------------------------------------
/contract_functions.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "regexp"
6 | "strings"
7 | "time"
8 |
9 | "github.com/aarzilli/golua/lua"
10 | "github.com/fiatjaf/etleneum/types"
11 | )
12 |
13 | func contractFromRedis(ctid string) (ct *types.Contract, err error) {
14 | var jct []byte
15 | ct = &types.Contract{}
16 |
17 | jct, err = rds.Get("contract:" + ctid).Bytes()
18 | if err != nil {
19 | return
20 | }
21 |
22 | err = json.Unmarshal(jct, ct)
23 | if err != nil {
24 | return
25 | }
26 |
27 | return
28 | }
29 |
30 | var (
31 | functionRe = regexp.MustCompile(`^function +([^_][\w_]+) *\(`)
32 | paramRe = regexp.MustCompile(`\bcall.payload\.([\w_]+)`)
33 | authRe = regexp.MustCompile(`\b(account.send|account.id|account.get_balance)\b`)
34 | endRe = regexp.MustCompile(`^end\b`)
35 | )
36 |
37 | func parseContractCode(ct *types.Contract) {
38 | lines := strings.Split(ct.Code, "\n")
39 |
40 | var currentMethod *types.Method
41 | var params map[string]bool
42 | for _, line := range lines {
43 | if matches := functionRe.FindStringSubmatch(line); len(matches) == 2 {
44 | currentMethod = &types.Method{
45 | Name: matches[1],
46 | Params: make([]string, 0, 3),
47 | }
48 | params = make(map[string]bool)
49 | }
50 |
51 | if currentMethod == nil {
52 | continue
53 | }
54 |
55 | if authRe.MatchString(line) {
56 | currentMethod.Auth = true
57 | }
58 |
59 | matches := paramRe.FindAllStringSubmatch(line, -1)
60 | for _, match := range matches {
61 | params[match[1]] = true
62 | }
63 |
64 | if endRe.MatchString(line) {
65 | for param, _ := range params {
66 | currentMethod.Params = append(currentMethod.Params, param)
67 | }
68 |
69 | ct.Methods = append(ct.Methods, *currentMethod)
70 | currentMethod = nil
71 | params = nil
72 | }
73 | }
74 | }
75 |
76 | func checkContractCode(code string) (ok bool) {
77 | if strings.Index(code, "function __init__") == -1 {
78 | return false
79 | }
80 |
81 | L := lua.NewState()
82 | defer L.Close()
83 |
84 | lerr := L.LoadString(code)
85 | if lerr != 0 {
86 | return false
87 | }
88 |
89 | return true
90 | }
91 |
92 | func getContractCost(ct types.Contract) int64 {
93 | words := int64(len(wordMatcher.FindAllString(ct.Code, -1)))
94 | return 1000*s.InitialContractCostSatoshis + 1000*words
95 | }
96 |
97 | func saveContractOnRedis(ct types.Contract) (jct []byte, err error) {
98 | jct, err = json.Marshal(ct)
99 | if err != nil {
100 | return
101 | }
102 |
103 | err = rds.Set("contract:"+ct.Id, jct, time.Hour*20).Err()
104 | if err != nil {
105 | return
106 | }
107 |
108 | return
109 | }
110 |
--------------------------------------------------------------------------------
/client/App.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
29 |
30 |
64 |
65 |
66 |
67 | list contracts
68 | create
69 |
70 | {#if $account.id}acct:{$account.id} {:else}login{/if}
72 |
73 | docs
74 |
75 |
76 |
94 |
--------------------------------------------------------------------------------
/client/accountStore.js:
--------------------------------------------------------------------------------
1 | /** @format */
2 |
3 | import {readable} from 'svelte/store'
4 | import hmac from 'hmac'
5 | import shajs from 'sha.js'
6 |
7 | import * as toast from './toast'
8 |
9 | function getInitial() {
10 | return {
11 | lnurl: {auth: null, withdraw: null},
12 | session: window.localStorage.getItem('auth-session') || null,
13 | id: null,
14 | balance: 0,
15 | secret: '',
16 | history: []
17 | }
18 | }
19 |
20 | var current = getInitial()
21 | var es
22 | var storeSet = () => {}
23 |
24 | const account = readable(current, set => {
25 | storeSet = set
26 | startEventSource()
27 |
28 | return () => {
29 | es.close()
30 | }
31 | })
32 |
33 | account.refresh = function() {
34 | window.fetch('/~/refresh?session=' + current.session)
35 | }
36 |
37 | account.reset = function() {
38 | if (es) {
39 | es.close()
40 | }
41 |
42 | window.localStorage.removeItem('auth-session')
43 | current = getInitial()
44 | storeSet(current)
45 |
46 | startEventSource()
47 | }
48 |
49 | function startEventSource() {
50 | es = new window.EventSource(
51 | '/~~~/session?src=store&session=' + (current.session ? current.session : '')
52 | )
53 | es.onerror = e => console.log('accountstore sse error', e.data)
54 | es.addEventListener('lnurls', e => {
55 | let data = JSON.parse(e.data)
56 | current = {...current, lnurl: data}
57 | storeSet(current)
58 | })
59 | es.addEventListener('auth', e => {
60 | let data = JSON.parse(e.data)
61 | current = {
62 | ...current,
63 | session: data.session || current.session,
64 | id: data.account,
65 | balance: data.balance,
66 | secret: data.secret
67 | }
68 | storeSet(current)
69 |
70 | if (data.session) {
71 | window.localStorage.setItem('auth-session', data.session)
72 | }
73 | })
74 | es.addEventListener('history', e => {
75 | let data = JSON.parse(e.data)
76 | current.history = data
77 | storeSet(current)
78 | })
79 | es.addEventListener('withdraw', e => {
80 | let data = JSON.parse(e.data)
81 | current = {...current, balance: data.new_balance}
82 | storeSet(current)
83 | })
84 | es.addEventListener('error', e => {
85 | toast.error(e.data)
86 | })
87 | }
88 |
89 | export default account
90 |
91 | export function hmacCall(contractId, call) {
92 | var res = `${contractId}:${call.method}:${call.msatoshi},`
93 |
94 | var keys = Object.keys(call.payload).sort()
95 | for (let i = 0; i < keys.length; i++) {
96 | let k = keys[i]
97 | let v = call.payload[k]
98 | res += `${k}=${v}`
99 | res += ','
100 | }
101 |
102 | return hmac(() => shajs('sha256'), 64, current.secret)
103 | .update(res, 'utf8')
104 | .digest('hex')
105 | }
106 |
--------------------------------------------------------------------------------
/runlua/keybase.go:
--------------------------------------------------------------------------------
1 | package runlua
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io/ioutil"
7 | "net/http"
8 | "net/url"
9 | "regexp"
10 | "strings"
11 |
12 | "github.com/tidwall/gjson"
13 | "golang.org/x/crypto/openpgp"
14 | openpgperrors "golang.org/x/crypto/openpgp/errors"
15 | )
16 |
17 | func lua_keybase_lookup(provider, name string) (username string, err error) {
18 | params := url.Values{}
19 | params.Set("fields", "basics")
20 | params.Set(provider, name)
21 | url := "https://keybase.io/_/api/1.0/user/lookup.json"
22 | resp, err := http.Get(url + "?" + params.Encode())
23 | if err != nil {
24 | log.Print(err)
25 | return "", err
26 | }
27 | defer resp.Body.Close()
28 |
29 | b, err := ioutil.ReadAll(resp.Body)
30 | if err != nil {
31 | log.Print(err)
32 | return "", err
33 | }
34 |
35 | gjson.GetBytes(b, "them").ForEach(func(_, match gjson.Result) bool {
36 | username = match.Get("basics.username").String()
37 | return false
38 | })
39 |
40 | log.Print(username)
41 | return username, nil
42 | }
43 |
44 | func lua_keybase_verify_signature(username, text, sig string) (ok bool, err error) {
45 | resp, err := http.Get("https://keybase.io/" + username + "/pgp_keys.asc")
46 | if err != nil {
47 | return false, err
48 | }
49 | defer resp.Body.Close()
50 |
51 | if resp.StatusCode != 200 {
52 | return false, fmt.Errorf("keybase returned status code %d", resp.StatusCode)
53 | }
54 |
55 | keyring, err := openpgp.ReadArmoredKeyRing(resp.Body)
56 | if err != nil {
57 | return false, err
58 | }
59 |
60 | sig, err = getSignatureBlockFromBundle(sig)
61 | if err != nil {
62 | return false, err
63 | }
64 |
65 | verification_target := strings.NewReader(text)
66 | signature := strings.NewReader(sig)
67 |
68 | _, err = openpgp.CheckArmoredDetachedSignature(keyring, verification_target, signature)
69 | if err != nil {
70 | if _, ok := err.(openpgperrors.SignatureError); ok {
71 | // this means the signature is wrong and not some kind of operational error
72 | return false, nil
73 | }
74 |
75 | return false, err
76 | }
77 |
78 | return true, nil
79 | }
80 |
81 | func lua_keybase_verify_bundle(username, bundle string) (ok bool, err error) {
82 | sig, err := getSignatureBlockFromBundle(bundle)
83 | if err != nil {
84 | return false, err
85 | }
86 | text, err := getSignedMessageFromBundle(bundle)
87 | if err != nil {
88 | return false, err
89 | }
90 |
91 | return lua_keybase_verify_signature(username, text, sig)
92 | }
93 |
94 | func lua_keybase_extract_message(bundle string) (message string) {
95 | message, _ = getSignedMessageFromBundle(bundle)
96 | return
97 | }
98 |
99 | var signedMessageRe = regexp.MustCompile(`-----BEGIN PGP SIGNED MESSAGE-----\n(\w.*\n)*\n((.*\n?)*)\n-----BEGIN`)
100 |
101 | func getSignedMessageFromBundle(bundle string) (message string, err error) {
102 | matches := signedMessageRe.FindStringSubmatch(bundle)
103 | if len(matches) != 4 {
104 | return "", errors.New("failed to find signed message in block")
105 | }
106 | return strings.TrimSpace(matches[2]), nil
107 | }
108 |
109 | func getSignatureBlockFromBundle(bundle string) (signatureBlock string, err error) {
110 | index := strings.Index(bundle, "-----BEGIN PGP SIGNATURE-----")
111 | if index == -1 {
112 | return "", errors.New("block doesn't contain a signature")
113 | }
114 | return bundle[index:], nil
115 | }
116 |
--------------------------------------------------------------------------------
/runlua/contract_lua_functions.go:
--------------------------------------------------------------------------------
1 | package runlua
2 |
3 | import (
4 | "bytes"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "encoding/json"
8 | "errors"
9 | "io/ioutil"
10 | "net/http"
11 | "strconv"
12 |
13 | decodepay "github.com/fiatjaf/ln-decodepay"
14 | )
15 |
16 | func make_lua_http(makeRequest func(*http.Request) (*http.Response, error)) (
17 | lua_http_gettext func(string, ...map[string]interface{}) (string, error),
18 | lua_http_getjson func(string, ...map[string]interface{}) (interface{}, error),
19 | lua_http_postjson func(string, interface{}, ...map[string]interface{}) (interface{}, error),
20 | calls_p *int,
21 | ) {
22 | calls := 0
23 | calls_p = &calls
24 |
25 | http_call := func(method, url string, body interface{}, headers ...map[string]interface{}) (b []byte, err error) {
26 | log.Debug().Str("method", method).Interface("body", body).Str("url", url).Msg("http call from contract")
27 |
28 | bodyjson := new(bytes.Buffer)
29 | if body != nil {
30 | err = json.NewEncoder(bodyjson).Encode(body)
31 | if err != nil {
32 | log.Warn().Err(err).Msg("http: failed to encode body")
33 | return
34 | }
35 | headers = append([]map[string]interface{}{{"Content-Type": "application/json"}}, headers...)
36 | }
37 |
38 | req, err := http.NewRequest(method, url, bodyjson)
39 | if err != nil {
40 | log.Warn().Err(err).Msg("http: failed to create request")
41 | return
42 | }
43 | defer req.Body.Close()
44 |
45 | for _, headermap := range headers {
46 | for k, v := range headermap {
47 | if sv, ok := v.(string); ok {
48 | req.Header.Set(k, sv)
49 | }
50 | }
51 | }
52 |
53 | resp, err := makeRequest(req)
54 | if err != nil {
55 | log.Warn().Err(err).Msg("http: failed to make request")
56 | return
57 | }
58 |
59 | if resp.StatusCode >= 300 {
60 | log.Debug().Err(err).Int("code", resp.StatusCode).Msg("http: got bad status")
61 | err = errors.New("response status code: " + strconv.Itoa(resp.StatusCode))
62 | return
63 | }
64 |
65 | b, err = ioutil.ReadAll(resp.Body)
66 | if err != nil {
67 | log.Warn().Err(err).Msg("http: failed to read body")
68 | return
69 | }
70 |
71 | return b, nil
72 | }
73 |
74 | lua_http_gettext = func(url string, headers ...map[string]interface{}) (t string, err error) {
75 | respbytes, err := http_call("GET", url, nil, headers...)
76 | if err != nil {
77 | return "", err
78 | }
79 | return string(respbytes), nil
80 | }
81 |
82 | lua_http_getjson = func(url string, headers ...map[string]interface{}) (j interface{}, err error) {
83 | respbytes, err := http_call("GET", url, nil, headers...)
84 | if err != nil {
85 | return nil, err
86 | }
87 |
88 | var value interface{}
89 | err = json.Unmarshal(respbytes, &value)
90 | if err != nil {
91 | return nil, err
92 | }
93 |
94 | return value, nil
95 | }
96 |
97 | lua_http_postjson = func(url string, body interface{}, headers ...map[string]interface{}) (j interface{}, err error) {
98 | respbytes, err := http_call("POST", url, body, headers...)
99 | if err != nil {
100 | return nil, err
101 | }
102 |
103 | var value interface{}
104 | err = json.Unmarshal(respbytes, &value)
105 | if err != nil {
106 | return nil, err
107 | }
108 |
109 | return value, nil
110 | }
111 |
112 | return
113 | }
114 |
115 | func lua_sha256(preimage string) (hash string, err error) {
116 | h := sha256.New()
117 | _, err = h.Write([]byte(preimage))
118 | if err != nil {
119 | return "", err
120 | }
121 | hash = hex.EncodeToString(h.Sum(nil))
122 | return hash, nil
123 | }
124 |
125 | func lua_parse_bolt11(bolt11 string) (map[string]interface{}, error) {
126 | inv, err := decodepay.Decodepay(bolt11)
127 | if err != nil {
128 | return nil, err
129 | }
130 | jinv, _ := json.Marshal(inv)
131 | minv := make(map[string]interface{})
132 | json.Unmarshal(jinv, &minv)
133 | return minv, nil
134 | }
135 |
--------------------------------------------------------------------------------
/client/Home.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
41 |
42 |
60 |
61 |
62 |
63 | {#if visible}
64 |
71 |
75 | {name}
76 |
77 | {/if}
78 |
79 |
80 | Etleneum is a global, open-source platform for dethe centralized applications.
82 |
83 |
84 | On Etleneum you can write code that controls digital value, runs exactly as
85 | programmed, and is accessible anywhere in the world.
86 |
87 |
88 |
89 | Etleneum in 2 minutes
90 |
91 | Etleneum is not just a pun with Ethereum, it's a real smart contract
92 | platform. You can build publicly auditable and trusted applications that
93 | run custom code, can talk to other services and are accessible through an
94 | API, all using a built-in user account system (optional) and real
95 | Lightning payments.
96 |
97 |
98 |
99 | Above you see a graphical example of a contract with two methods:
100 | bet and resolve . Account 74 made a bet with account
101 | 12 when both called the bet method (details of the contract
102 | and calls are hidden for brevity). Then later an anonymous oracle called
103 | resolve and settled the bet. Account 12 ended up with all
104 | the satoshis.
105 |
106 |
107 | Contracts are just that: a set of methods , some funds and a
108 | JSON state . Calls can be identified or not, and it
109 | can contain satoshis or not. Each call modifies the state in
110 | a certain way and can also transfer funds from the contract to an
111 | account.
112 |
113 |
114 |
115 |
116 | Read the docs {#if window.location.host ===
117 | 'etleneum.com'} and start creating some free contracts at the
118 | test website {/if}.
119 |
120 |
121 | {#if window.location.host === 'etleneum.com'}
122 |
123 | If you need help, have questions about Etleneum or any of the contracts, go
124 | talk to us at
125 | our Telegram chat . Also follow
126 | @etleneum2 on Twitter.
127 |
128 | {:else}
129 |
130 | This is a test website. You can create contracts and run calls for free.
131 | Whenever you see an invoice, just wait and it will be paid automatically
132 | after some seconds. You can login and have a balance, but withdrawals are
133 | impossible.
134 |
135 | {/if}
136 |
137 |
--------------------------------------------------------------------------------
/client/Account.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
71 |
72 |
105 |
106 |
107 | {#if $account.id}
108 |
Logged as {$account.id} .
109 |
Can withdraw {($account.balance / 1000).toFixed(3)} satoshi.
110 | {#if $account.balance > 0 && $account.lnurl.withdraw}
111 |
112 |
Scan to withdraw.
113 | {/if}
114 |
logout
115 | {#if $account.history.length}
116 |
117 |
118 | Transaction history
119 |
120 | {#each $account.history as entry}
121 |
122 | {entry.time.split('T').join(' ').replace(/\..*/, '')}
123 | {entry.msatoshi / 1000}sat
124 |
125 | {#if entry.counterparty[0] == 'c'}
126 |
127 | {entry.counterparty}
128 |
129 | {:else} {entry.counterparty} {/if}
130 |
131 |
132 | {/each}
133 |
134 |
135 | {/if} {:else if awaitingSeedAuth}
136 |
137 | Waiting for login on popup {:else if $account.lnurl.auth}
138 |
lnurl login
139 |
140 |
141 | Scan/click with
142 | BLW or
143 | scan/copy-paste to
144 | @lntxbot to login.
145 |
146 |
147 | Or
148 | login with username and password .
154 |
155 | {/if}
156 |
157 |
--------------------------------------------------------------------------------
/postgres.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE kv (
2 | k text PRIMARY KEY,
3 | v jsonb NOT NULL
4 | );
5 |
6 | CREATE TABLE accounts (
7 | id text PRIMARY KEY,
8 | lnurl_key text UNIQUE NOT NULL
9 | );
10 |
11 | CREATE TABLE contracts (
12 | id text PRIMARY KEY, -- prefix to turn into the init invoice label
13 | name text NOT NULL DEFAULT '',
14 | readme text NOT NULL DEFAULT '',
15 | code text NOT NULL,
16 | state jsonb NOT NULL DEFAULT '{}',
17 | created_at timestamp NOT NULL DEFAULT now(),
18 |
19 | CONSTRAINT state_is_object CHECK (jsonb_typeof(state) = 'object'),
20 | CONSTRAINT code_exists CHECK (code != '')
21 | );
22 |
23 | CREATE TABLE calls (
24 | id text PRIMARY KEY,
25 | time timestamp NOT NULL DEFAULT now(),
26 | contract_id text NOT NULL REFERENCES contracts (id) ON DELETE CASCADE,
27 | method text NOT NULL,
28 | payload jsonb NOT NULL DEFAULT '{}',
29 | msatoshi numeric(13) NOT NULL DEFAULT 0,
30 | cost numeric(13) NOT NULL,
31 | caller text REFERENCES accounts(id),
32 | diff text,
33 |
34 | CONSTRAINT method_exists CHECK (method != ''),
35 | CONSTRAINT cost_positive CHECK (method = '__init__' OR cost > 0),
36 | CONSTRAINT caller_not_blank CHECK (caller != ''),
37 | CONSTRAINT msatoshi_not_negative CHECK (msatoshi >= 0)
38 | );
39 |
40 | CREATE INDEX IF NOT EXISTS idx_calls_by_contract ON calls (contract_id, time);
41 |
42 | CREATE TABLE internal_transfers (
43 | call_id text NOT NULL REFERENCES calls (id),
44 | time timestamp NOT NULL DEFAULT now(),
45 | msatoshi numeric(13) NOT NULL,
46 | from_contract text REFERENCES contracts(id),
47 | from_account text REFERENCES accounts(id),
48 | to_account text REFERENCES accounts(id),
49 | to_contract text REFERENCES contracts(id),
50 |
51 | CONSTRAINT one_receiver CHECK (
52 | (to_contract IS NOT NULL AND to_contract != '' AND to_account IS NULL) OR
53 | (to_contract IS NULL AND to_account IS NOT NULL AND to_account != '')
54 | ),
55 | CONSTRAINT one_sender CHECK (
56 | (from_contract IS NOT NULL AND from_contract != '' AND from_account IS NULL) OR
57 | (from_contract IS NULL AND from_account IS NOT NULL AND from_account != '')
58 | )
59 | );
60 |
61 | CREATE INDEX IF NOT EXISTS idx_internal_transfers_from_contract ON internal_transfers (from_contract);
62 | CREATE INDEX IF NOT EXISTS idx_internal_transfers_to_contract ON internal_transfers (to_contract);
63 | CREATE INDEX IF NOT EXISTS idx_internal_transfers_from_account ON internal_transfers (from_account);
64 | CREATE INDEX IF NOT EXISTS idx_internal_transfers_to_account ON internal_transfers (to_account);
65 |
66 | CREATE OR REPLACE FUNCTION funds(contract contracts) RETURNS numeric(13) AS $$
67 | SELECT (
68 | SELECT coalesce(sum(msatoshi), 0)
69 | FROM calls WHERE calls.contract_id = contract.id
70 | ) - (
71 | SELECT coalesce(sum(msatoshi), 0)
72 | FROM internal_transfers WHERE from_contract = contract.id
73 | ) + (
74 | SELECT coalesce(sum(msatoshi), 0)
75 | FROM internal_transfers WHERE to_contract = contract.id
76 | );
77 | $$ LANGUAGE SQL;
78 |
79 | CREATE TABLE withdrawals (
80 | account_id text NOT NULL REFERENCES accounts(id),
81 | time timestamp NOT NULL DEFAULT now(),
82 | msatoshi numeric(13) NOT NULL,
83 | fee_msat int NOT NULL DEFAULT 0,
84 | fulfilled bool NOT NULL,
85 | bolt11 text NOT NULL
86 | );
87 |
88 | CREATE OR REPLACE VIEW account_history (
89 | time,
90 | account_id,
91 | msatoshi,
92 | counterparty
93 | ) AS
94 | SELECT * FROM (
95 | SELECT time, from_account, -msatoshi, coalesce(to_contract, to_account)
96 | FROM internal_transfers
97 | WHERE from_account IS NOT NULL
98 | UNION ALL
99 | SELECT time, to_account, msatoshi, coalesce(from_contract, from_account)
100 | FROM internal_transfers
101 | WHERE to_account IS NOT NULL
102 | UNION ALL
103 | SELECT time, account_id, -msatoshi-fee_msat, 'withdrawal'
104 | FROM withdrawals
105 | )u ORDER BY time DESC
106 | ;
107 |
108 | CREATE OR REPLACE FUNCTION balance(id text) RETURNS numeric(13) AS $$
109 | SELECT (coalesce(sum(msatoshi), 0) * 0.997)::numeric(13)
110 | FROM account_history
111 | WHERE account_id = id;
112 | $$ LANGUAGE SQL;
113 |
114 | CREATE OR REPLACE FUNCTION transfers(call text, contract text) RETURNS jsonb AS $$
115 | SELECT coalesce(jsonb_agg(transfers), '[]'::jsonb)
116 | FROM
117 | (
118 | SELECT 'out' AS direction, msatoshi,
119 | CASE WHEN to_account IS NOT NULL THEN to_account ELSE to_contract END AS counterparty
120 | FROM internal_transfers
121 | WHERE internal_transfers.call_id = call
122 | AND internal_transfers.from_contract = contract
123 | UNION ALL
124 | SELECT 'in' AS direction, msatoshi,
125 | CASE WHEN from_account IS NOT NULL THEN from_account ELSE from_contract END AS counterparty
126 | FROM internal_transfers
127 | WHERE internal_transfers.call_id = call
128 | AND internal_transfers.to_contract = contract
129 | ) AS transfers
130 | $$ LANGUAGE SQL;
131 |
--------------------------------------------------------------------------------
/lightning.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "fmt"
8 | "strconv"
9 | "strings"
10 | "time"
11 |
12 | "github.com/btcsuite/btcd/btcec"
13 | "github.com/btcsuite/btcd/chaincfg"
14 | "github.com/lightningnetwork/lnd/lnwire"
15 | "github.com/lightningnetwork/lnd/zpay32"
16 | )
17 |
18 | const BOGUS_INVOICE = "lnbcrt1231230p1pwccq4app53nrqyuwmhkcsqqq8qnqvka0njqt0q0w9ujjlu565yumcgjya7m7qdp8vakx7cnpdss8wctjd45kueeqd9ejqcfqdphkz7qxqgzay8dellcqp2r34dm702mtt9luaeuqfza47ltalrwk8jrwalwf5ncrkgm6v6kmm3cuwuhyhtkpyzzmxun8qz9qtx6hvwfltqnd6wvpkch2u3acculmqpk4d20k"
19 |
20 | var BOGUS_SECRET = [32]byte{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}
21 |
22 | func makeInvoice(
23 | ctid string, // pubkey is based on this
24 | id string, // call or contract id: scid and preimage based on this
25 | desc string,
26 | deschash *[32]byte,
27 | main_price int64, // in msatoshi
28 | cost int64, // will be added as routing fees in the last channel
29 | ) (bolt11 string, err error) {
30 | sk, _ := makeKeys(ctid)
31 | preimage := makePreimage(id)
32 | channelid := makeShortChannelId(id)
33 |
34 | nodeid, _ := hex.DecodeString(s.NodeId)
35 | ournodeid, err := btcec.ParsePubKey(nodeid, btcec.S256())
36 | if err != nil {
37 | return "", fmt.Errorf("error parsing our own nodeid: %w", err)
38 | }
39 |
40 | var addDescription func(*zpay32.Invoice)
41 | if deschash != nil {
42 | addDescription = zpay32.DescriptionHash(*deschash)
43 | } else {
44 | addDescription = zpay32.Description(desc)
45 | }
46 |
47 | invoice, err := zpay32.NewInvoice(
48 | &chaincfg.Params{Bech32HRPSegwit: "bc"},
49 | sha256.Sum256(preimage),
50 | time.Now(),
51 | zpay32.RouteHint([]zpay32.HopHint{
52 | zpay32.HopHint{
53 | NodeID: ournodeid,
54 | ChannelID: channelid,
55 | FeeBaseMSat: uint32(cost),
56 | FeeProportionalMillionths: 0,
57 | CLTVExpiryDelta: 2,
58 | },
59 | }),
60 | zpay32.Amount(lnwire.MilliSatoshi(main_price)),
61 | zpay32.Expiry(time.Hour*24),
62 | zpay32.Features(&lnwire.FeatureVector{
63 | RawFeatureVector: lnwire.NewRawFeatureVector(
64 | lnwire.PaymentAddrOptional,
65 | lnwire.TLVOnionPayloadOptional,
66 | ),
67 | }),
68 | zpay32.PaymentAddr(BOGUS_SECRET),
69 | addDescription,
70 | )
71 |
72 | return invoice.Encode(zpay32.MessageSigner{
73 | SignCompact: func(hash []byte) ([]byte, error) {
74 | return btcec.SignCompact(btcec.S256(), sk, hash, true)
75 | },
76 | })
77 | }
78 |
79 | var SHORT_CHANNEL_ID_CHARACTERS = []uint8{'_', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
80 |
81 | // makeShortChannelId turns a call or contract id into a short_channel_id (64 bits)
82 | func makeShortChannelId(id string) (scid uint64) {
83 | // we use 61 of the 64 bits available for this
84 | // the first 3 bits [63, 62, 61] are blank
85 |
86 | // the bit 60 is used to identify if this is a call (r) or contract (c).
87 | var typebit uint64
88 | if id[0] == 'c' {
89 | typebit = 1
90 | } else {
91 | typebit = 0
92 | }
93 | scid = scid | typebit<<60
94 |
95 | // then we fit the rest of letters and digits into a 6-bit custom encoding,
96 | id = id[1:]
97 |
98 | // so there are room for 10 characters, which is what we need to fit a cuid slug.
99 | // since the cuid slug can be between 7 and 10, we also accomodate for blank
100 | // strings at the end by having an empty character ('_') encoded in 6 bits too.
101 | arreda := 60
102 | for _, letter := range []byte(id) {
103 | n := bytes.Index(SHORT_CHANNEL_ID_CHARACTERS, []uint8{letter})
104 | arreda -= 6
105 | scid = scid | uint64(n)<> 60) & 1
114 | if typebit == 0 {
115 | id += "r"
116 | } else {
117 | id += "c"
118 | }
119 |
120 | for arreda := 60 - 6; arreda >= 0; arreda -= 6 {
121 | n := int((scid >> arreda) & 63)
122 |
123 | if n > len(SHORT_CHANNEL_ID_CHARACTERS)-1 {
124 | return "", false
125 | }
126 |
127 | letter := SHORT_CHANNEL_ID_CHARACTERS[n]
128 |
129 | if letter == '_' {
130 | continue
131 | }
132 |
133 | id += string([]uint8{letter})
134 | }
135 |
136 | return id, true
137 | }
138 |
139 | func encodeShortChannelId(scid uint64) string {
140 | block := strconv.FormatUint((scid>>40)&0xFFFFFF, 10)
141 | tx := strconv.FormatUint((scid>>16)&0xFFFFFF, 10)
142 | out := strconv.FormatUint(scid&0xFFFF, 10)
143 |
144 | return block + "x" + tx + "x" + out
145 | }
146 |
147 | func decodeShortChannelId(scid string) (uint64, error) {
148 | spl := strings.Split(scid, "x")
149 |
150 | x, err := strconv.ParseUint(spl[0], 10, 64)
151 | if err != nil {
152 | return 0, err
153 | }
154 | y, err := strconv.ParseUint(spl[1], 10, 64)
155 | if err != nil {
156 | return 0, err
157 | }
158 | z, err := strconv.ParseUint(spl[2], 10, 64)
159 | if err != nil {
160 | return 0, err
161 | }
162 |
163 | return ((x & 0xFFFFFF) << 40) | ((y & 0xFFFFFF) << 16) | (z & 0xFFFF), nil
164 | }
165 |
166 | func makeKeys(id string) (*btcec.PrivateKey, *btcec.PublicKey) {
167 | v := sha256.Sum256([]byte(s.SecretKey + ":" + id))
168 | return btcec.PrivKeyFromBytes(btcec.S256(), v[:])
169 | }
170 |
171 | func makePreimage(id string) []byte {
172 | v := sha256.Sum256([]byte(s.SecretKey + ":" + id))
173 | return v[:]
174 | }
175 |
--------------------------------------------------------------------------------
/runlua/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io/ioutil"
9 | "net/http"
10 | "os"
11 | "syscall"
12 |
13 | "github.com/fiatjaf/etleneum/runlua"
14 | "github.com/fiatjaf/etleneum/types"
15 | sqlxtypes "github.com/jmoiron/sqlx/types"
16 | "github.com/rs/zerolog"
17 | "gopkg.in/urfave/cli.v1"
18 | )
19 |
20 | var devNull = os.NewFile(uintptr(syscall.Stderr), "/dev/null")
21 | var log = zerolog.New(devNull).Output(zerolog.ConsoleWriter{Out: devNull})
22 |
23 | func main() {
24 | app := cli.NewApp()
25 | app.ErrWriter = os.Stderr
26 | app.Writer = os.Stdout
27 | app.Name = "runcall"
28 | app.Usage = "Run a call on an Etleneum contract."
29 | app.Flags = []cli.Flag{
30 | cli.StringFlag{
31 | Name: "contract",
32 | Usage: "File with the full lua code for the contract.",
33 | },
34 | cli.StringFlag{
35 | Name: "state",
36 | Value: "{}",
37 | Usage: "Current contract state as JSON string. Ignored when statefile is given.",
38 | },
39 | cli.StringFlag{
40 | Name: "statefile",
41 | Usage: "File with the initial JSON state which will be overwritten.",
42 | },
43 | cli.IntFlag{
44 | Name: "funds",
45 | Usage: "Contract will have this amount of funds (in satoshi).",
46 | },
47 | cli.StringFlag{
48 | Name: "caller",
49 | Usage: "Id of the account that is making the call.",
50 | },
51 | cli.StringFlag{
52 | Name: "method",
53 | Value: "__init__",
54 | Usage: "Contract method to run.",
55 | },
56 | cli.StringFlag{
57 | Name: "payload",
58 | Value: "{}",
59 | Usage: "Payload to send with the call as a JSON string.",
60 | },
61 | cli.Float64Flag{
62 | Name: "satoshis",
63 | Value: 0,
64 | Usage: "Satoshis to include in the call.",
65 | },
66 | cli.Int64Flag{
67 | Name: "msatoshi",
68 | Value: 0,
69 | Usage: "Msatoshi to include in the call.",
70 | },
71 | cli.StringSliceFlag{
72 | Name: "http",
73 | Usage: "HTTP response to mock. Can be called multiple times. Will return the multiple values in order to each HTTP call made by the contract.",
74 | },
75 | }
76 | app.Action = func(c *cli.Context) error {
77 | // contract code
78 | contractFile := c.String("contract")
79 | if contractFile == "" {
80 | fmt.Fprint(app.ErrWriter, "missing contract file.")
81 | os.Exit(1)
82 | }
83 | bcontractCode, err := ioutil.ReadFile(contractFile)
84 | if err != nil {
85 | fmt.Fprintf(app.ErrWriter, "failed to read contract file '%s'.", contractFile)
86 | os.Exit(1)
87 | }
88 |
89 | // http mock
90 | httpResponses := c.StringSlice("http")
91 | httpRespIndex := 0
92 | returnHttp := func(r *http.Request) (*http.Response, error) {
93 | if httpRespIndex < len(httpResponses) {
94 | // use a mock
95 | respText := httpResponses[httpRespIndex]
96 | body := bytes.NewBufferString(respText)
97 | w := &http.Response{
98 | Status: "200 OK",
99 | StatusCode: 200,
100 | Proto: "HTTP/1.0",
101 | ProtoMajor: 1,
102 | ProtoMinor: 0,
103 | Request: r,
104 | Body: ioutil.NopCloser(body),
105 | ContentLength: int64(body.Len()),
106 | }
107 | httpRespIndex++
108 | return w, nil
109 | }
110 | return http.DefaultClient.Do(r)
111 | }
112 |
113 | contractFunds := c.Int64("funds") * 1000
114 |
115 | var statejson []byte
116 | stateFile := c.String("statefile")
117 | if stateFile != "" {
118 | statejson, err = ioutil.ReadFile(stateFile)
119 | if err != nil {
120 | fmt.Fprintf(app.ErrWriter, "failed to read state file '%s'.", stateFile)
121 | os.Exit(1)
122 | }
123 | } else {
124 | statejson = []byte(c.String("state"))
125 | }
126 |
127 | msatoshi := c.Int64("msatoshi")
128 | if msatoshi == 0 {
129 | msatoshi = int64(1000 * c.Float64("satoshis"))
130 | }
131 |
132 | state, err := runlua.RunCall(
133 | log,
134 | os.Stderr,
135 | returnHttp,
136 | func(_ string) (interface{}, int64, error) {
137 | return nil, 0, errors.New("no external contracts in test environment")
138 | },
139 | func(_, _ string, _ interface{}, _ int64, _ string) error {
140 | return errors.New("no external contracts in test environment")
141 | },
142 | func() (contractFunds int, err error) { return contractFunds, nil },
143 | func(target string, msat int) (msatoshiSent int, err error) {
144 | contractFunds -= int64(msat)
145 | fmt.Fprintf(os.Stderr, "%dmsat sent to %s\n", msat, target)
146 | return msat, nil
147 | },
148 | func() (userBalance int, err error) { return 99999, nil },
149 | func(target string, msat int) (msatoshiSent int, err error) {
150 | fmt.Fprintf(os.Stderr, "%dmsat sent to %s\n", msat, target)
151 | return msat, nil
152 | },
153 | types.Contract{
154 | Code: string(bcontractCode),
155 | State: sqlxtypes.JSONText(statejson),
156 | Funds: contractFunds,
157 | },
158 | types.Call{
159 | Id: "callid",
160 | Msatoshi: msatoshi,
161 | Method: c.String("method"),
162 | Payload: sqlxtypes.JSONText([]byte(c.String("payload"))),
163 | Caller: c.String("caller"),
164 | },
165 | )
166 | if err != nil {
167 | fmt.Fprintln(app.ErrWriter, "execution error: "+err.Error())
168 | os.Exit(3)
169 | }
170 |
171 | if stateFile != "" {
172 | f, err := os.Create(stateFile)
173 | if err != nil {
174 | fmt.Fprintf(app.ErrWriter,
175 | "failed to write state to file '%s'.", stateFile)
176 | os.Exit(4)
177 | }
178 | defer f.Close()
179 | json.NewEncoder(f).Encode(state)
180 | } else {
181 | json.NewEncoder(app.Writer).Encode(state)
182 | }
183 |
184 | return nil
185 | }
186 |
187 | err := app.Run(os.Args)
188 | if err != nil {
189 | fmt.Fprint(app.ErrWriter, err.Error())
190 | os.Exit(2)
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/helpers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/base64"
7 | "encoding/json"
8 | "fmt"
9 | "image/png"
10 | "net/http"
11 | "regexp"
12 | "strconv"
13 | "time"
14 |
15 | "github.com/fiatjaf/hashbow"
16 | "github.com/fogleman/gg"
17 | "github.com/golang/freetype/truetype"
18 | "github.com/itchyny/gojq"
19 | "github.com/yudai/gojsondiff"
20 | )
21 |
22 | var wordMatcher *regexp.Regexp = regexp.MustCompile(`\b\w+\b`)
23 |
24 | type Result struct {
25 | Ok bool `json:"ok"`
26 | Value interface{} `json:"value"`
27 | Error string `json:"error,omitempty"`
28 | }
29 |
30 | func jsonError(w http.ResponseWriter, message string, code int) {
31 | w.WriteHeader(code)
32 | json.NewEncoder(w).Encode(Result{
33 | Ok: false,
34 | Error: message,
35 | })
36 | }
37 |
38 | func diffDeltaOneliner(prefix string, idelta gojsondiff.Delta) (lines []string) {
39 | key := prefix
40 | if key != "" {
41 | key += "."
42 | }
43 |
44 | switch pdelta := idelta.(type) {
45 | case gojsondiff.PreDelta:
46 | switch delta := pdelta.(type) {
47 | case *gojsondiff.Moved:
48 | key = key + delta.PrePosition().String()
49 | lines = append(lines, fmt.Sprintf("- %s", key))
50 | case *gojsondiff.Deleted:
51 | key = key + delta.PrePosition().String()
52 | lines = append(lines, fmt.Sprintf("- %s", key[:len(key)]))
53 | }
54 | }
55 |
56 | switch pdelta := idelta.(type) {
57 | case gojsondiff.PostDelta:
58 | switch delta := pdelta.(type) {
59 | case *gojsondiff.TextDiff:
60 | key = key + delta.PostPosition().String()
61 | lines = append(lines, fmt.Sprintf("= %s %v", key, delta.NewValue))
62 | case *gojsondiff.Modified:
63 | key = key + delta.PostPosition().String()
64 | value, _ := json.Marshal(delta.NewValue)
65 | lines = append(lines, fmt.Sprintf("= %s %s", key, value))
66 | case *gojsondiff.Added:
67 | key = key + delta.PostPosition().String()
68 | value, _ := json.Marshal(delta.Value)
69 | lines = append(lines, fmt.Sprintf("+ %s %s", key, value))
70 | case *gojsondiff.Object:
71 | key = key + delta.PostPosition().String()
72 | for _, nextdelta := range delta.Deltas {
73 | lines = append(lines, diffDeltaOneliner(key, nextdelta)...)
74 | }
75 | case *gojsondiff.Array:
76 | key = key + delta.PostPosition().String()
77 | for _, nextdelta := range delta.Deltas {
78 | lines = append(lines, diffDeltaOneliner(key, nextdelta)...)
79 | }
80 | case *gojsondiff.Moved:
81 | key = key + delta.PostPosition().String()
82 | value, _ := json.Marshal(delta.Value)
83 | lines = append(lines, fmt.Sprintf("+ %s %s", key, value))
84 | if delta.Delta != nil {
85 | if d, ok := delta.Delta.(gojsondiff.Delta); ok {
86 | lines = append(lines, diffDeltaOneliner(key, d)...)
87 | }
88 | }
89 | }
90 | }
91 |
92 | return
93 | }
94 |
95 | func runJQ(
96 | ctx context.Context,
97 | input []byte,
98 | filter string,
99 | ) (result interface{}, err error) {
100 | ctx, cancel := context.WithTimeout(ctx, time.Second*2)
101 | defer cancel()
102 |
103 | query, err := gojq.Parse(filter)
104 | if err != nil {
105 | return
106 | }
107 |
108 | var object map[string]interface{}
109 | err = json.Unmarshal(input, &object)
110 | if err != nil {
111 | return nil, err
112 | }
113 |
114 | iter := query.RunWithContext(ctx, object)
115 | v, ok := iter.Next()
116 | if !ok {
117 | return nil, nil
118 | }
119 | if err, ok := v.(error); ok {
120 | return nil, err
121 | }
122 | return v, nil
123 | }
124 |
125 | func generateLnurlImage(contractId string, method string) (b64 string, err error) {
126 | // load existing image
127 | base, err := Asset("static/lnurlpayicon.png")
128 | if err != nil {
129 | return
130 | }
131 | img, err := png.Decode(bytes.NewBuffer(base))
132 | if err != nil {
133 | return
134 | }
135 |
136 | // load font to write
137 | fontbytes, err := Asset("static/Inconsolata-Bold.ttf")
138 | if err != nil {
139 | return
140 | }
141 | f, err := truetype.Parse(fontbytes)
142 | if err != nil {
143 | return
144 | }
145 | face := truetype.NewFace(f, &truetype.Options{Size: 20})
146 |
147 | // create new image with gg
148 | bounds := img.Bounds()
149 | dc := gg.NewContext(bounds.Max.X, bounds.Max.Y)
150 | dc.DrawImage(img, 0, 0)
151 |
152 | // apply filters
153 | // contract filter
154 | hexcolor := hashbow.Hashbow(contractId)
155 | r, _ := strconv.ParseInt(hexcolor[1:3], 16, 64)
156 | g, _ := strconv.ParseInt(hexcolor[3:5], 16, 64)
157 | b, _ := strconv.ParseInt(hexcolor[5:7], 16, 64)
158 | dc.SetRGBA255(int(r), int(g), int(b), 120)
159 | dc.MoveTo(0, float64(bounds.Max.Y)*0.63)
160 | dc.CubicTo(
161 | float64(bounds.Max.X)*0.33, float64(bounds.Max.Y)*0.9,
162 | float64(bounds.Max.X)*0.80, float64(bounds.Max.Y)*0.25,
163 | float64(bounds.Max.X), float64(bounds.Max.Y)*0.3,
164 | )
165 | dc.LineTo(float64(bounds.Max.X), 0)
166 | dc.LineTo(0, 0)
167 | dc.Fill()
168 |
169 | // method filter
170 | hexcolor = hashbow.Hashbow(method)
171 | r, _ = strconv.ParseInt(hexcolor[1:3], 16, 64)
172 | g, _ = strconv.ParseInt(hexcolor[3:5], 16, 64)
173 | b, _ = strconv.ParseInt(hexcolor[5:7], 16, 64)
174 | dc.SetRGBA255(int(r), int(g), int(b), 120)
175 | dc.MoveTo(0, float64(bounds.Max.Y)*0.63)
176 | dc.CubicTo(
177 | float64(bounds.Max.X)*0.33, float64(bounds.Max.Y)*0.9,
178 | float64(bounds.Max.X)*0.80, float64(bounds.Max.Y)*0.25,
179 | float64(bounds.Max.X), float64(bounds.Max.Y)*0.3,
180 | )
181 | dc.LineTo(float64(bounds.Max.X), float64(bounds.Max.Y))
182 | dc.LineTo(0, float64(bounds.Max.Y))
183 | dc.Fill()
184 |
185 | // write contract id and method
186 | dc.SetFontFace(face)
187 | dc.SetRGB255(255, 255, 255)
188 | w, _ := dc.MeasureString(contractId)
189 | dc.DrawString(contractId, float64(bounds.Max.X)-8-w, 19)
190 | dc.DrawString(method+"()", 8, float64(bounds.Max.Y-9))
191 |
192 | // encode to base64 png and return
193 | out := bytes.Buffer{}
194 | err = dc.EncodePNG(&out)
195 | if err != nil {
196 | return
197 | }
198 |
199 | return base64.StdEncoding.EncodeToString(out.Bytes()), nil
200 | }
201 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import tempfile
4 | import subprocess
5 |
6 | import pytest
7 | from lightning import LightningRpc
8 | from bitcoin import BitcoinRPC
9 |
10 | from .utils import TailableProc, wait_for
11 |
12 | bitcoind_bin = os.getenv("BITCOIND")
13 | lightningd_bin = os.getenv("LIGHTNINGD")
14 | bitcoin_cli_bin = os.getenv("BITCOIN_CLI")
15 |
16 |
17 | @pytest.fixture
18 | def bitcoin_dir():
19 | bitcoin = tempfile.mkdtemp(prefix="bitcoin.")
20 | yield bitcoin
21 | shutil.rmtree(bitcoin)
22 |
23 |
24 | @pytest.fixture
25 | def lightning_dirs():
26 | lightning_a = tempfile.mkdtemp(prefix="lightning-a.")
27 | lightning_b = tempfile.mkdtemp(prefix="lightning-b.")
28 | yield [lightning_a, lightning_b]
29 | shutil.rmtree(lightning_a)
30 | shutil.rmtree(lightning_b)
31 |
32 |
33 | @pytest.fixture
34 | def bitcoind(bitcoin_dir):
35 | proc = TailableProc(
36 | "{bitcoind_bin} -regtest -datadir={dir} -server -printtoconsole -logtimestamps -nolisten -rpcport=10287 -rpcuser=rpcuser -rpcpassword=rpcpassword".format(
37 | bitcoind_bin=bitcoind_bin, dir=bitcoin_dir
38 | ),
39 | verbose=False,
40 | procname="bitcoind",
41 | )
42 | proc.start()
43 | proc.wait_for_log("Done loading")
44 |
45 | rpc = BitcoinRPC("http://127.0.0.1:10287/", "rpcuser", "rpcpassword")
46 | rpc.generate(101)
47 |
48 | yield proc, rpc
49 |
50 | proc.stop()
51 |
52 |
53 | @pytest.fixture
54 | def lightnings(bitcoin_dir, bitcoind, lightning_dirs):
55 | procs = []
56 | for i, dir in enumerate(lightning_dirs):
57 | proc = TailableProc(
58 | "{lightningd_bin} --network regtest --bitcoin-cli {bitcoin_cli_bin} --bitcoin-rpcport=10287 --bitcoin-datadir {bitcoin_dir} --bitcoin-rpcuser rpcuser --bitcoin-rpcpassword rpcpassword --lightning-dir {dir} --bind-addr 127.0.0.1:987{i}".format(
59 | lightningd_bin=lightningd_bin,
60 | bitcoin_cli_bin=bitcoin_cli_bin,
61 | bitcoin_dir=bitcoin_dir,
62 | dir=dir,
63 | i=i,
64 | ),
65 | verbose=False,
66 | procname="lightningd-{}".format(i),
67 | )
68 | proc.start()
69 | proc.wait_for_log("Server started with public key")
70 | procs.append(proc)
71 |
72 | # make rpc clients
73 | rpcs = []
74 | for dir in lightning_dirs:
75 | rpc = LightningRpc(os.path.join(dir, "lightning-rpc"))
76 | rpcs.append(rpc)
77 |
78 | # get nodes funded
79 | _, bitcoin_rpc = bitcoind
80 | for rpc in rpcs:
81 | addr = rpc.newaddr()["address"]
82 | bitcoin_rpc.sendtoaddress(addr, 15)
83 | bitcoin_rpc.generate(1)
84 |
85 | for rpc in rpcs:
86 | wait_for(lambda: len(rpc.listfunds()["outputs"]) == 1, timeout=60)
87 |
88 | # make a channel between the two
89 | t = rpcs[0]
90 | f = rpcs[1]
91 | tinfo = t.getinfo()
92 | f.connect(tinfo["id"], tinfo["binding"][0]["address"], tinfo["binding"][0]["port"])
93 | num_tx = len(bitcoin_rpc.getrawmempool())
94 | f.fundchannel(tinfo["id"], 10000000)
95 | wait_for(lambda: len(bitcoin_rpc.getrawmempool()) == num_tx + 1)
96 | bitcoin_rpc.generate(1)
97 |
98 | # wait for channels
99 | for proc in procs:
100 | proc.wait_for_log("to CHANNELD_NORMAL", timeout=60)
101 | for rpc in rpcs:
102 | wait_for(lambda: len(rpc.listfunds()["channels"]) > 0, timeout=60)
103 |
104 | # send some money just to open space at the channel
105 | f.pay(t.invoice(1000000000, "open", "nada")["bolt11"])
106 | t.waitinvoice("open")
107 |
108 | yield procs, rpcs
109 |
110 | # stop nodes
111 | for proc, rpc in zip(procs, rpcs):
112 | try:
113 | rpc.stop()
114 | except:
115 | pass
116 |
117 | proc.proc.wait(5)
118 | proc.stop()
119 |
120 |
121 | @pytest.fixture
122 | def init_db():
123 | db = os.getenv("DATABASE_URL")
124 | if "@localhost" not in db or "test" not in db:
125 | raise Exception("Use the test postgres database, please.")
126 |
127 | # destroy db
128 | end = subprocess.run(
129 | "psql {url} -c 'drop table if exists withdrawals cascade; drop table if exists internal_transfers cascade; drop table if exists calls cascade; drop table if exists contracts cascade; drop table if exists accounts cascade;'".format(
130 | url=db
131 | ),
132 | shell=True,
133 | capture_output=True,
134 | )
135 | print("db destroy stdout: " + end.stdout.decode("utf-8"))
136 | print("db destroy stderr: " + end.stderr.decode("utf-8"))
137 |
138 | # rebuild db
139 | end = subprocess.run(
140 | "psql {url} -f postgres.sql".format(url=db), shell=True, capture_output=True
141 | )
142 | print("db creation stdout: " + end.stdout.decode("utf-8"))
143 | print("db creation stderr: " + end.stderr.decode("utf-8"))
144 |
145 | # create an account
146 | subprocess.run(
147 | """psql {url} -c "insert into accounts values ('account1', 'xxx')"
148 | """.format(
149 | url=db
150 | ),
151 | shell=True,
152 | capture_output=True,
153 | )
154 |
155 |
156 | @pytest.fixture
157 | def flush_redis():
158 | r = os.getenv("REDIS_URL")
159 | if "localhost" not in r:
160 | raise Exception("Use the test redis database, please.")
161 |
162 | # delete everything
163 | end = subprocess.run("redis-cli flushdb", shell=True, capture_output=True)
164 | print("redis destroy stdout: " + end.stdout.decode("utf-8"))
165 | print("redis destroy stderr: " + end.stderr.decode("utf-8"))
166 |
167 |
168 | @pytest.fixture
169 | def etleneum(init_db, flush_redis, lightning_dirs, lightnings):
170 | dir_a = lightning_dirs[0]
171 | env = os.environ.copy()
172 | env.update({"SOCKET_PATH": os.path.join(dir_a, "lightning-rpc")})
173 |
174 | proc = TailableProc("./etleneum", env=env, procname="etleneum")
175 | proc.start()
176 | proc.wait_for_log("listening.")
177 | yield proc, env["SERVICE_URL"]
178 |
179 | proc.stop()
180 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | import re
2 | import os
3 | import time
4 | import shlex
5 | import logging
6 | import subprocess
7 | import threading
8 |
9 | TIMEOUT = 5
10 |
11 |
12 | def wait_for(success, timeout=TIMEOUT):
13 | start_time = time.time()
14 | interval = 0.25
15 | while not success() and time.time() < start_time + timeout:
16 | time.sleep(interval)
17 | interval *= 2
18 | if interval > 5:
19 | interval = 5
20 | if time.time() > start_time + timeout:
21 | raise ValueError("Error waiting for {}", success)
22 |
23 |
24 | class TailableProc(object):
25 | """A monitorable process that we can start, stop and tail.
26 | This is the base class for the daemons. It allows us to directly
27 | tail the processes and react to their output.
28 | """
29 |
30 | def __init__(self, cmd_line, env=None, outputDir=None, verbose=True, procname=None):
31 | self.cmd_line = cmd_line
32 | self.logs = []
33 | self.logs_cond = threading.Condition(threading.RLock())
34 | self.env = env or os.environ.copy()
35 | self.running = False
36 | self.proc = None
37 | self.outputDir = outputDir
38 | self.logsearch_start = 0
39 | self.procname = procname or ""
40 |
41 | # Should we be logging lines we read from stdout?
42 | self.verbose = verbose
43 |
44 | # A filter function that'll tell us whether to filter out the line (not
45 | # pass it to the log matcher and not print it to stdout).
46 | self.log_filter = lambda line: False
47 |
48 | def start(self):
49 | """Start the underlying process and start monitoring it.
50 | """
51 | logging.debug("Starting '%s'" % self.cmd_line)
52 | self.proc = subprocess.Popen(
53 | shlex.split(self.cmd_line),
54 | stderr=subprocess.STDOUT,
55 | stdout=subprocess.PIPE,
56 | env=self.env,
57 | )
58 | self.thread = threading.Thread(target=self.tail)
59 | self.thread.daemon = True
60 | self.thread.start()
61 | self.running = True
62 |
63 | def stop(self, timeout=10):
64 | self.proc.terminate()
65 |
66 | # Now give it some time to react to the signal
67 | rc = self.proc.wait(timeout)
68 |
69 | if rc is None:
70 | self.proc.kill()
71 |
72 | self.proc.wait()
73 | self.thread.join(timeout=TIMEOUT)
74 |
75 | if self.proc.returncode:
76 | raise ValueError(
77 | "Process '{} ({})' did not cleanly shutdown: return code {}".format(
78 | self.procname, self.proc.pid, rc
79 | )
80 | )
81 |
82 | return self.proc.returncode
83 |
84 | def kill(self):
85 | """Kill process without giving it warning."""
86 | self.proc.kill()
87 | self.proc.wait()
88 | self.thread.join(timeout=TIMEOUT)
89 |
90 | def tail(self):
91 | """Tail the stdout of the process and remember it.
92 | Stores the lines of output produced by the process in
93 | self.logs and signals that a new line was read so that it can
94 | be picked up by consumers.
95 | """
96 | for line in iter(self.proc.stdout.readline, ""):
97 | if len(line) == 0:
98 | break
99 | if self.log_filter(line.decode("ASCII")):
100 | continue
101 | if self.verbose:
102 | logging.debug("%s: %s", self.procname, line.decode().rstrip())
103 | with self.logs_cond:
104 | self.logs.append(str(line.rstrip()))
105 | self.logs_cond.notifyAll()
106 | self.running = False
107 | self.proc.stdout.close()
108 |
109 | def is_in_log(self, regex, start=0):
110 | """Look for `regex` in the logs."""
111 |
112 | ex = re.compile(regex)
113 | for l in self.logs[start:]:
114 | if ex.search(l):
115 | logging.debug("Found '%s' in logs", regex)
116 | return l
117 |
118 | logging.debug("Did not find '%s' in logs", regex)
119 | return None
120 |
121 | def wait_for_logs(self, regexs, timeout=TIMEOUT):
122 | """Look for `regexs` in the logs.
123 | We tail the stdout of the process and look for each regex in `regexs`,
124 | starting from last of the previous waited-for log entries (if any). We
125 | fail if the timeout is exceeded or if the underlying process
126 | exits before all the `regexs` were found.
127 | If timeout is None, no time-out is applied.
128 | """
129 | logging.debug("Waiting for {} in the logs".format(regexs))
130 | exs = [re.compile(r) for r in regexs]
131 | start_time = time.time()
132 | pos = self.logsearch_start
133 | while True:
134 | if timeout is not None and time.time() > start_time + timeout:
135 | print("Time-out: can't find {} in logs".format(exs))
136 | for r in exs:
137 | if self.is_in_log(r):
138 | print("({} was previously in logs!)".format(r))
139 | raise TimeoutError('Unable to find "{}" in logs.'.format(exs))
140 | elif not self.running:
141 | raise ValueError("Process died while waiting for logs.")
142 |
143 | with self.logs_cond:
144 | if pos >= len(self.logs):
145 | self.logs_cond.wait(1)
146 | continue
147 |
148 | for r in exs.copy():
149 | self.logsearch_start = pos + 1
150 | if r.search(self.logs[pos]):
151 | logging.debug("Found '%s' in logs", r)
152 | exs.remove(r)
153 | break
154 | if len(exs) == 0:
155 | return self.logs[pos]
156 | pos += 1
157 |
158 | def wait_for_log(self, regex, timeout=TIMEOUT):
159 | """Look for `regex` in the logs.
160 | Convenience wrapper for the common case of only seeking a single entry.
161 | """
162 | return self.wait_for_logs([regex], timeout)
163 |
--------------------------------------------------------------------------------
/call_handlers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "encoding/json"
6 | "net/http"
7 | "strconv"
8 | "time"
9 |
10 | "github.com/fiatjaf/etleneum/types"
11 | "github.com/gorilla/mux"
12 | "github.com/lucsky/cuid"
13 | )
14 |
15 | func listCalls(w http.ResponseWriter, r *http.Request) {
16 | ctid := mux.Vars(r)["ctid"]
17 | logger := log.With().Str("ctid", ctid).Logger()
18 |
19 | limit := r.URL.Query().Get("limit")
20 | if limit == "" {
21 | limit = "50"
22 | }
23 |
24 | calls := make([]types.Call, 0)
25 | err = pg.Select(&calls, `
26 | SELECT `+types.CALLFIELDS+`
27 | FROM calls
28 | WHERE contract_id = $1
29 | OR $1 IN (SELECT to_contract FROM internal_transfers it WHERE calls.id = it.call_id)
30 | ORDER BY time DESC
31 | LIMIT $2
32 | `, ctid, limit)
33 | if err == sql.ErrNoRows {
34 | calls = make([]types.Call, 0)
35 | } else if err != nil {
36 | logger.Error().Err(err).Msg("failed to fetch calls")
37 | jsonError(w, "failed to fetch calls", 404)
38 | return
39 | }
40 |
41 | w.Header().Set("Content-Type", "application/json")
42 | json.NewEncoder(w).Encode(Result{Ok: true, Value: calls})
43 | }
44 |
45 | func prepareCall(w http.ResponseWriter, r *http.Request) {
46 | ctid := mux.Vars(r)["ctid"]
47 | logger := log.With().Str("ctid", ctid).Logger()
48 |
49 | call := &types.Call{}
50 | err := json.NewDecoder(r.Body).Decode(call)
51 | if err != nil {
52 | log.Warn().Err(err).Msg("failed to parse call json")
53 | jsonError(w, "failed to parse json", 400)
54 | return
55 | }
56 | call.ContractId = ctid
57 | call.Id = "r" + cuid.Slug()
58 | call.Cost = getCallCosts(*call, false)
59 | logger = logger.With().Str("callid", call.Id).Logger()
60 |
61 | // if the user has authorized and want to make an authenticated call
62 | if session := r.URL.Query().Get("session"); session != "" {
63 | if accountId, err := rds.Get("auth-session:" + session).Result(); err != nil {
64 | log.Warn().Err(err).Str("session", session).Msg("failed to get account for authenticated session")
65 | jsonError(w, "failed to get account for authenticated session", 400)
66 | return
67 | } else {
68 | call.Caller = accountId
69 | }
70 | }
71 |
72 | // verify call is valid as best as possible
73 | if len(call.Method) == 0 || call.Method[0] == '_' {
74 | logger.Warn().Err(err).Str("method", call.Method).Msg("invalid method")
75 | jsonError(w, "invalid method", 400)
76 | return
77 | }
78 |
79 | var invoice string
80 | if s.FreeMode {
81 | invoice = BOGUS_INVOICE
82 |
83 | // wait 5 seconds and notify this payment was received
84 | go func() {
85 | time.Sleep(5 * time.Second)
86 | callPaymentReceived(call.Id, call.Msatoshi+call.Cost)
87 | }()
88 | } else {
89 | invoice, err = makeInvoice(
90 | call.ContractId,
91 | call.Id,
92 | s.ServiceId+" "+call.Method+" ["+call.ContractId+"]["+call.Id+"]",
93 | nil,
94 | call.Msatoshi+call.Cost,
95 | 0,
96 | )
97 | if err != nil {
98 | logger.Error().Err(err).Msg("failed to make invoice.")
99 | jsonError(w, "failed to make invoice, please try again", 500)
100 | return
101 | }
102 | }
103 |
104 | _, err = saveCallOnRedis(*call)
105 | if err != nil {
106 | logger.Error().Err(err).Interface("call", call).
107 | Msg("failed to save call on redis")
108 | jsonError(w, "failed to save prepared call", 500)
109 | return
110 | }
111 |
112 | w.Header().Set("Content-Type", "application/json")
113 | json.NewEncoder(w).Encode(Result{Ok: true, Value: types.StuffBeingCreated{
114 | Id: call.Id,
115 | Invoice: invoice,
116 | }})
117 | }
118 |
119 | func getCall(w http.ResponseWriter, r *http.Request) {
120 | callid := mux.Vars(r)["callid"]
121 | limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
122 | if limit == 0 {
123 | limit = 50
124 | }
125 | logger := log.With().Str("callid", callid).Logger()
126 |
127 | call := &types.Call{}
128 | err = pg.Get(call, `
129 | SELECT `+types.CALLFIELDS+`, coalesce(diff, '') AS diff
130 | FROM calls
131 | WHERE id = $1
132 | ORDER BY time DESC
133 | LIMIT $2
134 | `, callid, limit)
135 | if err == sql.ErrNoRows {
136 | call, err = callFromRedis(callid)
137 | if err != nil {
138 | logger.Warn().Err(err).Msg("failed to fetch call from redis")
139 | jsonError(w, "couldn't find call "+callid+", it may have expired", 404)
140 | return
141 | }
142 | } else if err != nil {
143 | // it's a database error
144 | logger.Error().Err(err).Msg("database error fetching call")
145 | jsonError(w, "failed to fetch call "+callid, 500)
146 | return
147 | }
148 |
149 | w.Header().Set("Content-Type", "application/json")
150 | json.NewEncoder(w).Encode(Result{Ok: true, Value: call})
151 | }
152 |
153 | // changes call payload after being prepared
154 | func patchCall(w http.ResponseWriter, r *http.Request) {
155 | callid := mux.Vars(r)["callid"]
156 | logger := log.With().Str("callid", callid).Logger()
157 |
158 | call, err := callFromRedis(callid)
159 | if err != nil {
160 | logger.Warn().Err(err).Msg("failed to fetch call from redis")
161 | jsonError(w, "couldn't find call "+callid+", it may have expired.", 404)
162 | return
163 | }
164 |
165 | // authenticated calls can only be modified by an authenticated session
166 | if call.Caller != "" {
167 | if session := r.URL.Query().Get("session"); session != "" {
168 | accountId, err := rds.Get("auth-session:" + session).Result()
169 | if err != nil || accountId != call.Caller {
170 | jsonError(w, "only the author can patch an authenticated call.", 401)
171 | return
172 | }
173 | }
174 | }
175 |
176 | patch := make(map[string]interface{})
177 | err = json.NewDecoder(r.Body).Decode(&patch)
178 | if err != nil {
179 | log.Warn().Err(err).Msg("failed to parse patch json")
180 | jsonError(w, "failed to parse json", 400)
181 | return
182 | }
183 |
184 | payload := make(map[string]interface{})
185 | err = json.Unmarshal(call.Payload, &payload)
186 | for k, v := range patch {
187 | payload[k] = v
188 | }
189 | jpayload, _ := json.Marshal(payload)
190 | call.Payload.UnmarshalJSON(jpayload)
191 |
192 | _, err = saveCallOnRedis(*call)
193 | if err != nil {
194 | logger.Error().Err(err).Interface("call", call).
195 | Msg("failed to save patched call on redis")
196 | jsonError(w, "failed to save patched call", 500)
197 | return
198 | }
199 |
200 | json.NewEncoder(w).Encode(Result{Ok: true, Value: call})
201 | }
202 |
--------------------------------------------------------------------------------
/payment_receive.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "encoding/hex"
7 | "strconv"
8 | "time"
9 |
10 | "github.com/fiatjaf/etleneum/types"
11 | "github.com/fiatjaf/lightningd-gjson-rpc/plugin"
12 | )
13 |
14 | var continueHTLC = map[string]interface{}{"result": "continue"}
15 | var failHTLC = map[string]interface{}{"result": "fail", "failure_code": 16392}
16 |
17 | func htlc_accepted(p *plugin.Plugin, params plugin.Params) (resp interface{}) {
18 | amount := params.Get("htlc.amount").String()
19 | scid := params.Get("onion.short_channel_id").String()
20 | if scid == "0x0x0" {
21 | // payment coming to this node, accept it
22 | return continueHTLC
23 | }
24 |
25 | hash := params.Get("htlc.payment_hash").String()
26 |
27 | p.Logf("got HTLC. amount=%s short_channel_id=%s hash=%s", amount, scid, hash)
28 | for rds == nil || pg == nil {
29 | p.Log("htlc_accepted: waiting until redis and postgres are available.")
30 | time.Sleep(1 * time.Second)
31 | }
32 |
33 | msatoshi, err := strconv.ParseInt(amount[:len(amount)-4], 10, 64)
34 | if err != nil {
35 | // I don't know what is happening
36 | p.Logf("error parsing onion.forward_amount: %s - continue", err.Error())
37 | return continueHTLC
38 | }
39 |
40 | bscid, err := decodeShortChannelId(scid)
41 | if err != nil {
42 | p.Logf("onion.short_channel_id is not in the usual format - continue")
43 | return continueHTLC
44 | }
45 |
46 | id, ok := parseShortChannelId(bscid)
47 | if !ok {
48 | // it's not an invoice for an etleneum call or contract
49 | p.Logf("failed to parse onion.short_channel_id - continue")
50 | return continueHTLC
51 | }
52 |
53 | if id[0] == 'c' {
54 | ok = contractPaymentReceived(id, msatoshi)
55 | } else if id[0] == 'r' {
56 | ok = callPaymentReceived(id, msatoshi)
57 | } else {
58 | // it's not an invoice for an etleneum call or contract
59 | p.Logf("parsed id is not an etleneum payment (%s) - continue", id)
60 | return continueHTLC
61 | }
62 |
63 | if ok {
64 | preimage := hex.EncodeToString(makePreimage(id))
65 | p.Logf("call went ok. we have a preimage: %s - resolve", preimage)
66 | return map[string]interface{}{
67 | "result": "resolve",
68 | "payment_key": preimage,
69 | }
70 | } else {
71 | // in case of call execution failure we just fail the payment
72 | p.Logf("call failed - fail")
73 | return failHTLC
74 | }
75 | }
76 |
77 | func contractPaymentReceived(contractId string, msatoshi int64) (ok bool) {
78 | // start the contract
79 | logger := log.With().Str("ctid", contractId).Logger()
80 |
81 | ct, err := contractFromRedis(contractId)
82 | if err != nil {
83 | logger.Warn().Err(err).Msg("failed to fetch contract from redis to activate")
84 | dispatchContractEvent(contractId,
85 | ctevent{contractId, "", "", err.Error(), "internal"}, "contract-error")
86 | return false
87 | }
88 |
89 | if getContractCost(*ct) > msatoshi {
90 | return false
91 | }
92 |
93 | txn, err := pg.BeginTxx(context.TODO(),
94 | &sql.TxOptions{Isolation: sql.LevelSerializable})
95 | if err != nil {
96 | logger.Warn().Err(err).Msg("transaction start failed")
97 | dispatchContractEvent(contractId,
98 | ctevent{contractId, "", "", err.Error(), "internal"}, "contract-error")
99 | return false
100 | }
101 | defer txn.Rollback()
102 |
103 | // create initial contract
104 | _, err = txn.Exec(`
105 | INSERT INTO contracts (id, name, readme, code, state)
106 | VALUES ($1, $2, $3, $4, '{}')
107 | `, ct.Id, ct.Name, ct.Readme, ct.Code)
108 | if err != nil {
109 | logger.Warn().Err(err).Msg("failed to save contract on database")
110 | dispatchContractEvent(contractId,
111 | ctevent{contractId, "", "", err.Error(), "internal"}, "contract-error")
112 | return false
113 | }
114 |
115 | // instantiate call (the __init__ special kind)
116 | call := &types.Call{
117 | ContractId: ct.Id,
118 | Id: ct.Id, // same
119 | Method: "__init__",
120 | Payload: []byte{},
121 | Cost: getContractCost(*ct),
122 | }
123 |
124 | err = runCall(call, txn)
125 | if err != nil {
126 | logger.Warn().Err(err).Msg("failed to run call")
127 | dispatchContractEvent(contractId,
128 | ctevent{contractId, "", call.Method, err.Error(), "runtime"}, "contract-error")
129 | return false
130 | }
131 |
132 | // commit contract call
133 | err = txn.Commit()
134 | if err != nil {
135 | log.Warn().Err(err).Str("callid", call.Id).Msg("failed to commit contract")
136 | return false
137 | }
138 |
139 | dispatchContractEvent(contractId,
140 | ctevent{contractId, "", call.Method, "", ""}, "contract-created")
141 | logger.Info().Msg("contract is live")
142 |
143 | // saved. delete from redis.
144 | rds.Del("contract:" + contractId)
145 |
146 | // save contract on github
147 | saveContractOnGitHub(ct)
148 |
149 | return true
150 | }
151 |
152 | func callPaymentReceived(callId string, msatoshi int64) (ok bool) {
153 | // run the call
154 | logger := log.With().Str("callid", callId).Logger()
155 |
156 | call, err := callFromRedis(callId)
157 | if err != nil {
158 | logger.Warn().Err(err).Msg("failed to fetch call from redis")
159 | return false
160 | }
161 | logger = logger.With().Str("ct", call.ContractId).Logger()
162 |
163 | if call.Msatoshi+call.Cost > msatoshi {
164 | // TODO: this is the place where we should handle MPP payments
165 | logger.Warn().Int64("got", msatoshi).Int64("needed", call.Msatoshi+call.Cost).
166 | Msg("insufficient payment amount")
167 | return false
168 | }
169 | // if msatoshi is bigger than needed we take it as a donation
170 |
171 | txn, err := pg.BeginTxx(context.TODO(),
172 | &sql.TxOptions{Isolation: sql.LevelSerializable})
173 | if err != nil {
174 | logger.Warn().Err(err).Msg("transaction start failed")
175 | dispatchContractEvent(call.ContractId,
176 | ctevent{callId, call.ContractId, call.Method, err.Error(), "internal"}, "call-error")
177 | return false
178 | }
179 | defer txn.Rollback()
180 |
181 | logger.Info().Interface("call", call).Msg("call being made")
182 |
183 | // a normal call
184 | err = runCall(call, txn)
185 | if err != nil {
186 | logger.Warn().Err(err).Msg("failed to run call")
187 | dispatchContractEvent(call.ContractId,
188 | ctevent{callId, call.ContractId, call.Method, err.Error(), "runtime"}, "call-error")
189 |
190 | return false
191 | }
192 |
193 | // commit
194 | err = txn.Commit()
195 | if err != nil {
196 | log.Warn().Err(err).Str("callid", call.Id).Msg("failed to commit call")
197 | return false
198 | }
199 |
200 | dispatchContractEvent(call.ContractId,
201 | ctevent{callId, call.ContractId, call.Method, "", ""}, "call-made")
202 |
203 | // saved. delete from redis.
204 | rds.Del("call:" + call.Id)
205 |
206 | return true
207 | }
208 |
--------------------------------------------------------------------------------
/contract_handlers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "encoding/json"
6 | "fmt"
7 | "io/ioutil"
8 | "net/http"
9 | "strconv"
10 | "strings"
11 | "time"
12 |
13 | "github.com/fiatjaf/etleneum/types"
14 | "github.com/gorilla/mux"
15 | sqlxtypes "github.com/jmoiron/sqlx/types"
16 | "github.com/lucsky/cuid"
17 | )
18 |
19 | func listContracts(w http.ResponseWriter, r *http.Request) {
20 | qs := r.URL.Query()
21 |
22 | // pagination
23 | page, _ := strconv.Atoi(qs.Get("page"))
24 | if page == 0 {
25 | page = 1
26 | }
27 | offset := fmt.Sprintf("OFFSET %d", (page-1)*30)
28 |
29 | // filtering
30 | id := qs.Get("id")
31 | where := ""
32 | args := make([]interface{}, 0, 1)
33 | if id != "" {
34 | where = "WHERE id = $1"
35 | args = append(args, id)
36 | }
37 |
38 | contracts := make([]types.Contract, 0)
39 | err = pg.Select(&contracts, `
40 | SELECT id, name, readme, funds, ncalls FROM (
41 | SELECT `+types.CONTRACTFIELDS+`, c.funds,
42 | (SELECT max(time) FROM calls WHERE contract_id = c.id) AS lastcalltime,
43 | (SELECT count(*) FROM calls WHERE contract_id = c.id) AS ncalls
44 | FROM contracts AS c
45 | ) AS x
46 | `+where+`
47 | ORDER BY lastcalltime DESC, created_at DESC
48 | LIMIT 30 `+offset+`
49 | `, args...)
50 | if err == sql.ErrNoRows {
51 | contracts = make([]types.Contract, 0)
52 | } else if err != nil {
53 | log.Warn().Err(err).Msg("failed to fetch contracts")
54 | jsonError(w, "failed to fetch contracts", 500)
55 | return
56 | }
57 |
58 | w.Header().Set("Content-Type", "application/json")
59 | json.NewEncoder(w).Encode(Result{Ok: true, Value: contracts})
60 | }
61 |
62 | func prepareContract(w http.ResponseWriter, r *http.Request) {
63 | // making a contract only saves it temporarily.
64 | // the contract can be inspected only by its creator.
65 | // once the creator knows everything is right, he can call init.
66 | ct := &types.Contract{}
67 | err := json.NewDecoder(r.Body).Decode(ct)
68 | if err != nil {
69 | log.Warn().Err(err).Msg("failed to parse contract json")
70 | jsonError(w, "failed to parse json", 400)
71 | return
72 | }
73 | if strings.TrimSpace(ct.Name) == "" {
74 | jsonError(w, "contract must have a name", 400)
75 | return
76 | }
77 |
78 | ct.Id = "c" + cuid.Slug()
79 |
80 | if ok := checkContractCode(ct.Code); !ok {
81 | log.Warn().Err(err).Msg("invalid contract code")
82 | jsonError(w, "invalid contract code", 400)
83 | return
84 | }
85 |
86 | var invoice string
87 | if s.FreeMode {
88 | invoice = BOGUS_INVOICE
89 |
90 | // wait 10 seconds and notify this payment was received
91 | go func() {
92 | time.Sleep(5 * time.Second)
93 | contractPaymentReceived(ct.Id, getContractCost(*ct))
94 | }()
95 | } else {
96 | invoice, err = makeInvoice(
97 | ct.Id,
98 | ct.Id,
99 | s.ServiceId+" __init__ ["+ct.Id+"]",
100 | nil,
101 | getContractCost(*ct),
102 | 0,
103 | )
104 | if err != nil {
105 | log.Warn().Err(err).Msg("failed to make invoice.")
106 | jsonError(w, "failed to make invoice", 500)
107 | return
108 | }
109 | }
110 |
111 | _, err = saveContractOnRedis(*ct)
112 | if err != nil {
113 | log.Warn().Err(err).Interface("ct", ct).Msg("failed to save to redis")
114 | jsonError(w, "failed to save prepared contract", 500)
115 | }
116 |
117 | w.Header().Set("Content-Type", "application/json")
118 | json.NewEncoder(w).Encode(Result{Ok: true, Value: types.StuffBeingCreated{
119 | Id: ct.Id,
120 | Invoice: invoice,
121 | }})
122 | }
123 |
124 | func getContract(w http.ResponseWriter, r *http.Request) {
125 | ctid := mux.Vars(r)["ctid"]
126 |
127 | ct := &types.Contract{}
128 | err = pg.Get(ct, `
129 | SELECT `+types.CONTRACTFIELDS+`, contracts.funds
130 | FROM contracts
131 | WHERE id = $1`,
132 | ctid)
133 | if err == sql.ErrNoRows {
134 | // couldn't find on database, maybe it's a temporary contract?
135 | ct, err = contractFromRedis(ctid)
136 | if err != nil {
137 | log.Warn().Err(err).Str("ctid", ctid).
138 | Msg("failed to fetch fetch prepared contract from redis")
139 | jsonError(w, "failed to fetch prepared contract", 404)
140 | return
141 | }
142 | } else if err != nil {
143 | // it's a database error
144 | log.Warn().Err(err).Str("ctid", ctid).Msg("database error fetching contract")
145 | jsonError(w, "database error", 500)
146 | return
147 | }
148 |
149 | parseContractCode(ct)
150 |
151 | w.Header().Set("Content-Type", "application/json")
152 | json.NewEncoder(w).Encode(Result{Ok: true, Value: ct})
153 | }
154 |
155 | func getContractState(w http.ResponseWriter, r *http.Request) {
156 | ctid := mux.Vars(r)["ctid"]
157 |
158 | var state sqlxtypes.JSONText
159 | err = pg.Get(&state, `SELECT state FROM contracts WHERE id = $1`, ctid)
160 | if err != nil {
161 | jsonError(w, "contract not found", 404)
162 | return
163 | }
164 | w.Header().Set("Content-Type", "application/json")
165 |
166 | var jqfilter string
167 | if r.Method == "GET" {
168 | jqfilter, _ = mux.Vars(r)["jq"]
169 | } else if r.Method == "POST" {
170 | defer r.Body.Close()
171 | b, _ := ioutil.ReadAll(r.Body)
172 | jqfilter = string(b)
173 | }
174 |
175 | if strings.TrimSpace(jqfilter) != "" {
176 | if result, err := runJQ(r.Context(), []byte(state), jqfilter); err != nil {
177 | log.Warn().Err(err).Str("ctid", ctid).
178 | Str("f", jqfilter).Str("state", string(state)).
179 | Msg("error applying jq filter")
180 | jsonError(w, "error applying jq filter", 400)
181 | return
182 | } else {
183 | jresult, _ := json.Marshal(result)
184 | state = sqlxtypes.JSONText(jresult)
185 | }
186 | }
187 |
188 | json.NewEncoder(w).Encode(Result{Ok: true, Value: state})
189 | }
190 |
191 | func getContractFunds(w http.ResponseWriter, r *http.Request) {
192 | var funds int
193 | err = pg.Get(&funds, `SELECT contracts.funds FROM contracts WHERE id = $1`, mux.Vars(r)["ctid"])
194 | if err != nil {
195 | jsonError(w, "contract not found", 404)
196 | return
197 | }
198 | w.Header().Set("Content-Type", "application/json")
199 | json.NewEncoder(w).Encode(Result{Ok: true, Value: funds})
200 | }
201 |
202 | func deleteContract(w http.ResponseWriter, r *http.Request) {
203 | id := mux.Vars(r)["ctid"]
204 |
205 | var err error
206 |
207 | // can only delete on free mode
208 | if s.FreeMode {
209 | _, err = pg.Exec(`
210 | WITH del_t AS (
211 | DELETE FROM internal_transfers
212 | WHERE call_id IN (SELECT id FROM calls WHERE contract_id = $1)
213 | ), del_c AS (
214 | DELETE FROM calls WHERE contract_id = $1
215 | )
216 | DELETE FROM contracts WHERE id = $1
217 | `, id)
218 | }
219 | if err != nil {
220 | log.Info().Err(err).Str("id", id).Msg("can't delete contract")
221 | jsonError(w, "can't delete contract", 404)
222 | return
223 | }
224 | w.Header().Set("Content-Type", "application/json")
225 | json.NewEncoder(w).Encode(Result{Ok: true})
226 | }
227 |
--------------------------------------------------------------------------------
/lnurlpay.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "encoding/json"
8 | "fmt"
9 | _ "image/png"
10 | "net/http"
11 | "strconv"
12 |
13 | "github.com/fiatjaf/etleneum/types"
14 | "github.com/fiatjaf/go-lnurl"
15 | "github.com/gorilla/mux"
16 | "github.com/lucsky/cuid"
17 | "github.com/tidwall/gjson"
18 | )
19 |
20 | func lnurlCallMetadata(call *types.Call, fixedAmount bool) string {
21 | desc := fmt.Sprintf(`Call method "%s" on contract "%s" with payload %v`,
22 | call.Method, call.ContractId, call.Payload)
23 | if call.Caller != "" {
24 | desc += fmt.Sprintf(" on behalf of account %s", call.Caller)
25 | }
26 | if fixedAmount {
27 | desc += fmt.Sprintf(" including %d msatoshi.", call.Msatoshi)
28 | } else {
29 | desc += " with variadic amount."
30 | }
31 |
32 | metadata := [][]string{[]string{"text/plain", desc}}
33 | if imageb64, err := generateLnurlImage(call.ContractId, call.Method); err == nil {
34 | metadata = append(metadata, []string{"image/png;base64", imageb64})
35 | } else {
36 | log.Warn().Err(err).Msg("error generating image for lnurl")
37 | }
38 | jmetadata, _ := json.Marshal(metadata)
39 |
40 | return string(jmetadata)
41 | }
42 |
43 | func lnurlPayParams(w http.ResponseWriter, r *http.Request) {
44 | vars := mux.Vars(r)
45 | ctid := vars["ctid"]
46 | method := vars["method"]
47 | msatoshi, _ := strconv.ParseInt(vars["msatoshi"], 10, 64)
48 |
49 | logger := log.With().
50 | Str("ctid", ctid).
51 | Str("url", r.URL.String()).
52 | Bool("lnurl", true).
53 | Logger()
54 |
55 | qs := r.URL.Query()
56 |
57 | // fixed minSendable, maxSendable
58 | var defaultMinSendable int64 = 1
59 | var defaultMaxSendable int64 = 1000000000
60 | var err error
61 | if min := qs.Get("_minsendable"); min != "" {
62 | defaultMinSendable, err = strconv.ParseInt(min, 10, 64)
63 | if err != nil {
64 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("_minsendable param is invalid."))
65 | return
66 | }
67 | qs.Del("_minsendable")
68 | }
69 | if max := qs.Get("_maxsendable"); max != "" {
70 | defaultMaxSendable, err = strconv.ParseInt(max, 10, 64)
71 | if err != nil {
72 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("_maxsendable param is invalid."))
73 | return
74 | }
75 | qs.Del("_maxsendable")
76 | }
77 |
78 | // payload comes as query parameters
79 | payload := make(map[string]interface{})
80 | for k, _ := range qs {
81 | if k[0] == '_' {
82 | continue
83 | }
84 |
85 | v := qs.Get(k)
86 | if gjson.Valid(v) {
87 | payload[k] = gjson.Parse(v).Value()
88 | } else {
89 | payload[k] = v
90 | }
91 |
92 | }
93 | jpayload, _ := json.Marshal(payload)
94 |
95 | call := &types.Call{
96 | Id: "r" + cuid.Slug(),
97 | ContractId: ctid,
98 | Method: method,
99 | Msatoshi: msatoshi,
100 | Payload: []byte(jpayload),
101 | }
102 | call.Cost = getCallCosts(*call, false)
103 |
104 | // if the user has hmac'ed this call we set them as the caller
105 | if account := qs.Get("_account"); account != "" {
106 | mac, _ := hex.DecodeString(qs.Get("_hmac"))
107 | call.Caller = account // assume correct
108 |
109 | // then verify
110 | if !hmac.Equal(mac, hmacCall(call)) {
111 | logger.Warn().Str("hmac", hex.EncodeToString(mac)).
112 | Str("expected", hex.EncodeToString(hmacCall(call))).
113 | Str("serialized", callHmacString(call)).
114 | Msg("hmac mismatch")
115 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("Invalid HMAC."))
116 | return
117 | }
118 | }
119 |
120 | logger = logger.With().Str("callid", call.Id).Logger()
121 |
122 | // verify call is valid as best as possible
123 | if len(call.Method) == 0 || call.Method[0] == '_' {
124 | logger.Warn().Err(err).Str("method", call.Method).Msg("invalid method")
125 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("Invalid method '" + call.Method + "'."))
126 | return
127 | }
128 |
129 | _, err = saveCallOnRedis(*call)
130 | if err != nil {
131 | logger.Error().Err(err).Interface("call", call).
132 | Msg("failed to save call on redis")
133 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("Failed to save call data."))
134 | return
135 | }
136 |
137 | var min, max int64
138 | var encodedMetadata string
139 | if call.Msatoshi == 0 && vars["msatoshi"] != "0" {
140 | // if amount is not given let the person choose on lnurl-pay UI
141 | min = defaultMinSendable
142 | max = defaultMaxSendable
143 | encodedMetadata = lnurlCallMetadata(call, false)
144 | } else {
145 | // otherwise make the lnurl params be the full main_price + cost
146 | min = call.Msatoshi + call.Cost
147 | max = call.Msatoshi + call.Cost
148 | encodedMetadata = lnurlCallMetadata(call, true)
149 | }
150 |
151 | json.NewEncoder(w).Encode(lnurl.LNURLPayResponse1{
152 | Tag: "payRequest",
153 | Callback: s.ServiceURL + "/lnurl/call/" + call.Id,
154 | EncodedMetadata: encodedMetadata,
155 | MinSendable: min,
156 | MaxSendable: max,
157 | })
158 | }
159 |
160 | func lnurlPayValues(w http.ResponseWriter, r *http.Request) {
161 | callid := mux.Vars(r)["callid"]
162 | msatoshi, _ := strconv.ParseInt(r.URL.Query().Get("amount"), 10, 64)
163 |
164 | logger := log.With().Str("callid", callid).Logger()
165 |
166 | call, err := callFromRedis(callid)
167 | if err != nil {
168 | logger.Warn().Err(err).Msg("failed to fetch call from redis")
169 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("Failed to fetch call data."))
170 | return
171 | }
172 |
173 | var encodedMetadata string
174 | var lastHopFee int64
175 |
176 | // update the call saved on redis so we can check values paid later.
177 | // this is only needed if the lnurl-pay params sent before were variable
178 | // and the user has chosen them in the wallet (i.e., they were not hardcoded
179 | // in the lnurl itself.
180 | if call.Msatoshi == 0 && msatoshi != (call.Msatoshi+call.Cost) {
181 | // to make the lnurl wallet happy, we'll generate an invoice for
182 | // the exact msatoshi amount chosen in the screen, costs will be
183 | // appended as fees in the last hop shadow channel.
184 | call.Msatoshi = msatoshi
185 | call.Cost = getCallCosts(*call, true)
186 | lastHopFee = call.Cost
187 |
188 | _, err = saveCallOnRedis(*call)
189 | if err != nil {
190 | logger.Error().Err(err).Interface("call", call).
191 | Msg("failed to save call on redis after lnurl-pay step 2")
192 | json.NewEncoder(w).Encode(
193 | lnurl.ErrorResponse("Failed to save call with new amount."))
194 | return
195 | }
196 |
197 | encodedMetadata = lnurlCallMetadata(call, false)
198 | } else {
199 | encodedMetadata = lnurlCallMetadata(call, true)
200 | lastHopFee = 0
201 | }
202 |
203 | descriptionHash := sha256.Sum256([]byte(encodedMetadata))
204 | pr, err := makeInvoice(call.ContractId, call.Id, "", &descriptionHash, msatoshi, lastHopFee)
205 | if err != nil {
206 | logger.Error().Err(err).Msg("translate invoice")
207 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("Error translating invoice."))
208 | return
209 | }
210 |
211 | json.NewEncoder(w).Encode(lnurl.LNURLPayResponse2{
212 | Routes: make([][]lnurl.RouteInfo, 0),
213 | PR: pr,
214 | SuccessAction: lnurl.Action("", s.ServiceURL+"/#/call/"+call.Id),
215 | Disposable: lnurl.FALSE,
216 | })
217 | }
218 |
--------------------------------------------------------------------------------
/github.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "net/http"
6 |
7 | "github.com/fiatjaf/etleneum/types"
8 | "github.com/google/go-github/github"
9 | "github.com/jmoiron/sqlx"
10 | )
11 |
12 | type ghRoundTripper struct{}
13 |
14 | func (_ ghRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
15 | r.Header.Set("Authorization", "Token "+s.GitHubToken)
16 | return http.DefaultTransport.RoundTrip(r)
17 | }
18 |
19 | var ghHttpClient = &http.Client{
20 | Transport: ghRoundTripper{},
21 | }
22 | var gh = github.NewClient(ghHttpClient)
23 |
24 | func handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
25 | if s.GitHubToken != "" {
26 | go getGitHubChanges()
27 | }
28 | w.WriteHeader(200)
29 | }
30 |
31 | func getGitHubChanges() {
32 | tx, _ := pg.Beginx()
33 | defer tx.Rollback()
34 |
35 | var head string
36 | err := tx.Get(&head, `
37 | SELECT (jsonb_build_object('v', v)->>'v')::text
38 | FROM kv
39 | WHERE k = 'github_head'
40 | `)
41 | if err != nil {
42 | log.Error().Err(err).Msg("fetching git head from database")
43 | return
44 | }
45 |
46 | log.Debug().Str("since", head).Msg("getting github changes")
47 |
48 | prev, _, err := gh.Git.GetTree(context.Background(),
49 | s.GitHubRepoOwner, s.GitHubRepoName,
50 | head, false)
51 | if err != nil {
52 | log.Error().Err(err).Msg("fetching head tree")
53 | return
54 | }
55 |
56 | ref, _, err := gh.Git.GetRef(context.Background(),
57 | s.GitHubRepoOwner, s.GitHubRepoName,
58 | "refs/heads/master",
59 | )
60 | if err != nil {
61 | log.Error().Err(err).Msg("fetching next ref")
62 | return
63 | }
64 |
65 | nextSHA := *(*ref.Object).SHA
66 | if nextSHA == head {
67 | log.Info().Msg("already at the latest github state")
68 | return
69 | }
70 |
71 | next, _, err := gh.Git.GetTree(context.Background(),
72 | s.GitHubRepoOwner, s.GitHubRepoName,
73 | nextSHA, false)
74 | if err != nil {
75 | log.Error().Err(err).Msg("fetching next tree")
76 | return
77 | }
78 |
79 | prevEntries := make(map[string]string)
80 | for _, entry := range prev.Entries {
81 | if *entry.Type == "tree" {
82 | prevEntries[*entry.Path] = *entry.SHA
83 | }
84 | }
85 |
86 | for _, entry := range next.Entries {
87 | if sha, ok := prevEntries[*entry.Path]; ok && sha != *entry.SHA {
88 | // different
89 | err := updateContractFromTree(tx, *entry.Path, *entry.SHA)
90 | if err != nil {
91 | return
92 | }
93 | }
94 | }
95 |
96 | _, err = tx.Exec(`
97 | UPDATE kv
98 | SET v = to_jsonb($1::text)
99 | WHERE k = 'github_head'
100 | `, nextSHA)
101 | if err != nil {
102 | log.Error().Err(err).Msg("updating git head on database")
103 | return
104 | }
105 |
106 | tx.Commit()
107 | }
108 |
109 | func updateContractFromTree(tx *sqlx.Tx, contractId, sha string) error {
110 | tree, _, err := gh.Git.GetTree(context.Background(),
111 | s.GitHubRepoOwner, s.GitHubRepoName,
112 | sha, false)
113 | if err != nil {
114 | log.Warn().Err(err).Str("ctid", contractId).Msg("fetching subdir tree")
115 | return err
116 | }
117 |
118 | var name string
119 | var code string
120 | var readme string
121 |
122 | for _, entry := range tree.Entries {
123 | bvalue, _, err := gh.Git.GetBlobRaw(context.Background(),
124 | s.GitHubRepoOwner, s.GitHubRepoName,
125 | *entry.SHA,
126 | )
127 | if err != nil {
128 | log.Warn().Err(err).Str("ctid", contractId).Str("path", *entry.Path).
129 | Msg("fetching blob")
130 | return err
131 | }
132 |
133 | switch *entry.Path {
134 | case "name.txt":
135 | name = string(bvalue)
136 | case "contract.lua":
137 | code = string(bvalue)
138 | case "README.md":
139 | readme = string(bvalue)
140 | }
141 | }
142 |
143 | _, err = tx.Exec(`
144 | UPDATE contracts SET name = $1, readme = $2, code = $3
145 | WHERE id = $4
146 | `, name, readme, code, contractId)
147 | if err != nil {
148 | log.Warn().Err(err).Str("ctid", contractId).
149 | Str("name", name).
150 | Str("code", code).
151 | Str("readme", readme).
152 | Msg("updating contract on database")
153 | return err
154 | }
155 |
156 | return nil
157 | }
158 |
159 | func saveContractOnGitHub(ct *types.Contract) {
160 | if s.GitHubToken == "" {
161 | return
162 | }
163 |
164 | ref, _, err := gh.Git.GetRef(context.Background(),
165 | s.GitHubRepoOwner, s.GitHubRepoName,
166 | "refs/heads/master",
167 | )
168 | if err != nil {
169 | log.Error().Err(err).Msg("getting current ref before updating github")
170 | return
171 | }
172 |
173 | commit, _, err := gh.Git.GetCommit(context.Background(),
174 | s.GitHubRepoOwner, s.GitHubRepoName,
175 | *(*ref.Object).SHA,
176 | )
177 | if err != nil {
178 | log.Error().Err(err).Msg("getting current commit before updating github")
179 | return
180 | }
181 |
182 | cttree, _, err := gh.Git.CreateTree(context.Background(),
183 | s.GitHubRepoOwner, s.GitHubRepoName,
184 | "",
185 | []github.TreeEntry{
186 | {
187 | Mode: github.String("100644"),
188 | Type: github.String("blob"),
189 | Path: github.String("name.txt"),
190 | Content: github.String(ct.Name),
191 | },
192 | {
193 | Mode: github.String("100644"),
194 | Type: github.String("blob"),
195 | Path: github.String("README.md"),
196 | Content: github.String(ct.Readme),
197 | },
198 | {
199 | Mode: github.String("100644"),
200 | Type: github.String("blob"),
201 | Path: github.String("contract.lua"),
202 | Content: github.String(ct.Code),
203 | },
204 | },
205 | )
206 | if err != nil {
207 | log.Error().Err(err).Str("id", ct.Id).Msg("creating new tree on github")
208 | return
209 | }
210 |
211 | tree, _, err := gh.Git.CreateTree(context.Background(),
212 | s.GitHubRepoOwner, s.GitHubRepoName,
213 | *(*commit.Tree).SHA,
214 | []github.TreeEntry{
215 | {
216 | Mode: github.String("040000"),
217 | Type: github.String("tree"),
218 | Path: github.String(ct.Id),
219 | SHA: github.String(*cttree.SHA),
220 | },
221 | },
222 | )
223 | if err != nil {
224 | log.Error().Err(err).Str("id", ct.Id).Msg("creating new tree on github")
225 | return
226 | }
227 |
228 | newcommit, _, err := gh.Git.CreateCommit(context.Background(),
229 | s.GitHubRepoOwner, s.GitHubRepoName,
230 | &github.Commit{
231 | Parents: []github.Commit{*commit},
232 | Tree: tree,
233 | Message: github.String("created contract '" + ct.Name + "' (" + ct.Id + ")"),
234 | },
235 | )
236 | if err != nil {
237 | log.Error().Err(err).Str("id", ct.Id).Msg("creating new commit on github")
238 | return
239 | }
240 |
241 | _, _, err = gh.Git.UpdateRef(context.Background(),
242 | s.GitHubRepoOwner, s.GitHubRepoName,
243 | &github.Reference{
244 | Ref: github.String("refs/heads/master"),
245 | Object: &github.GitObject{
246 | SHA: newcommit.SHA,
247 | },
248 | },
249 | false,
250 | )
251 | if err != nil {
252 | log.Error().Err(err).Str("id", ct.Id).Msg("updating tree on github")
253 | return
254 | }
255 |
256 | _, err = pg.Exec(`
257 | UPDATE kv
258 | SET v = to_jsonb($1::text)
259 | WHERE k = 'github_head'
260 | `, *newcommit.SHA)
261 | if err != nil {
262 | log.Error().Err(err).Msg("updating git head on database")
263 | return
264 | }
265 |
266 | log.Debug().Str("id", ct.Id).Msg("created contract on github")
267 | }
268 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "net/url"
7 | "os"
8 | "os/exec"
9 | "os/signal"
10 | "path/filepath"
11 | "strconv"
12 | "strings"
13 | "syscall"
14 | "time"
15 |
16 | assetfs "github.com/elazarl/go-bindata-assetfs"
17 | lightning "github.com/fiatjaf/lightningd-gjson-rpc"
18 | "github.com/fiatjaf/lightningd-gjson-rpc/plugin"
19 | "github.com/gorilla/mux"
20 | "github.com/jmoiron/sqlx"
21 | "github.com/joho/godotenv"
22 | "github.com/kelseyhightower/envconfig"
23 | _ "github.com/lib/pq"
24 | cmap "github.com/orcaman/concurrent-map"
25 | "github.com/rs/cors"
26 | "github.com/rs/zerolog"
27 | "gopkg.in/redis.v5"
28 | )
29 |
30 | type Settings struct {
31 | ServiceId string `envconfig:"SERVICE_ID" default:"etleneum.com"`
32 | ServiceURL string `envconfig:"SERVICE_URL" required:"true"`
33 | Port string `envconfig:"PORT" required:"true"`
34 | SecretKey string `envconfig:"SECRET_KEY" default:"etleneum"`
35 | PostgresURL string `envconfig:"DATABASE_URL" required:"true"`
36 | RedisURL string `envconfig:"REDIS_URL" required:"true"`
37 |
38 | GitHubRepoOwner string `envconfig:"GITHUB_REPO_OWNER"`
39 | GitHubRepoName string `envconfig:"GITHUB_REPO_NAME"`
40 | GitHubToken string `envconfig:"GITHUB_TOKEN"`
41 |
42 | InitialContractCostSatoshis int64 `envconfig:"INITIAL_CONTRACT_COST_SATOSHIS" default:"970"`
43 | FixedCallCostSatoshis int64 `envconfig:"FIXED_CALL_COST_SATOSHIS" default:"1"`
44 |
45 | NodeId string
46 | FreeMode bool
47 | }
48 |
49 | var err error
50 | var s Settings
51 | var pg *sqlx.DB
52 | var ln *lightning.Client
53 | var rds *redis.Client
54 | var log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stderr})
55 | var httpPublic = &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, Prefix: ""}
56 | var userstreams = cmap.New()
57 | var contractstreams = cmap.New()
58 |
59 | func main() {
60 | http.DefaultClient = &http.Client{Transport: &http.Transport{
61 | MaxIdleConns: 10,
62 | MaxConnsPerHost: 10,
63 | MaxIdleConnsPerHost: 2,
64 | IdleConnTimeout: 10 * time.Second,
65 | DisableCompression: true,
66 | }}
67 |
68 | if isRunningAsPlugin() {
69 | p := plugin.Plugin{
70 | Name: "etleneum",
71 | Version: "v2.0",
72 | Dynamic: true,
73 | Hooks: []plugin.Hook{
74 | {
75 | "htlc_accepted",
76 | htlc_accepted,
77 | },
78 | },
79 | OnInit: func(p *plugin.Plugin) {
80 | // set environment from envfile (hack)
81 | envpath := "etleneum.env"
82 | if !filepath.IsAbs(envpath) {
83 | // expand tlspath from lightning dir
84 | envpath = filepath.Join(filepath.Dir(p.Client.Path), envpath)
85 | }
86 |
87 | if _, err := os.Stat(envpath); err != nil {
88 | log.Fatal().Err(err).Str("path", envpath).Msg("envfile not found")
89 | }
90 |
91 | godotenv.Load(envpath)
92 |
93 | // globalize the lightning rpc client
94 | ln = p.Client
95 |
96 | // get our own nodeid
97 | res, err := ln.Call("getinfo")
98 | if err != nil {
99 | log.Fatal().Err(err).Msg("couldn't call getinfo")
100 | }
101 | s.NodeId = res.Get("id").String()
102 |
103 | // start the server
104 | server()
105 | },
106 | }
107 |
108 | p.Run()
109 | } else {
110 | // when not running as a plugin this will operate on the free mode
111 | s.FreeMode = true
112 |
113 | // start the server
114 | server()
115 | }
116 | }
117 |
118 | func server() {
119 | err = envconfig.Process("", &s)
120 | if err != nil {
121 | log.Fatal().Err(err).Msg("couldn't process envconfig.")
122 | }
123 |
124 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
125 | log = log.With().Timestamp().Logger()
126 |
127 | // postgres connection
128 | pg, err = sqlx.Connect("postgres", s.PostgresURL)
129 | if err != nil {
130 | log.Fatal().Err(err).Msg("couldn't connect to postgres")
131 | }
132 |
133 | // redis connection
134 | rurl, _ := url.Parse(s.RedisURL)
135 | pw, _ := rurl.User.Password()
136 | rds = redis.NewClient(&redis.Options{
137 | Addr: rurl.Host,
138 | Password: pw,
139 | })
140 | if err := rds.Ping().Err(); err != nil {
141 | log.Fatal().Err(err).Str("url", s.RedisURL).
142 | Msg("failed to connect to redis")
143 | }
144 |
145 | // http server
146 | router := mux.NewRouter()
147 | router.PathPrefix("/static/").Methods("GET").Handler(http.FileServer(httpPublic))
148 | router.Path("/favicon.ico").Methods("GET").HandlerFunc(
149 | func(w http.ResponseWriter, r *http.Request) {
150 | w.Header().Set("Content-Type", "image/png")
151 | iconf, _ := httpPublic.Open("static/icon.png")
152 | fstat, _ := iconf.Stat()
153 | http.ServeContent(w, r, "static/icon.png", fstat.ModTime(), iconf)
154 | return
155 | })
156 | router.Path("/~/contracts").Methods("GET").HandlerFunc(listContracts)
157 | router.Path("/~/contract").Methods("POST").HandlerFunc(prepareContract)
158 | router.Path("/~/contract/{ctid}").Methods("GET").HandlerFunc(getContract)
159 | router.Path("/~/contract/{ctid}/state").Methods("GET").HandlerFunc(getContractState)
160 | router.Path("/~/contract/{ctid}/state").Methods("POST").HandlerFunc(getContractState)
161 | router.Path("/~/contract/{ctid}/state/{jq}").Methods("GET").HandlerFunc(getContractState)
162 | router.Path("/~/contract/{ctid}/funds").Methods("GET").HandlerFunc(getContractFunds)
163 | router.Path("/~/contract/{ctid}").Methods("DELETE").HandlerFunc(deleteContract)
164 | router.Path("/~/contract/{ctid}/calls").Methods("GET").HandlerFunc(listCalls)
165 | router.Path("/~/contract/{ctid}/call").Methods("POST").HandlerFunc(prepareCall)
166 | router.Path("/~~~/contract/{ctid}").Methods("GET").HandlerFunc(contractStream)
167 | router.Path("/~/call/{callid}").Methods("GET").HandlerFunc(getCall)
168 | router.Path("/~/call/{callid}").Methods("PATCH").HandlerFunc(patchCall)
169 | router.Path("/lnurl/contract/{ctid}/call/{method}/{msatoshi}").
170 | Methods("GET").HandlerFunc(lnurlPayParams)
171 | router.Path("/lnurl/contract/{ctid}/call/{method}").
172 | Methods("GET").HandlerFunc(lnurlPayParams)
173 | router.Path("/lnurl/call/{callid}").Methods("GET").HandlerFunc(lnurlPayValues)
174 | router.Path("/~~~/session").Methods("GET").HandlerFunc(lnurlSession)
175 | router.Path("/lnurl/auth").Methods("GET").HandlerFunc(lnurlAuth)
176 | router.Path("/~/session/refresh").Methods("GET").HandlerFunc(refreshBalance)
177 | router.Path("/lnurl/withdraw").Methods("GET").HandlerFunc(lnurlWithdraw)
178 | router.Path("/lnurl/withdraw/callback").Methods("GET").HandlerFunc(lnurlWithdrawCallback)
179 | router.Path("/~/session/logout").Methods("POST").HandlerFunc(logout)
180 | router.Path("/^/webhook/github").Methods("POST").HandlerFunc(handleGitHubWebhook)
181 | router.Path("/_/decode-scid/{scid}").Methods("GET").HandlerFunc(handleDecodeScid)
182 | router.Path("/_/call-details/{callid}").Methods("GET").HandlerFunc(handleCallDetails)
183 | router.PathPrefix("/").Methods("GET").HandlerFunc(serveClient)
184 |
185 | srv := &http.Server{
186 | Handler: cors.New(cors.Options{
187 | AllowedOrigins: []string{"*"},
188 | AllowedMethods: []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT"},
189 | AllowCredentials: false,
190 | }).Handler(router),
191 | Addr: "0.0.0.0:" + s.Port,
192 | WriteTimeout: 25 * time.Second,
193 | ReadTimeout: 25 * time.Second,
194 | }
195 |
196 | idleConnsClosed := make(chan struct{})
197 | go func() {
198 | sigint := make(chan os.Signal, 1)
199 | signal.Notify(sigint, syscall.SIGTERM, syscall.SIGINT)
200 | <-sigint
201 |
202 | log.Debug().Msg("Received an interrupt signal, shutting down.")
203 | if err := srv.Shutdown(context.Background()); err != nil {
204 | // error from closing listeners, or context timeout:
205 | log.Warn().Err(err).Msg("HTTP server shutdown")
206 | }
207 |
208 | close(idleConnsClosed)
209 | }()
210 |
211 | log.Info().Str("port", s.Port).Msg("listening.")
212 | if err := srv.ListenAndServe(); err != http.ErrServerClosed {
213 | log.Warn().Err(err).Msg("listenAndServe")
214 | }
215 |
216 | <-idleConnsClosed
217 | }
218 |
219 | func serveClient(w http.ResponseWriter, r *http.Request) {
220 | w.Header().Set("Content-Type", "text/html")
221 | indexf, err := httpPublic.Open("static/index.html")
222 | if err != nil {
223 | log.Error().Err(err).Str("file", "static/index.html").
224 | Msg("make sure you generated bindata.go without -debug")
225 | return
226 | }
227 | fstat, _ := indexf.Stat()
228 | http.ServeContent(w, r, "static/index.html", fstat.ModTime(), indexf)
229 | return
230 | }
231 |
232 | func isRunningAsPlugin() bool {
233 | pid := os.Getppid()
234 | res, _ := exec.Command(
235 | "ps", "-p", strconv.Itoa(pid), "-o", "comm=",
236 | ).CombinedOutput()
237 |
238 | return strings.TrimSpace(string(res)) == "lightningd"
239 | }
240 |
--------------------------------------------------------------------------------
/call_functions.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "net/http"
9 | "strings"
10 | "time"
11 |
12 | "github.com/fiatjaf/etleneum/runlua"
13 | "github.com/fiatjaf/etleneum/types"
14 | "github.com/jmoiron/sqlx"
15 | "github.com/lucsky/cuid"
16 | "github.com/yudai/gojsondiff"
17 | )
18 |
19 | func getCallCosts(c types.Call, isLnurl bool) int64 {
20 | cost := s.FixedCallCostSatoshis * 1000 // a fixed cost of 1 satoshi by default
21 |
22 | if !isLnurl {
23 | chars := int64(len(string(c.Payload)))
24 | cost += 10 * chars // 50 msatoshi for each character in the payload
25 | }
26 |
27 | return cost
28 | }
29 |
30 | func callFromRedis(callid string) (call *types.Call, err error) {
31 | var jcall []byte
32 | call = &types.Call{}
33 |
34 | jcall, err = rds.Get("call:" + callid).Bytes()
35 | if err != nil {
36 | return
37 | }
38 |
39 | err = json.Unmarshal(jcall, call)
40 | if err != nil {
41 | return
42 | }
43 |
44 | return
45 | }
46 |
47 | func saveCallOnRedis(call types.Call) (jcall []byte, err error) {
48 | jcall, err = json.Marshal(call)
49 | if err != nil {
50 | return
51 | }
52 |
53 | err = rds.Set("call:"+call.Id, jcall, time.Hour*20).Err()
54 | if err != nil {
55 | return
56 | }
57 |
58 | return
59 | }
60 |
61 | func runCall(call *types.Call, txn *sqlx.Tx) (err error) {
62 | // get contract data
63 | var ct types.Contract
64 | err = txn.Get(&ct, `
65 | SELECT `+types.CONTRACTFIELDS+`, contracts.funds
66 | FROM contracts
67 | WHERE id = $1`,
68 | call.ContractId)
69 | if err != nil {
70 | log.Warn().Err(err).Str("ctid", call.ContractId).Str("callid", call.Id).
71 | Msg("failed to get contract data")
72 | return
73 | }
74 |
75 | // caller can be either an account id or null
76 | caller := sql.NullString{Valid: call.Caller != "", String: call.Caller}
77 |
78 | // save call data even though we don't know if it will succeed or not (this is a transaction anyway)
79 | _, err = txn.Exec(`
80 | INSERT INTO calls (id, contract_id, method, payload, cost, msatoshi, caller)
81 | VALUES ($1, $2, $3, $4, $5, $6, $7)
82 | `, call.Id, call.ContractId, call.Method, call.Payload, call.Cost, call.Msatoshi, caller)
83 | if err != nil {
84 | log.Warn().Err(err).Str("callid", call.Id).Msg("database error")
85 | return
86 | }
87 |
88 | // actually run the call
89 | dispatchContractEvent(call.ContractId, ctevent{call.Id, call.ContractId, call.Method, "", "start"}, "call-run-event")
90 | newStateO, err := runlua.RunCall(
91 | log,
92 | &callPrinter{call.ContractId, call.Id, call.Method},
93 | func(r *http.Request) (*http.Response, error) { return http.DefaultClient.Do(r) },
94 |
95 | // get external contract
96 | func(contractId string) (state interface{}, funds int64, err error) {
97 | var data types.Contract
98 | err = txn.Get(&data, "SELECT state, contracts.funds FROM contracts WHERE id = $1", contractId)
99 | if err != nil {
100 | return
101 | }
102 | err = json.Unmarshal(data.State, &state)
103 | if err != nil {
104 | return
105 | }
106 | return state, data.Funds, nil
107 | },
108 |
109 | // call external method
110 | func(externalContractId string, method string, payload interface{}, msatoshi int64, account string) (err error) {
111 | jpayload, _ := json.Marshal(payload)
112 |
113 | // build the call
114 | externalCall := &types.Call{
115 | ContractId: externalContractId,
116 | Id: "r" + cuid.Slug(), // a normal new call id
117 | Method: method,
118 | Payload: jpayload,
119 | Msatoshi: msatoshi,
120 | Cost: 1000, // only the fixed cost, the other costs are included
121 | Caller: account,
122 | }
123 |
124 | // pay for the call (by extracting the fixed cost from call satoshis)
125 | // this external call will already have its cost saved by runCall()
126 | _, err = txn.Exec(`
127 | UPDATE calls AS c SET msatoshi = c.msatoshi - $2 WHERE id = $1
128 | `, call.Id, externalCall.Cost)
129 | if err != nil {
130 | log.Error().Err(err).Msg("external call cost update failed")
131 | return
132 | }
133 |
134 | // transfer funds from current contract to the external contract
135 | if externalCall.Msatoshi > 0 {
136 | _, err = txn.Exec(`
137 | INSERT INTO internal_transfers
138 | (call_id, msatoshi, from_contract, to_contract)
139 | VALUES ($1, $2, $3, $4)
140 | `, call.Id, externalCall.Msatoshi, ct.Id, externalCall.ContractId)
141 | if err != nil {
142 | log.Error().Err(err).Msg("external call transfer failed")
143 | return
144 | }
145 | }
146 |
147 | // then run
148 | err = runCall(externalCall, txn)
149 | if err != nil {
150 | return err
151 | }
152 |
153 | return nil
154 | },
155 |
156 | // get contract balance
157 | func() (contractFunds int, err error) {
158 | err = txn.Get(&contractFunds, "SELECT contracts.funds FROM contracts WHERE id = $1", ct.Id)
159 | return
160 | },
161 |
162 | // send from contract
163 | func(target string, msat int) (msatoshiSent int, err error) {
164 | var totype string
165 | if len(target) == 0 {
166 | return 0, errors.New("can't send to blank recipient")
167 | } else if target[0] == 'c' {
168 | totype = "contract"
169 | } else if target[0] == 'a' {
170 | totype = "account"
171 | } else {
172 | return 0, errors.New("invalid recipient " + target)
173 | }
174 |
175 | _, err = txn.Exec(`
176 | INSERT INTO internal_transfers (call_id, msatoshi, from_contract, to_`+totype+`)
177 | VALUES ($1, $2, $3, $4)
178 | `, call.Id, msat, ct.Id, target)
179 | if err != nil {
180 | return
181 | }
182 |
183 | var funds int
184 | err = txn.Get(&funds, "SELECT contracts.funds FROM contracts WHERE id = $1", ct.Id)
185 | if err != nil {
186 | return
187 | }
188 | if funds < 0 {
189 | return 0, fmt.Errorf("insufficient contract funds, needed %d msat more", -funds)
190 | }
191 |
192 | dispatchContractEvent(call.ContractId, ctevent{call.Id, call.ContractId, call.Method, fmt.Sprintf("contract.send(%s, %d)", target, msat), "function"}, "call-run-event")
193 | return msat, nil
194 | },
195 |
196 | // get account balance
197 | func() (userBalance int, err error) {
198 | if call.Caller == "" {
199 | return 0, errors.New("no account")
200 | }
201 | err = txn.Get(&userBalance, "SELECT balance($1)", call.Caller)
202 | return
203 | },
204 |
205 | // send from current account
206 | func(target string, msat int) (msatoshiSent int, err error) {
207 | var totype string
208 | if len(target) == 0 {
209 | return 0, errors.New("can't send to blank recipient")
210 | } else if target[0] == 'c' {
211 | totype = "contract"
212 | } else if target[0] == 'a' {
213 | totype = "account"
214 | } else {
215 | return 0, errors.New("invalid recipient " + target)
216 | }
217 | _, err = txn.Exec(`
218 | INSERT INTO internal_transfers (call_id, msatoshi, from_account, to_`+totype+`)
219 | VALUES ($1, $2, $3, $4)
220 | `, call.Id, msat, call.Caller, target)
221 | if err != nil {
222 | return
223 | }
224 |
225 | var balance int
226 | err = txn.Get(&balance, "SELECT balance($1)", ct.Id)
227 | if err != nil {
228 | return
229 | }
230 | if balance < 0 {
231 | return 0, fmt.Errorf("insufficient account balance, needed %d msat more", -balance)
232 | }
233 |
234 | dispatchContractEvent(call.ContractId, ctevent{call.Id, call.ContractId, call.Method, fmt.Sprintf("account.send(%s, %d)", target, msat), "function"}, "call-run-event")
235 | return msat, nil
236 | },
237 | ct,
238 | *call,
239 | )
240 | if err != nil {
241 | return
242 | }
243 |
244 | newState, err := json.Marshal(newStateO)
245 | if err != nil {
246 | log.Warn().Err(err).Str("callid", call.Id).Msg("failed to marshal new state")
247 | return
248 | }
249 |
250 | // calculate and save state diff
251 | differ := gojsondiff.New()
252 | diff, err := differ.Compare(ct.State, newState)
253 | if err == nil {
254 | var adiff []string
255 | for _, idelta := range diff.Deltas() {
256 | adiff = append(adiff, diffDeltaOneliner("", idelta)...)
257 | }
258 | tdiff := strings.Join(adiff, "\n")
259 | _, err = txn.Exec(`
260 | UPDATE calls SET diff = $2
261 | WHERE id = $1
262 | `, call.Id, tdiff)
263 | if err != nil {
264 | log.Warn().Err(err).Str("callid", call.Id).
265 | Str("diff", tdiff).
266 | Msg("database error")
267 | return
268 | }
269 | } else {
270 | log.Warn().Err(err).Str("callid", call.Id).Msg("error calculating diff")
271 | }
272 |
273 | // save new state
274 | _, err = txn.Exec(`
275 | UPDATE contracts SET state = $2
276 | WHERE id = $1
277 | `, call.ContractId, newState)
278 | if err != nil {
279 | log.Warn().Err(err).Str("callid", call.Id).Str("state", string(newState)).
280 | Msg("database error")
281 | return
282 | }
283 |
284 | // get contract balance (if balance is negative after the call all will fail)
285 | var contractFunds int
286 | err = txn.Get(&contractFunds, `
287 | SELECT contracts.funds
288 | FROM contracts WHERE id = $1`,
289 | call.ContractId)
290 | if err != nil {
291 | log.Warn().Err(err).Str("callid", call.Id).Msg("database error")
292 | return
293 | }
294 |
295 | if contractFunds < 0 {
296 | log.Warn().Err(err).Str("callid", call.Id).Msg("contract out of funds")
297 | err = errors.New("contract out of funds")
298 | return
299 | }
300 |
301 | // ok, all is good
302 | log.Info().Str("callid", call.Id).Msg("call done")
303 | return
304 | }
305 |
--------------------------------------------------------------------------------
/runlua/runlua.go:
--------------------------------------------------------------------------------
1 | package runlua
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "time"
10 |
11 | "github.com/aarzilli/golua/lua"
12 | "github.com/fiatjaf/etleneum/types"
13 | "github.com/fiatjaf/lunatico"
14 | "github.com/lucsky/cuid"
15 | "github.com/rs/zerolog"
16 | )
17 |
18 | var log zerolog.Logger
19 |
20 | func RunCall(
21 | logger zerolog.Logger,
22 | printToDestination io.Writer,
23 | makeRequest func(*http.Request) (*http.Response, error),
24 | getExternalContractData func(string) (interface{}, int64, error),
25 | callExternalMethod func(string, string, interface{}, int64, string) error,
26 | getContractFunds func() (int, error),
27 | sendFromContract func(target string, sats int) (int, error),
28 | getCurrentAccountBalance func() (int, error),
29 | sendFromCurrentAccount func(target string, sats int) (int, error),
30 | contract types.Contract,
31 | call types.Call,
32 | ) (stateAfter interface{}, err error) {
33 | log = logger
34 | completedOk := make(chan bool, 1)
35 | failed := make(chan error, 1)
36 |
37 | go func() {
38 | stateAfter, err = runCall(
39 | printToDestination,
40 | makeRequest,
41 | getExternalContractData,
42 | callExternalMethod,
43 | getContractFunds,
44 | sendFromContract,
45 | getCurrentAccountBalance,
46 | sendFromCurrentAccount,
47 | contract,
48 | call,
49 | )
50 | if err != nil {
51 | failed <- err
52 | return
53 | }
54 |
55 | completedOk <- true
56 | }()
57 |
58 | select {
59 | case <-completedOk:
60 | return
61 | case failure := <-failed:
62 | err = failure
63 | return
64 | case <-time.After(time.Second * 3):
65 | err = errors.New("timeout!")
66 | return
67 | }
68 | }
69 |
70 | func runCall(
71 | printToDestination io.Writer,
72 | makeRequest func(*http.Request) (*http.Response, error),
73 | getExternalContractData func(string) (interface{}, int64, error),
74 | callExternalMethod func(string, string, interface{}, int64, string) error,
75 | getContractFunds func() (int, error),
76 | sendFromContract func(target string, sats int) (int, error),
77 | getCurrentAccountBalance func() (int, error),
78 | sendFromCurrentAccount func(target string, sats int) (int, error),
79 | contract types.Contract,
80 | call types.Call,
81 | ) (stateAfter interface{}, err error) {
82 | // init lua
83 | L := lua.NewState()
84 | defer L.Close()
85 | L.OpenLibs()
86 |
87 | initialFunds := contract.Funds + call.Msatoshi
88 |
89 | lua_http_gettext, lua_http_getjson, lua_http_postjson, _ := make_lua_http(makeRequest)
90 | var lua_current_account interface{}
91 | if call.Caller != "" {
92 | lua_current_account = call.Caller
93 | }
94 |
95 | var currentstate map[string]interface{}
96 | err = contract.State.Unmarshal(¤tstate)
97 | if err != nil {
98 | return
99 | }
100 |
101 | var payload map[string]interface{}
102 | err = call.Payload.Unmarshal(&payload)
103 | if err != nil {
104 | return
105 | }
106 |
107 | // run the code
108 | log.Debug().Str("method", call.Method).
109 | Str("caller", call.Caller).
110 | Int64("msatoshi", call.Msatoshi).
111 | Interface("payload", payload).
112 | Interface("state", currentstate).
113 | Int64("funds", initialFunds).
114 | Msg("running code")
115 |
116 | actualCode := contract.Code + "\nreturn " + call.Method + "()"
117 |
118 | // globals
119 | lunatico.SetGlobals(L, map[string]interface{}{
120 | "code": actualCode,
121 | "state": currentstate,
122 | "payload": payload,
123 | "msatoshi": call.Msatoshi,
124 | "call": call.Id,
125 | "current_contract": call.ContractId,
126 | "current_account": lua_current_account,
127 | "send_from_current_account": sendFromCurrentAccount,
128 | "get_current_account_balance": getCurrentAccountBalance,
129 | "get_external_contract_data": getExternalContractData,
130 | "call_external_method": callExternalMethod,
131 | "contract": contract.Id,
132 | "get_contract_funds": getContractFunds,
133 | "send_from_contract": sendFromContract,
134 | "httpgettext": lua_http_gettext,
135 | "httpgetjson": lua_http_getjson,
136 | "httppostjson": lua_http_postjson,
137 | "keybase_verify": lua_keybase_verify_signature,
138 | "keybase_verify_bundle": lua_keybase_verify_bundle,
139 | "keybase_extract_message": lua_keybase_extract_message,
140 | "keybase_lookup": lua_keybase_lookup,
141 | "print": func(args ...interface{}) {
142 | actualArgs := make([]interface{}, len(args)*2+1)
143 | i := 0
144 | for _, arg := range args {
145 | var v interface{}
146 | switch arg.(type) {
147 | case string, int, int64, float64, bool:
148 | v = arg
149 | default:
150 | j, _ := json.Marshal(arg)
151 | v = string(j)
152 | }
153 |
154 | actualArgs[i] = v
155 | actualArgs[i+1] = "\t"
156 | i += 2
157 | }
158 | actualArgs[i] = "\n"
159 | fmt.Fprint(printToDestination, actualArgs...)
160 | },
161 | "sha256": lua_sha256,
162 | "cuid": cuid.Slug,
163 | "parse_bolt11": lua_parse_bolt11,
164 | })
165 |
166 | code := `
167 | -- account.id will be nil if there's not a logged user
168 | local account_id = nil
169 | if current_account ~= "" then
170 | account_id = current_account
171 | end
172 |
173 | sandbox_env = {
174 | ipairs = ipairs,
175 | next = next,
176 | pairs = pairs,
177 | error = error,
178 | tonumber = tonumber,
179 | tostring = tostring,
180 | type = type,
181 | unpack = unpack,
182 | utf8 = utf8,
183 | string = { byte = string.byte, char = string.char, find = string.find,
184 | format = string.format, gmatch = string.gmatch, gsub = string.gsub,
185 | len = string.len, lower = string.lower, match = string.match,
186 | rep = string.rep, reverse = string.reverse, sub = string.sub,
187 | upper = string.upper },
188 | table = { insert = table.insert, maxn = table.maxn, remove = table.remove,
189 | sort = table.sort, pack = table.pack },
190 | math = { abs = math.abs, acos = math.acos, asin = math.asin,
191 | atan = math.atan, atan2 = math.atan2, ceil = math.ceil, cos = math.cos,
192 | cosh = math.cosh, deg = math.deg, exp = math.exp, floor = math.floor,
193 | fmod = math.fmod, frexp = math.frexp, huge = math.huge,
194 | ldexp = math.ldexp, log = math.log, log10 = math.log10, max = math.max,
195 | min = math.min, modf = math.modf, pi = math.pi, pow = math.pow,
196 | rad = math.rad, random = math.random, randomseed = math.randomseed,
197 | sin = math.sin, sinh = math.sinh, sqrt = math.sqrt, tan = math.tan, tanh = math.tanh },
198 | os = { clock = os.clock, difftime = os.difftime, time = os.time, date = os.date },
199 | http = {
200 | gettext = httpgettext,
201 | getjson = httpgetjson,
202 | postjson = httppostjson
203 | },
204 | util = {
205 | sha256 = sha256,
206 | cuid = cuid,
207 | print = print,
208 | parse_bolt11 = parse_bolt11,
209 | },
210 | contract = {
211 | id = current_contract,
212 | get_funds = function ()
213 | funds, err = get_contract_funds()
214 | if err ~= nil then
215 | error(err)
216 | end
217 | return funds
218 | end,
219 | send = function (target, amount)
220 | amt, err = send_from_contract(target, amount)
221 | if err ~= nil then
222 | error(err)
223 | end
224 | return amt
225 | end,
226 | state = state
227 | },
228 | etleneum = {
229 | get_contract = function (id)
230 | state, funds, err = get_external_contract_data(id)
231 | if err ~= nil then
232 | error(err)
233 | end
234 | return state, funds
235 | end,
236 | call_external = function (contract, method, payload, msatoshi, params)
237 | local as = nil
238 | if account_id and params.as == 'caller' then
239 | as = account_id
240 | elseif params.as == 'contract' then
241 | as = current_contract
242 | end
243 | local err = call_external_method(contract, method, payload, msatoshi, as)
244 | if err ~= nil then
245 | error(err)
246 | end
247 | end
248 | },
249 | account = {
250 | id = account_id,
251 | send = function (target, amount)
252 | amt, err = send_from_current_account(target, amount)
253 | if err ~= nil then
254 | error(err)
255 | end
256 | return amt
257 | end,
258 | get_balance = function ()
259 | balance, err = get_current_account_balance()
260 | if err ~= nil then
261 | error(err)
262 | end
263 | return balance
264 | end,
265 | },
266 | call = {
267 | id = call,
268 | payload = payload,
269 | msatoshi = msatoshi
270 | },
271 | keybase = {
272 | verify = function (username, text_or_bundle, signature_block)
273 | if not signature_block then
274 | return keybase_verify_bundle(username, text_or_bundle)
275 | end
276 | return keybase_verify(username, text_or_bundle, signature_block)
277 | end,
278 | extract_message = keybase_extract_message,
279 | lookup = keybase_lookup,
280 | exists = function (n) return keybase.username(n) ~= "" end,
281 | github = function (n) return keybase.lookup("github", n) end,
282 | twitter = function (n) return keybase.lookup("twitter", n) end,
283 | reddit = function (n) return keybase.lookup("reddit", n) end,
284 | hackernews = function (n) return keybase.lookup("hackernews", n) end,
285 | key_fingerprint = function (n) return keybase.lookup("key_fingerprint", n) end,
286 | domain = function (n) return keybase.lookup("domain", n) end,
287 | username = function (n) return keybase.lookup("usernames", n) end,
288 | _verify = keybase_verify,
289 | _verify_bundle = keybase_verify_bundle,
290 | }
291 | }
292 |
293 | _calls = 0
294 | function count ()
295 | _calls = _calls + 1
296 | if _calls > 1000 then
297 | error('too many operations!')
298 | end
299 | end
300 | debug.sethook(count, 'c')
301 |
302 | ret = load(code, 'call', 't', sandbox_env)()
303 | state = sandbox_env.contract.state
304 | `
305 |
306 | err = L.DoString(code)
307 | if err != nil {
308 | st := stackTraceWithCode(err.Error(), actualCode)
309 | log.Print(st)
310 | err = errors.New(st)
311 | return
312 | }
313 |
314 | globalsAfter := lunatico.GetGlobals(L, "ret", "state")
315 | stateAfter = globalsAfter["state"]
316 |
317 | // get state after method is run
318 | if call.Method == "__init__" {
319 | // on __init__ calls the returned value is the initial state
320 | stateAfter = globalsAfter["ret"]
321 | }
322 |
323 | return stateAfter, nil
324 | }
325 |
--------------------------------------------------------------------------------
/client/View.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
222 |
223 |
224 | {#if contract && contract.name}
225 | [{contract.id}] {contract.name} | etleneum contract
226 | {/if}
227 |
228 |
229 | {#if !contract}
230 | loading
231 | {:else}
232 |
233 |
234 |
235 | {contract.name} {#if window.location.host !== 'etleneum.com'}
236 | delete {/if}
237 |
238 |
{(contract.funds/1000).toFixed(3)} sat
239 |
state
240 |
241 |
readme
242 |
{contract.readme}
246 |
code
247 |
{contract.code}
248 |
249 |
250 |
make a call
251 | {#if contract.methods.length == 0 }
252 |
apparently this contract has no callable methods
253 | {:else} {#if nextcall.invoice}
254 |
255 |
256 |
{nextcall.id} pay to make the call
257 |
prepare a different call
258 |
259 | {:else}
260 |
261 |
262 | method: {#if nextmethod}
{nextmethod.name} {:else}none
263 | selected{/if}
264 |
265 | {#each contract.methods as method (method.name)}
266 |
270 | {method.name}
271 |
272 | {/each}
273 |
274 | {#if nextmethod && nextmethod.auth}
275 |
apparently this method requires you to be authenticated
276 | {/if}
277 |
278 | {#if nextmethod}
279 |
280 | satoshi:
282 |
283 | {#each nextmethod.params as pf (pf)}
284 |
285 | {pf}:
286 |
290 |
291 | {/each} {#if Object.keys(nextcall.payload).length > 0}
292 | payload:
293 | {/if}
294 |
295 | make this call authenticated with your account:
296 |
301 |
302 | prepare call
303 | {/if}
304 |
305 | {/if} {#if nextmethod && !nextcall.invoice}
306 |
307 |
reusable lnurl-pay for this call
308 | {#if nextcall.includeCallerSession}
309 |
(includes secret auth token)
310 | {/if}
311 |
312 |
313 |
314 |
315 | with
317 | amount
319 |
320 |
321 | {/if} {/if}
322 |
323 |
324 |
contract (recent) history
325 | {#each calls as call}
326 |
327 | {/each}
328 |
329 |
330 | {/if}
331 |
332 |
385 |
--------------------------------------------------------------------------------
/account_handlers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "encoding/hex"
7 | "encoding/json"
8 | "fmt"
9 | "net/http"
10 | "strconv"
11 | "time"
12 |
13 | "github.com/fiatjaf/etleneum/types"
14 | "github.com/fiatjaf/go-lnurl"
15 | lightning "github.com/fiatjaf/lightningd-gjson-rpc"
16 | "github.com/lucsky/cuid"
17 | "gopkg.in/antage/eventsource.v1"
18 | )
19 |
20 | func lnurlSession(w http.ResponseWriter, r *http.Request) {
21 | var es eventsource.EventSource
22 | session := r.URL.Query().Get("session")
23 |
24 | if session == "" {
25 | session = lnurl.RandomK1()
26 | } else {
27 | // check session validity as k1
28 | b, err := hex.DecodeString(session)
29 | if err != nil || len(b) != 32 {
30 | session = lnurl.RandomK1()
31 | } else {
32 | // finally try to fetch an existing stream
33 | ies, ok := userstreams.Get(session)
34 | if ok {
35 | es = ies.(eventsource.EventSource)
36 | }
37 | }
38 | }
39 |
40 | if es == nil {
41 | es = eventsource.New(
42 | &eventsource.Settings{
43 | Timeout: 5 * time.Second,
44 | CloseOnTimeout: true,
45 | IdleTimeout: 1 * time.Minute,
46 | },
47 | func(r *http.Request) [][]byte {
48 | return [][]byte{
49 | []byte("X-Accel-Buffering: no"),
50 | []byte("Cache-Control: no-cache"),
51 | []byte("Content-Type: text/event-stream"),
52 | []byte("Connection: keep-alive"),
53 | []byte("Access-Control-Allow-Origin: *"),
54 | }
55 | },
56 | )
57 | userstreams.Set(session, es)
58 | go func() {
59 | for {
60 | time.Sleep(25 * time.Second)
61 | es.SendEventMessage("", "keepalive", "")
62 | }
63 | }()
64 | }
65 |
66 | go func() {
67 | time.Sleep(100 * time.Millisecond)
68 | es.SendRetryMessage(3 * time.Second)
69 | }()
70 |
71 | accountId := rds.Get("auth-session:" + session).Val()
72 | if accountId != "" {
73 | // we're logged already, so send account information
74 | go func() {
75 | time.Sleep(100 * time.Millisecond)
76 | var acct types.Account
77 | err := pg.Get(&acct, `SELECT `+types.ACCOUNTFIELDS+` FROM accounts WHERE id = $1`, accountId)
78 | if err != nil {
79 | log.Error().Err(err).Str("session", session).Str("id", accountId).
80 | Msg("failed to load account from session")
81 | return
82 | }
83 | es.SendEventMessage(`{"account": "`+acct.Id+`", "balance": `+strconv.FormatInt(acct.Balance, 10)+`, "secret": "`+getAccountSecret(acct.Id)+`"}`, "auth", "")
84 | }()
85 |
86 | // we're logged already, so send history
87 | go func() {
88 | time.Sleep(100 * time.Millisecond)
89 | notifyHistory(es, accountId)
90 | }()
91 |
92 | // also renew this session
93 | rds.Expire("auth-session:"+session, time.Hour*24*30)
94 | }
95 |
96 | // always send lnurls because we need lnurl-withdraw even if we're
97 | // logged already
98 | go func() {
99 | time.Sleep(100 * time.Millisecond)
100 | auth, _ := lnurl.LNURLEncode(s.ServiceURL + "/lnurl/auth?tag=login&k1=" + session)
101 | withdraw, _ := lnurl.LNURLEncode(s.ServiceURL + "/lnurl/withdraw?session=" + session)
102 |
103 | es.SendEventMessage(`{"auth": "`+auth+`", "withdraw": "`+withdraw+`"}`, "lnurls", "")
104 | }()
105 |
106 | es.ServeHTTP(w, r)
107 | }
108 |
109 | func lnurlAuth(w http.ResponseWriter, r *http.Request) {
110 | params := r.URL.Query()
111 | k1 := params.Get("k1")
112 | sig := params.Get("sig")
113 | key := params.Get("key")
114 |
115 | if ok, err := lnurl.VerifySignature(k1, sig, key); !ok {
116 | log.Debug().Err(err).Str("k1", k1).Str("sig", sig).Str("key", key).
117 | Msg("failed to verify lnurl-auth signature")
118 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("signature verification failed."))
119 | return
120 | }
121 |
122 | session := k1
123 | log.Debug().Str("session", session).Str("pubkey", key).Msg("valid login")
124 |
125 | // there must be a valid auth session (meaning an eventsource client) one otherwise something is wrong
126 | ies, ok := userstreams.Get(session)
127 | if !ok {
128 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("there's no browser session to authorize."))
129 | return
130 | }
131 |
132 | // get the account id from the pubkey
133 | var acct types.Account
134 | err = pg.Get(&acct, `
135 | INSERT INTO accounts (id, lnurl_key) VALUES ($1, $2)
136 | ON CONFLICT (lnurl_key)
137 | DO UPDATE SET lnurl_key = $2
138 | RETURNING `+types.ACCOUNTFIELDS+`
139 | `, "a"+cuid.Slug(), key)
140 | if err != nil {
141 | log.Error().Err(err).Str("key", key).Msg("failed to ensure account")
142 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("failed to ensure account with key " + key + "."))
143 | return
144 | }
145 |
146 | // assign the account id to this session on redis
147 | if rds.Set("auth-session:"+session, acct.Id, time.Hour*24*30).Err() != nil {
148 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("failed to save session."))
149 | return
150 | }
151 |
152 | es := ies.(eventsource.EventSource)
153 |
154 | // notify browser
155 | es.SendEventMessage(`{"session": "`+k1+`", "account": "`+acct.Id+`", "balance": `+strconv.FormatInt(acct.Balance, 10)+`, "secret": "`+getAccountSecret(acct.Id)+`"}`, "auth", "")
156 |
157 | // also send history
158 | notifyHistory(es, acct.Id)
159 |
160 | json.NewEncoder(w).Encode(lnurl.OkResponse())
161 | }
162 |
163 | func refreshBalance(w http.ResponseWriter, r *http.Request) {
164 | session := r.URL.Query().Get("session")
165 |
166 | // get account id from session
167 | accountId, err := rds.Get("auth-session:" + session).Result()
168 | if err != nil {
169 | log.Error().Err(err).Str("session", session).Msg("failed to get session from redis on refresh")
170 | w.WriteHeader(500)
171 | return
172 | }
173 |
174 | // get balance
175 | var balance int
176 | err = pg.Get(&balance, "SELECT balance($1)", accountId)
177 | if err != nil {
178 | w.WriteHeader(500)
179 | return
180 | }
181 |
182 | if ies, ok := userstreams.Get(session); ok {
183 | ies.(eventsource.EventSource).SendEventMessage(`{"account": "`+accountId+`", "balance": `+strconv.Itoa(balance)+`, "secret": "`+getAccountSecret(accountId)+`"}`, "auth", "")
184 | }
185 |
186 | w.WriteHeader(200)
187 | }
188 |
189 | func lnurlWithdraw(w http.ResponseWriter, r *http.Request) {
190 | session := r.URL.Query().Get("session")
191 |
192 | // get account id from session
193 | accountId, err := rds.Get("auth-session:" + session).Result()
194 | if err != nil {
195 | log.Error().Err(err).Str("session", session).Msg("failed to get session from redis on withdraw")
196 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("lnurl session " + session + " has expired."))
197 | return
198 | }
199 |
200 | // get balance
201 | var balance int64
202 | err = pg.Get(&balance, "SELECT balance($1)", accountId)
203 | if err != nil {
204 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("error fetching " + accountId + " balance."))
205 | return
206 | }
207 |
208 | if balance < 10000 {
209 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("the minimum withdrawal is 10 sat, your balance is " + strconv.FormatInt(balance, 10) + " msat."))
210 | return
211 | }
212 |
213 | json.NewEncoder(w).Encode(lnurl.LNURLWithdrawResponse{
214 | LNURLResponse: lnurl.LNURLResponse{Status: "OK"},
215 | Callback: fmt.Sprintf("%s/lnurl/withdraw/callback", s.ServiceURL),
216 | K1: session,
217 | MaxWithdrawable: int64(balance),
218 | MinWithdrawable: 100000,
219 | DefaultDescription: fmt.Sprintf("etleneum.com %s balance withdraw", accountId),
220 | Tag: "withdrawRequest",
221 | })
222 | }
223 |
224 | func lnurlWithdrawCallback(w http.ResponseWriter, r *http.Request) {
225 | session := r.URL.Query().Get("k1")
226 | bolt11 := r.URL.Query().Get("pr")
227 |
228 | // get account id from session
229 | accountId, err := rds.Get("auth-session:" + session).Result()
230 | if err != nil {
231 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("lnurl session " + session + " has expired."))
232 | return
233 | }
234 |
235 | // start withdrawal transaction
236 | txn, err := pg.BeginTxx(context.TODO(), &sql.TxOptions{Isolation: sql.LevelSerializable})
237 | if err != nil {
238 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("internal database error."))
239 | return
240 | }
241 | defer txn.Rollback()
242 |
243 | if s.FreeMode {
244 | json.NewEncoder(w).Encode(lnurl.OkResponse())
245 | return
246 | }
247 |
248 | // decode invoice
249 | inv, err := ln.Call("decodepay", bolt11)
250 | if err != nil {
251 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("failed to decode invoice."))
252 | return
253 | }
254 | amount := inv.Get("msatoshi").Int()
255 |
256 | log.Debug().Str("bolt11", bolt11).Str("account", accountId).Int64("amount", amount).
257 | Msg("got a withdraw payment request")
258 |
259 | fee := int64(float64(amount)/0.997) - amount
260 |
261 | // add a pending withdrawal
262 | _, err = txn.Exec(`
263 | INSERT INTO withdrawals (account_id, msatoshi, fee_msat, fulfilled, bolt11)
264 | VALUES ($1, $2, $3, false, $4)
265 | `, accountId, amount, fee, bolt11)
266 | if err != nil {
267 | log.Warn().Err(err).Msg("error inserting withdrawal")
268 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("database error."))
269 | return
270 | }
271 |
272 | // check balance afterwards
273 | var balance int
274 | err = txn.Get(&balance, "SELECT balance($1)", accountId)
275 | if err != nil {
276 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("database error."))
277 | return
278 | }
279 | if balance < 0 {
280 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("insufficient balance."))
281 | return
282 | }
283 |
284 | log.Debug().Int("balance after", balance).Msg("will fulfill")
285 |
286 | err = txn.Commit()
287 | if err != nil {
288 | log.Warn().Err(err).Msg("error commiting withdrawal")
289 | json.NewEncoder(w).Encode(lnurl.ErrorResponse("database error."))
290 | return
291 | }
292 |
293 | // actually send the payment
294 | go func() {
295 | payresp, err := ln.CallWithCustomTimeout(time.Hour*24*30, "pay",
296 | map[string]interface{}{
297 | "bolt11": bolt11,
298 | "label": "etleneum withdraw " + accountId,
299 | "use_shadow": false,
300 | "maxfeepercent": 0.3,
301 | "exemptfee": 0,
302 | })
303 | log.Debug().Err(err).Str("resp", payresp.String()).Str("account", accountId).Str("bolt11", bolt11).
304 | Msg("withdraw pay result")
305 |
306 | if _, ok := err.(lightning.ErrorCommand); ok {
307 | goto failure
308 | }
309 |
310 | if payresp.Get("status").String() == "complete" {
311 | // mark as fulfilled
312 | _, err := pg.Exec(`UPDATE withdrawals SET fulfilled = true WHERE bolt11 = $1`, bolt11)
313 | if err != nil {
314 | log.Error().Err(err).Str("accountId", accountId).
315 | Msg("error marking payment as fulfilled")
316 | }
317 |
318 | return
319 | }
320 |
321 | // call listpays to check failure
322 | if listpays, _ := ln.Call("listpays", bolt11); listpays.Get("pays.#").Int() > 0 && listpays.Get("pays.0.status").String() != "failed" {
323 | // not a failure -- but also not a success
324 | // we don't know what happened, maybe it's pending, so don't do anything
325 | log.Debug().Str("bolt11", bolt11).
326 | Msg("we don't know what happened with this payment")
327 |
328 | // notify browser
329 | if ies, ok := userstreams.Get(session); ok {
330 | ies.(eventsource.EventSource).SendEventMessage("We don't know what happened with the payment.", "error", "")
331 | }
332 |
333 | return
334 | }
335 |
336 | // if we reached this point then it's a failure
337 | failure:
338 | // delete attempt since it has undoubtely failed
339 | _, err = pg.Exec(`DELETE FROM withdrawals WHERE bolt11 = $1`, bolt11)
340 | if err != nil {
341 | log.Error().Err(err).Str("accountId", accountId).
342 | Msg("error deleting withdrawal attempt")
343 | }
344 |
345 | // notify browser
346 | if ies, ok := userstreams.Get(session); ok {
347 | ies.(eventsource.EventSource).SendEventMessage("Payment failed.", "error", "")
348 | }
349 | }()
350 |
351 | json.NewEncoder(w).Encode(lnurl.OkResponse())
352 | }
353 |
354 | func logout(w http.ResponseWriter, r *http.Request) {
355 | session := r.URL.Query().Get("session")
356 | rds.Del("auth-session:" + session)
357 | userstreams.Remove(session)
358 | w.WriteHeader(200)
359 | }
360 |
--------------------------------------------------------------------------------
/tests/test_integration.py:
--------------------------------------------------------------------------------
1 | import json
2 | import datetime
3 | import urllib3
4 | import hashlib
5 | import subprocess
6 | from pathlib import Path
7 |
8 | import requests
9 | import sseclient
10 |
11 |
12 | def test_everything_else(etleneum, lightnings):
13 | etleneum_proc, url = etleneum
14 | _, [rpc_a, rpc_b] = lightnings
15 |
16 | # there are zero contracts
17 | r = requests.get(url + "/~/contracts")
18 | assert r.ok
19 | assert r.json() == {"ok": True, "value": []}
20 |
21 | # create a valid contract
22 | ctdata = {
23 | "name": "ico",
24 | "readme": "get rich!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!",
25 | "code": """
26 | function __init__ ()
27 | return {
28 | token_name='richcoin',
29 | balances={dummy=0}
30 | }
31 | end
32 |
33 | function setowner ()
34 | if not account.id then
35 | error('no account')
36 | end
37 |
38 | if account.get_balance() ~= 0 then
39 | error('this should never happen in this test')
40 | end
41 |
42 | contract.state.owner = account.id
43 | end
44 |
45 | function buy ()
46 | local price = 5000
47 |
48 | if call.msatoshi == call.payload.amount * price then
49 | current = contract.state.balances[call.payload.user]
50 | if current == nil then
51 | current = 0
52 | end
53 | contract.state.balances[call.payload.user] = current + call.payload.amount
54 | else
55 | error("wrong amount paid")
56 | end
57 | end
58 |
59 | function cashout () -- in which the contract owner takes all the funds and disappears
60 | if call.payload.amt_to_cashout < contract.get_funds() then
61 | error('you have to cashout all. tried to cashout ' .. call.payload.amt_to_cashout .. ' but contract has ' .. contract.get_funds())
62 | end
63 |
64 | contract.send(contract.state.owner, call.payload.amt_to_cashout)
65 | end
66 |
67 | function cashout_wrong ()
68 | contract.send(nil, call.payload.amt_to_cashout)
69 | end
70 |
71 | function return23 () return 23 end
72 |
73 | local just23 = 23
74 |
75 | function apitest ()
76 | local resp = http.getjson("https://httpbin.org/anything?numbers=1&numbers=2&fruit=banana")
77 |
78 | sig = [[
79 | -----BEGIN PGP SIGNATURE-----
80 | Version: Keybase OpenPGP v2.1.0
81 | Comment: https://keybase.io/crypto
82 |
83 | wsBcBAABCgAGBQJcqPiIAAoJEAJs7pbOl+xq3wAH/RdKQspEpZOFpRrurD21dlvj
84 | 2umI4Cu2XBOfVCNZPh++hpacNr2lk5iVvGm7eHgO54ybd11+b9QVcWwEyRLeKQhn
85 | SbcPlc90POXZ05J3uwjVItLsNVW/Z9HYDDb8Fcf9C8s+ywVZ9oDHz9W4fRaBWKD3
86 | Cwt2SscEsFFOTenlBJDU/8laX8EAzdqJ9PbUwqmwyrAYXmWqklLC7xOMdGHhLieZ
87 | ZTlElCj5cSDz8M43sUMeGCzQ9v2MNxgz95GVZZwpTNI/Mut6d6d7UvQ/6bnvL65A
88 | hG89/FmpOuSJgmxSoQgCsgPYuwqvcUpXx6sACJE1Zn4lyrDbi4zRH97cDKVhfjI=
89 | =jha+
90 | -----END PGP SIGNATURE-----
91 | ]]
92 |
93 | contract.state.apitest = {
94 | globalconstant=just23,
95 | globalfn=return23(),
96 | args=resp.args,
97 | today=os.date("%Y-%m-%d", os.time()),
98 | hash=util.sha256("hash"),
99 | kb_github=keybase.github("fiatjaf"),
100 | kb_domain=keybase.domain("fiatjaf.alhur.es"),
101 | sigok=keybase.verify("fiatjaf", "abc", sig),
102 | sigokt=keybase.verify(keybase.twitter("fiatjaf"), "abc", sig),
103 | signotok=keybase.verify("fiatjaf", "xyz", sig),
104 | signotokt=keybase.verify(keybase.twitter("qkwublakjbdaskjdb"), "abc", sig),
105 | userexists=keybase.exists('fiatjaf'),
106 | userdoesntexist=keybase.exists('qwkbqwelikqbeqw'),
107 | cuid=util.cuid()
108 | }
109 | end
110 |
111 | function losemoney ()
112 | -- do nothing, just eat the satoshis sent with the call
113 | end
114 |
115 | function infiniteloop ()
116 | while true do
117 | local x = 'y'
118 | end
119 | end
120 |
121 | function dangerous ()
122 | os.execute("rm file")
123 | os.execute("touch nofile")
124 | contract.state.home = os.getenv("HOME")
125 | end
126 | """,
127 | }
128 |
129 | # prepare contract
130 | r = requests.post(url + "/~/contract", json=ctdata)
131 | assert r.ok
132 | ctid = r.json()["value"]["id"]
133 | bolt11 = r.json()["value"]["invoice"]
134 |
135 | sse = sseclient.SSEClient(
136 | urllib3.PoolManager().request(
137 | "GET", url + "/~~~/contract/" + ctid, preload_content=False
138 | )
139 | ).events()
140 |
141 | # there are still zero contracts in the list
142 | r = requests.get(url + "/~/contracts")
143 | assert r.ok
144 | assert r.json() == {"ok": True, "value": []}
145 |
146 | # get prepared
147 | r = requests.get(url + "/~/contract/" + ctid)
148 | assert r.ok
149 | assert r.json()["value"]["invoice"] == bolt11
150 | assert r.json()["value"]["code"] == ctdata["code"]
151 | assert r.json()["value"]["name"] == ctdata["name"]
152 | assert r.json()["value"]["readme"] == ctdata["readme"]
153 | assert r.json()["value"]["invoice_paid"] == False
154 |
155 | # pay for contract
156 | payment = rpc_b.pay(bolt11)
157 |
158 | # it should get created and we should get a notification
159 | assert next(sse).event == "call-run-event"
160 | ev = next(sse)
161 | assert ev.event == "contract-created"
162 | assert json.loads(ev.data)["id"] == ctid
163 |
164 | # check contract info
165 | r = requests.get(url + "/~/contract/" + ctid)
166 | assert r.ok
167 | assert "invoice" not in r.json()["value"]
168 | assert r.json()["value"]["code"] == ctdata["code"]
169 | assert r.json()["value"]["name"] == ctdata["name"]
170 | assert r.json()["value"]["readme"] == ctdata["readme"]
171 | assert r.json()["value"]["state"]["token_name"] == "richcoin"
172 | assert r.json()["value"]["funds"] == 0
173 |
174 | # contract list should show this single contract
175 | r = requests.get(url + "/~/contracts")
176 | assert r.ok
177 | contracts = r.json()["value"]
178 | assert len(contracts) == 1
179 | assert contracts[0]["name"] == ctdata["name"]
180 | assert contracts[0]["readme"] == ctdata["readme"]
181 | assert contracts[0]["id"] == ctid
182 | assert contracts[0]["funds"] == 0
183 | assert contracts[0]["ncalls"] == 1
184 |
185 | # get contract calls (should contain the initial call)
186 | r = requests.get(url + "/~/contract/" + ctid + "/calls")
187 | assert r.ok
188 | assert len(r.json()["value"]) == 1
189 | call = r.json()["value"][0]
190 | assert call["method"] == "__init__"
191 | assert call["cost"] == payment["msatoshi"]
192 | assert call["payload"] == {}
193 | assert call["msatoshi"] == 0
194 |
195 | # prepare a call and then patch it, but then ignore it
196 | r = requests.post(
197 | url + "/~/contract/" + ctid + "/call",
198 | json={
199 | "method": "buy",
200 | "payload": {"amount": 1, "user": "ttt", "x": "t"},
201 | "msatoshi": 10000,
202 | },
203 | )
204 | assert r.ok
205 | callid = r.json()["value"]["id"]
206 | r = requests.patch(url + "/~/call/" + callid, json={"amount": 2, "user": "uuu"})
207 | assert r.ok
208 | r = requests.get(url + "/~/call/" + callid)
209 | assert r.ok
210 | assert r.json()["value"]["id"] == callid
211 | assert r.json()["value"]["payload"] == {"amount": 2, "user": "uuu", "x": "t"}
212 | assert r.json()["value"]["msatoshi"] == 10000
213 |
214 | # set contract owner in a very insecure way
215 | ## fail because no logged account
216 | r = requests.post(
217 | url + "/~/contract/" + ctid + "/call",
218 | json={"method": "setowner", "payload": {}},
219 | )
220 | rpc_b.pay(r.json()["value"]["invoice"])
221 | assert next(sse).event == "call-run-event"
222 | assert next(sse).event == "call-error"
223 |
224 | ## create a fake session, then succeed
225 | subprocess.run("redis-cli setex auth-session:zxcasdqwe 999 account1", shell=True)
226 | r = requests.post(
227 | url + "/~/contract/" + ctid + "/call?session=zxcasdqwe",
228 | json={"method": "setowner", "payload": {}},
229 | )
230 | rpc_b.pay(r.json()["value"]["invoice"])
231 | assert next(sse).event == "call-run-event"
232 | assert next(sse).event == "call-made"
233 |
234 | ## fail because no logged account
235 | r = requests.post(
236 | url + "/~/contract/" + ctid + "/call",
237 | json={"method": "setowner", "payload": {}},
238 | )
239 | rpc_b.pay(r.json()["value"]["invoice"])
240 | assert next(sse).event == "call-run-event"
241 | assert next(sse).event == "call-error"
242 |
243 | # prepare calls and send them
244 | current_state = {
245 | "balances": {"dummy": 0},
246 | "token_name": "richcoin",
247 | "owner": "account1",
248 | }
249 | current_funds = 0
250 | current_call_n = 2 # __ini__ and setowner
251 | for buyer, amount, msatoshi, succeed in [
252 | ("x", 2, 9000, False),
253 | ("x", 0, 0, True),
254 | ("y", 2, 10000, True),
255 | ]:
256 | r = requests.post(
257 | url + "/~/contract/" + ctid + "/call",
258 | json={
259 | "method": "buy",
260 | "payload": {"amount": amount, "user": buyer},
261 | "msatoshi": msatoshi,
262 | },
263 | )
264 | assert r.ok
265 | callid = r.json()["value"]["id"]
266 | assert 6000 > r.json()["value"]["cost"] > 1000
267 | assert r.json()["value"]["msatoshi"] == msatoshi
268 |
269 | payment = rpc_b.pay(r.json()["value"]["invoice"])
270 | assert (
271 | payment["msatoshi"]
272 | == r.json()["value"]["cost"] + r.json()["value"]["msatoshi"]
273 | )
274 |
275 | assert next(sse).event == "call-run-event"
276 | ev = next(sse)
277 | assert json.loads(ev.data)["id"] == callid
278 |
279 | if succeed:
280 | assert ev.event == "call-made"
281 | bal = current_state["balances"].setdefault(buyer, 0)
282 | current_state["balances"][buyer] = bal + amount
283 | current_funds += msatoshi
284 | current_call_n += 1
285 | else:
286 | assert ev.event == "call-error"
287 |
288 | # check contract state and funds after
289 | r = requests.get(url + "/~/contract/" + ctid)
290 | assert (
291 | r.json()["value"]["state"]
292 | == current_state
293 | == requests.get(url + "/~/contract/" + ctid + "/state").json()["value"]
294 | )
295 | assert (
296 | r.json()["value"]["funds"]
297 | == current_funds
298 | == requests.get(url + "/~/contract/" + ctid + "/funds").json()["value"]
299 | )
300 |
301 | # calls after
302 | r = requests.get(url + "/~/contract/" + ctid + "/calls")
303 | assert r.ok
304 | assert len(r.json()["value"]) == current_call_n
305 |
306 | # try to cash out to our own scammer balance
307 | ## fail because of too big amount
308 | r = requests.post(
309 | url + "/~/contract/" + ctid + "/call",
310 | json={"method": "cashout", "payload": {"amt_to_cashout": current_funds + 1}},
311 | )
312 | rpc_b.pay(r.json()["value"]["invoice"])
313 | assert next(sse).event == "call-run-event"
314 | assert next(sse).event == "call-error"
315 | r = requests.get(url + "/~/contract/" + ctid)
316 | assert r.json()["value"]["funds"] == current_funds
317 |
318 | ## also fail because of too small amount
319 | r = requests.post(
320 | url + "/~/contract/" + ctid + "/call",
321 | json={"method": "cashout", "payload": {"amt_to_cashout": current_funds - 1}},
322 | )
323 | rpc_b.pay(r.json()["value"]["invoice"])
324 | assert next(sse).event == "call-run-event"
325 | assert next(sse).event == "call-error"
326 | r = requests.get(url + "/~/contract/" + ctid)
327 | assert r.json()["value"]["funds"] == current_funds
328 |
329 | ## fail calling a buggy version of the same method that sends to nil
330 | r = requests.post(
331 | url + "/~/contract/" + ctid + "/call",
332 | json={"method": "cashout_wrong", "payload": {"amt_to_cashout": current_funds}},
333 | )
334 | rpc_b.pay(r.json()["value"]["invoice"])
335 | assert next(sse).event == "call-run-event"
336 | assert next(sse).event == "call-error"
337 | r = requests.get(url + "/~/contract/" + ctid)
338 | assert r.json()["value"]["funds"] == current_funds
339 |
340 | ## then succeed
341 | r = requests.post(
342 | url + "/~/contract/" + ctid + "/call",
343 | json={"method": "cashout", "payload": {"amt_to_cashout": current_funds}},
344 | )
345 | rpc_b.pay(r.json()["value"]["invoice"])
346 | assert next(sse).event == "call-run-event"
347 | assert next(sse).event == "call-run-event"
348 | assert next(sse).event == "call-made"
349 | r = requests.get(url + "/~/contract/" + ctid)
350 | assert r.json()["value"]["funds"] == 0
351 |
352 | # calls after
353 | r = requests.get(url + "/~/contract/" + ctid + "/calls")
354 | assert r.ok
355 | assert len(r.json()["value"]) == current_call_n + 1
356 | assert r.json()["value"][0]["method"] == "cashout"
357 |
358 | # before should be the last buy that succeeded
359 | assert r.json()["value"][1]["method"] == "buy"
360 | assert r.json()["value"][1]["msatoshi"] == 10000
361 |
362 | # call a method that will timeout
363 | r = requests.post(
364 | url + "/~/contract/" + ctid + "/call",
365 | json={"method": "infiniteloop", "payload": {}},
366 | )
367 | rpc_b.pay(r.json()["value"]["invoice"])
368 | assert next(sse).event == "call-run-event"
369 | ev = next(sse)
370 | assert ev.event == "call-error"
371 | assert json.loads(ev.data)["kind"] == "runtime"
372 |
373 | # call a method that should break out of the sandbox
374 | Path("file").touch()
375 | r = requests.post(
376 | url + "/~/contract/" + ctid + "/call",
377 | json={"method": "dangerous", "payload": {}},
378 | )
379 | rpc_b.pay(r.json()["value"]["invoice"])
380 | assert next(sse).event == "call-run-event"
381 | ev = next(sse)
382 | assert ev.event == "call-error"
383 | assert json.loads(ev.data)["kind"] == "runtime"
384 | assert (
385 | "home"
386 | not in requests.get(url + "/~/contract/" + ctid + "/state").json()["value"]
387 | )
388 | assert not Path("nofile").exists()
389 | assert Path("file").exists()
390 | Path("file").unlink()
391 | assert not Path("file").exists()
392 |
393 | # call the method that tests all the fancy lua apis
394 | r = requests.post(
395 | url + "/~/contract/" + ctid + "/call", json={"method": "apitest", "payload": {}}
396 | )
397 | rpc_b.pay(r.json()["value"]["invoice"])
398 | assert next(sse).event == "call-run-event"
399 | assert next(sse).event == "call-made"
400 |
401 | data = requests.get(url + "/~/contract/" + ctid + "/state").json()["value"][
402 | "apitest"
403 | ]
404 | assert type(data["cuid"]) == str
405 | assert len(data["cuid"]) > 5
406 | del data["cuid"]
407 |
408 | assert data == {
409 | "globalfn": 23,
410 | "globalconstant": 23,
411 | "args": {"numbers": ["1", "2"], "fruit": "banana"},
412 | "today": datetime.date.today().isoformat(),
413 | "hash": hashlib.sha256(b"hash").hexdigest(),
414 | "kb_github": "fiatjaf",
415 | "kb_domain": "fiatjaf",
416 | "sigok": True,
417 | "sigokt": True,
418 | "signotok": False,
419 | "signotokt": False,
420 | "userexists": True,
421 | "userdoesntexist": False,
422 | }
423 |
424 | # send a lot of money to the contract so we can have incoming capacity in our second node for the next step
425 | r = requests.post(
426 | url + "/~/contract/" + ctid + "/call",
427 | json={"method": "losemoney", "payload": {}, "msatoshi": 444444444},
428 | )
429 | rpc_b.pay(r.json()["value"]["invoice"])
430 | assert next(sse).event == "call-run-event"
431 | assert next(sse).event == "call-made"
432 |
433 | # finally withdraw our scammer balance
434 | r = requests.get(url + "/lnurl/withdraw?session=zxcasdqwe")
435 | assert r.ok
436 | assert r.json()["maxWithdrawable"] == current_funds
437 | bolt11 = rpc_b.invoice(
438 | current_funds, "withdraw-scam", r.json()["defaultDescription"]
439 | )["bolt11"]
440 | r = requests.get(r.json()["callback"] + "?k1=zxcasdqwe&pr=" + bolt11)
441 | assert rpc_b.waitinvoice("withdraw-scam")["label"] == "withdraw-scam"
442 |
--------------------------------------------------------------------------------
/client/Docs.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
36 |
37 |
46 |
47 |
48 | Writing a contract
49 |
50 | A contract consists of a state , some
51 | funds and multiple methods , which can be
52 | called by anyone and may affect the contract state, make GET requests to
53 | other places on the internet and manage the contract funds. What we call
54 | methods are just Lua functions.
55 |
56 |
57 | See the following example of a simple "community faucet" contract code:
58 |
59 |
60 | {faucetcode}
61 |
62 |
63 | The contract above allows generous rich people to put money in (by calling
64 | fundfaucet and including some satoshis in the call) and poor
65 | people to get the money out (by calling getmoney and specifying
66 | how much they want to withdraw in the call payload).
67 |
68 |
69 | However, it is very naïve and could easily be exploited. Although it limits
70 | withdrawals to 100 sat, someone could easily call the
71 | getmoney method multiple times and get all the money to itself.
72 | A less naïve approach would use the global contract.state to
73 | store a list of accounts who have already called getmoney and
74 | disallow them from calling it again. Another approach would be to use
75 | os.time() to keep track of the time of the withdrawals and
76 | force people to wait some time until they can call
77 | getmoney again. The possibilities are endless.
78 |
79 |
80 | Now that you've seen a contract, here are other things you must know about
81 | them:
82 |
83 |
84 |
85 | Each contract must have an __init__ method. That is a special
86 | method that is called only when the contract is created, it must return a
87 | Lua table that will serve as the initial contract state.
88 |
89 |
90 | All other top level functions are methods callable from the external
91 | world, except methods with names beginning in an underscore:
92 | _.
93 |
94 |
95 | Some internal functions called from within a contract may fail and these
96 | may call the call execution to terminate, like
97 | contract.send(). Others, like http calls, can
98 | fail without causing the contract to terminate, instead they return an
99 | error, If you want the call to fail completely you must check for these
100 | errors and call the Lua function error() directly.
101 |
102 |
103 | All calls and payloads will be stored publicly in the contract history,
104 | except for errored calls.
105 |
106 |
107 | For your contracts to be easily integrated into the contract explorer and
108 | method caller interface in this website, make sure to follow these
109 | guidelines:
110 |
111 |
112 | Write top-level methods with 0 indentation, both the
113 | function and the end keywords.
114 |
115 |
116 | When referring to payload fields, use the full object path, like
117 | call.payload.fieldname at least once, in other words,
118 | don't assign call.payload to another variable or you'll
119 | break our naïve regex-based parser.
120 |
121 |
122 | If you intend to use helper functions from inside the main methods,
123 | don't rely on call and contract globals,
124 | instead pass them as arguments.
125 |
126 |
127 | These are just soft requirements and they may be dropped once we get a
128 | better Lua parser on our UI (but that will probably never happen).
129 |
130 |
131 | No one is able to change a contract's code after it has been activated,
132 | not even the contract creator (but contracts can be deleted if you made a
133 | mistake when creating them, provided they're new and don't have any
134 | funds). But of course this is a centralized system and the Etleneum team
135 | may delete contracts if they consider them wrong or harmful in any way.
136 |
137 |
138 | Calling a contract
139 | When you make a call, you send 4 things to the contract:
140 |
141 |
142 | A string method with the name of the contract method
143 | you're calling.
144 |
145 | A JSON payload .
146 |
147 | An integer representing the number of msatoshis to
148 | include in your call. Some methods may require you to include a certain
149 | number of msatoshis so they can be effective. The invoice you're required
150 | to pay to make any call includes this number of msatoshis plus a small
151 | antispam cost.
152 |
153 | Regardless of what the contract code does with them, the msatoshis are
154 | always added to the contract funds.
155 |
156 |
157 | Optionally, a ?session=<String> query string
158 | identifying the current authenticated user,
159 |
160 |
161 | Contract API
162 |
163 | Contract code has access to the following globals:
164 |
165 |
166 |
167 | call table with fields:
168 |
169 | id: String, the call id, mostly useless;
170 |
171 | payload: Any, the payload submitted along with the call;
172 |
173 | msatoshi: Int, the funds included in the call;
174 |
175 |
176 |
177 | contract table with fields:
178 |
179 | id: String, the contract id, mostly useless;
180 |
181 | state: Any, the contract current state, should be mutated
182 | in-place;
183 |
184 |
185 | get_funds: () => Int, a function that returns the
186 | contract's current funds, in msatoshi;
187 |
188 |
189 | send: (target: String, msatoshi: Int) => (), a function
190 | that sends from the contract funds to an user/contract;
191 |
192 |
193 |
194 |
195 | account table with fields:
196 |
197 |
198 | id: String, the account id of the caller,
199 | nil when the call is not authenticated;
200 |
201 |
202 | get_balance: () => Int, a function that returns the
203 | caller's full balance;
204 |
205 |
206 | send: (target: String, msatoshi: Int) => (), a function
207 | that sends from the caller's balance to another user/contract;
208 |
209 |
210 |
211 |
212 | etleneum table with fields:
213 |
214 |
215 | get_contract: (id: String) => (state: Any, funds: Int), a
216 | function that returns data about another contract.
217 |
218 |
219 | call_external: (contract_id: String, method: String, payload: Any,
221 | msatoshi: Int, params: {`{as: String?}`}) => (), a function that calls a method on another contract -- each external
223 | call costs one satoshi and it must be manually included in the current
224 | call, the as parameter could be either "contract" to make
225 | the call as the contract or "user" to make the call as the current
226 | user, or nil;
227 |
228 |
229 |
230 |
231 | util table with functions:
232 |
233 |
234 | print: (...Any) => (), shows a notification to the caller
235 | if he is listening to the server stream when making the call;
236 |
237 |
238 | sha256: (any: String) => String, takes any string and
239 | returns it's SHA256, hex-encoded.
240 |
241 | cuid: () => String, generates a random id;
242 |
243 | parse_bolt11: (pr: String) => (inv: {payee: String, expiry:
245 | Int, routes: Array, currency: String, msatoshi: Int, created_at:
246 | Int, description: String, payment_hash: String,
247 | min_final_cltv_expiry: Int}, error), parses a bolt11 invoice (and checks signatures, as this has not
249 | many more uses besides checking signatures);
250 |
251 |
252 |
253 |
254 | http table with functions:
255 |
256 |
257 | gettext: (url: String, headers: Map) => (body: String, error), calls an URL and returns the body response as text;
260 |
261 |
262 | getjson: (url: String, headers: Map) => (body: Any, error), calls an URL and returns the body response JSON as a table;
264 |
265 |
266 | postjson: (url: String, data: Any, headers: Map) => (body: Any,
268 | error), calls an URL and returns the body response JSON as a table;
270 |
271 |
272 |
273 |
274 | Then there are the following modules and functions from Lua's standard
275 | library, all pre-imported and available:
276 |
277 | pairs;
278 | ipairs;
279 | next;
280 | error;
281 | tonumber;
282 | tostring;
283 | type;
284 | unpack;
285 | string with most its functions;
286 | table with most its functions;
287 | math with most its functions;
288 |
289 | os with time, clock,
290 | difftime and date functions;
291 |
292 |
293 |
294 |
295 | JSON API
296 |
297 | Anything you can do on this website you can also do through Etleneum's
298 | public JSON API.
299 |
300 | Types
301 |
302 |
303 | Contract:
304 | {id: String, code: String, name: String, readme: String, funds:
306 | Int}
308 |
309 |
310 | Call:
311 | {id: String, time: String, method: String, payload: Any, matoshi:
313 | Int, cost: Int, diff: String, transfers: []Transfer, ran:
314 | Bool}
316 |
317 |
318 | Transfer:
319 | {direction: "in" | "out", matoshi: Int, counterparty:
321 | String}
323 |
324 |
325 | AccountHistoryEntry:
326 | {msatoshi: Int, time: String, counterparty: String}
327 |
328 |
329 | Endpoints
330 |
331 | All paths start at https://etleneum.com and must be called with
332 | Content-Type: application/json. All methods are
333 | CORS -enabled and no authorization mechanism is required or supported.
335 | All calls return an object of type
336 | {ok: Bool, error: String, value: Any}. The relevant
337 | data is always in the value key and error is only
338 | present when the call has failed. In the following endpoint descriptions we
339 | omit the ok/value envelope and show just what should be inside
340 | value.
341 |
342 |
343 |
344 | GET /~/contracts lists all the contracts, sorted
345 | by the most recent activity, returns Contract;
346 |
347 |
348 | POST /~/contract prepares a new contract, takes
349 | {name: String, code: String, readme: String},
350 | returns {id: String, invoice: String}, when the
351 | invoice is paid the __init__ call is executed and the
352 | contract is created;
353 |
354 |
355 | GET /~/contract/<id> returns the full
356 | contract info, Contract;
357 |
358 |
359 | GET /~/contract/<id>/state returns just
360 | the contract state, Any;
361 |
362 |
363 | POST
364 | /~/contract/<id>/state with <jq_filter> as the
365 | body, or
366 | GET
367 | /~/contract/<id>/state/<jq_filter>
368 | returns the contract state after a
369 | jq filter has been
370 | applied to it, Any;
371 |
372 |
373 | GET /~/contract/<id>/funds returns just
374 | the contract funds, in msat, Int;
375 |
376 |
377 | GET /~/contract/<id>/calls lists all
378 | contract calls, sorted by most recent first, returns Call;
379 |
380 |
381 | SSE /~~~/contract/<id> returns a
382 | text/event-stream that emits the following events:
383 |
384 | contract-created: {id: String};
385 |
386 | contract-error: {id: String, kind: "internal" | "runtime",
388 | message: String};
390 |
391 |
392 | call-run-event: {id: String, contract_id: String, kind:
394 | "start" | "print" | "run" String, message: String, method:
395 | String};
397 |
398 |
399 | call-made: {id: String, contract_id: String, method:
401 | String};
403 |
404 |
405 | call-error: {id: String, contract_id: String, kind: "internal"
407 | | "runtime", message: String, method: String};
409 |
410 |
411 |
412 |
413 | POST /~/contract/<id>/call prepares a new
414 | call, takes
415 | {method: String, payload: Any, msatoshi: Int},
416 | returns {id: String, invoice: String}, when the
417 | invoice is paid the call is executed;
418 |
419 |
420 | GET /~/call/<id> returns the full call
421 | info, Call;
422 |
423 |
424 | PATCH /~/call/<id> takes anything passed
425 | in the JSON body and patches it to the current prepared call
426 | payload , returns the full call info, Call;
427 |
428 |
429 | GET /lnurl/auth performs
430 | lnurl-auth
434 | and creates a session;
435 |
436 |
437 | SSE /~~~/session[?session=...] returns a
438 | text/event-stream that emits the following events:
439 |
440 |
441 | lnurls: {auth: String, withdraw: String};
442 |
443 |
444 | auth: {account: String, balance: Int, [secret:
446 | String]};
448 |
449 | history: {[]AccountHistoryEntry};
450 |
451 | withdraw: {amount: Int, new_balance: Int};
452 |
453 |
454 |
455 |
456 |
457 |
--------------------------------------------------------------------------------