Browse Source

working prototype and updated README

master
nitowa 2 years ago
parent
commit
60ce8d4839

+ 48
- 6
README.md View File

@@ -1,6 +1,6 @@
1 1
 # Overview 
2 2
 
3
-httXrp is a proof of concept for a truly serverless web architecture. If serverless simply means "a server owned by someone else", httXrp pushes that definition to its limit.
3
+httXrp is a proof of concept for a truly serverless web architecture. If serverless simply means "a server owned by someone else", httXrp pushes that definition to its limit -or- perhaps its logical conclusion: What if that "someone else" never even intended that server to be used that way but can't do anything about it?
4 4
 
5 5
 # How it works
6 6
 
@@ -8,11 +8,11 @@ httXrp is a proof of concept for a truly serverless web architecture. If serverl
8 8
 
9 9
 Transactions on the ripple blockchain are allowed to carry up to 1kB of arbitrary data via the memo field. 
10 10
 We can use this to store data of any size by building a tree of references between these transactions that can then be reassembled by reading them back from the blockchain.
11
-In order to generate these transactions a library called [xrpio](https://gitea.nitowa.xyz/npm-packages/xrpio.git) is used.
11
+In order to generate these transactions a library called [xrpio](https://gitea.nitowa.xyz/npm-packages/xrpio.git) is used to send minimum-denomination transactions between two user controlled wallets.
12 12
 
13 13
 Highly simplified, you can visualize the process like this:
14 14
 
15
-![xrpio treewrite](https://i.imgur.com/G2HofSE.gif)
15
+<img src="https://i.imgur.com/G2HofSE.gif" alt="xrpio" width="650"/>
16 16
 
17 17
 ## 2: Abstracting the webserver away from the web
18 18
 
@@ -20,11 +20,53 @@ Using tools like `webpack`, it is possible to condense even modern complex singl
20 20
 
21 21
 Since such a condensed HTML file is effectively nothing more than a long string it is possible to use `xrpio` to store them into the ripple blockchain and to retrieve them via a single identifying hash.
22 22
 
23
-![Webserverless web](https://i.imgur.com/Y0TgzVi.gif)
23
+<img src="https://i.imgur.com/Rwo37xJ.gif" alt="serverless web" width="650"/>
24 24
 
25
-## 3: Dynamic web applications without a backend
25
+## 3: Backendless dynamic web applications: Databases without databases
26 26
 
27
-Superficially, this technique is limited to serving static webpages, as there is no *real* backend serving these pages. However, since it is possible to embed `xrpio` into such a "static" page, it is possible to listen for transactions on the Ripple blockchain containing valid xrpio hashes and to dynamically update the webpage's content based on the stored data.  
27
+Superficially, this technique is limited to serving static webpages, as there can be no backend communicating with these pages without betraying the serverless premise. However, since it is possible to embed `xrpio` into such a "static" page, it is possible to listen for transactions on the blockchain containing valid xrpio hashes and to dynamically update the webpage's content based on the stored data. 
28
+
29
+All necessary mechanisms can easily be embedded within that webpage, which allows us to build complex webapplications without any need for a backend server. 
30
+
31
+To prove the feasibility of this approach, this project contains a small example application in the form of a shoutbox:
32
+
33
+<img src="https://i.imgur.com/5gYLuYc.png" alt="shoutbox" width="450"/>
34
+
35
+The exact procedure is more easily explained in code than visually. The presented code snippets should be considered pseudocode, but if you're interested in the exact steps please take a look into `src/frontend/src/app/services/ShoutboxData.service.ts`. The actual implementation isn't any more complex than the steps below but they were altered for readability reasons.
36
+
37
+### Submitting a new shout to the shoutbox
38
+```js
39
+//When submitting a new shout, first the user creates a xrpio write between two of their own wallets
40
+submitShout = async (shout: any) => {
41
+    const shoutHash = await xrpio.treeWrite(shout, userWallet1.address, userWallet2.secret)
42
+    return await submit(shoutHash)
43
+}
44
+
45
+//After the shout has been written to the blockchain, the hash pointing to the data is sent to the address keeping track of the application's state
46
+submit = async (shoutHash: string) => {
47
+    return await xrpio.writeRaw({ data: shoutHash }, shoutboxAddress, userWallet1.secret)
48
+}
49
+```
50
+
51
+### Loading the application state and live updating it
52
+```js
53
+//Loading old data is as easy as parsing the historical transactions of the shoutboxAddress
54
+loadHistory = async () => {
55
+    const raw_txs = await getTransactions(shoutboxAddress)
56
+    //Extracts hashes from memos and reads them with xrpio
57
+    const shouts = await parseMemos(raw_txs.map(getMemo)) 
58
+    history = shouts
59
+}
60
+
61
+//Fetching new data as it comes in is also possible by simply subscribing to new transactions for the shoutboxAddress
62
+listen = async () => {
63
+    await subscribeTxs(async (raw_tx: any) => {
64
+        //Extracts hashes from memos and reads them with xrpio
65
+        const shout = await parseMemos(getMemo(raw_tx))
66
+        history.push(shout)
67
+    })
68
+}
69
+```
28 70
 
29 71
 # Credits
30 72
 

+ 54
- 23
src/frontend/src/app/app.component.html View File

@@ -1,26 +1,57 @@
1 1
 <div class="main-container">
2
-<header class="header-2">
3
-  <div class="branding">
4
-    <a class="nav-link">
5
-      <cds-icon shape="home" size="lg"></cds-icon>
6
-      <span class="title">Clarity</span>
7
-    </a>
8
-  </div>
9
-  <div class="header-nav">
10
-    <a class="active nav-link nav-text">Home</a>
11
-  </div>
12
-</header>
13
-<div class="content-container">
14
-  <div class="content-area">
15
-    <h3>Clarity Starter Instructions:</h3>
2
+  <header class="header-2">
3
+    <div class="branding">
4
+      <a class="nav-link">
5
+        <cds-icon shape="home" size="lg"></cds-icon>
6
+        <span class="title">httXrp</span>
7
+      </a>
8
+    </div>
9
+    <div class="header-nav">
10
+      <a class="active nav-link nav-text">Shoutbox</a>
11
+    </div>
12
+  </header>
13
+  <div class="content-container">
14
+    <div class="content-area">
15
+      <div class="clr-row">
16
+        <div class="clr-col-lg-5 clr-col-md-8 clr-col-12">
17
+          <div class="card">
18
+            <h3 class="card-header">Shout Something</h3>
19
+            <form clrForm #loginForm="ngForm">
20
+
21
+              <div class="card-block">
22
+                <clr-input-container>
23
+                  <input clrInput [disabled]="sending"  required placeholder="Title" type="text" [(ngModel)]="newShout.title" name="title" />
24
+                </clr-input-container>
25
+                <textarea clrTextarea [disabled]="sending" required placeholder="Shout Body" type="text" [(ngModel)]="newShout.body"
26
+                  name="body"></textarea>
27
+                <clr-input-container>
28
+                  <input clrInput [disabled]="sending" required placeholder="Your Name" type="text" [(ngModel)]="newShout.from" name="from" />
29
+                </clr-input-container>
30
+              </div>
31
+              <div class="card-footer">
32
+                <button class="btn btn-success" (click)="submitShout()"
33
+                  [disabled]="!loginForm.form.valid || sending">Shout</button> <span *ngIf="sending">Sending...</span>
34
+              </div>
35
+            </form>
36
+          </div>
37
+        </div>
38
+      </div>
16 39
 
17
-    <ul>
18
-      <li>Start by clicking Fork in the toolbar above.</li>
19
-      <li>Implement the problem in the new editor.</li>
20
-      <li>
21
-        Save the result, and share the url in a GitHub or StackOverflow issue.
22
-      </li>
23
-    </ul>
40
+      <div class="clr-row" *ngFor="let shout of shouts">
41
+        <div class="clr-col-lg-5 clr-col-md-8 clr-col-12">
42
+          <div class="card">
43
+            <h3 class="card-header">{{shout.title}}</h3>
44
+            <div class="card-block">
45
+              <div class="card-text">
46
+                {{shout.body}}
47
+              </div>
48
+            </div>
49
+            <div class="card-footer">
50
+              By: {{shout.from}}
51
+            </div>
52
+          </div>
53
+        </div>
54
+      </div>
55
+    </div>
24 56
   </div>
25
-</div>
26
-</div>
57
+</div>

+ 34
- 3
src/frontend/src/app/app.component.ts View File

@@ -1,10 +1,41 @@
1
-import { Component } from '@angular/core';
1
+import { Component, OnInit } from '@angular/core';
2
+import { ShoutboxDataService } from './services/ShoutboxData.service';
2 3
 
3 4
 @Component({
4 5
   selector: 'app-root',
5 6
   templateUrl: './app.component.html',
6 7
   styleUrls: ['./app.component.scss'],
7 8
 })
8
-export class AppComponent {
9
-  title = 'angular-cli';
9
+export class AppComponent implements OnInit{
10
+  title = 'httXrp';
11
+  shouts: any[] = []
12
+  sending = false
13
+  newShout = {
14
+    title: "",
15
+    body: "",
16
+    from: ""
17
+  }
18
+
19
+  constructor(
20
+    private dataService: ShoutboxDataService
21
+  ){}
22
+
23
+  submitShout = () => {
24
+    this.sending = true
25
+    this.dataService.submitShout(this.newShout)
26
+    .then(() => {
27
+      this.newShout = {
28
+        title: "",
29
+        body: "",
30
+        from: ""
31
+      }
32
+    })
33
+    .finally(() => {
34
+      this.sending = false
35
+    })
36
+  }
37
+
38
+  ngOnInit(){
39
+    this.shouts = this.dataService.history
40
+  }
10 41
 }

+ 19
- 3
src/frontend/src/app/app.module.ts View File

@@ -1,4 +1,4 @@
1
-import { NgModule } from '@angular/core';
1
+import { APP_INITIALIZER, NgModule } from '@angular/core';
2 2
 import { BrowserModule } from '@angular/platform-browser';
3 3
 
4 4
 import { AppRoutingModule } from './app-routing.module';
@@ -7,11 +7,27 @@ import { CdsModule } from '@cds/angular';
7 7
 import { ClarityModule } from '@clr/angular';
8 8
 
9 9
 import { ClarityIcons, homeIcon } from '@cds/core/icon';
10
+import { ShoutboxDataService, initShoutboxSvc } from './services/ShoutboxData.service';
11
+import { FormsModule } from '@angular/forms';
10 12
 
11 13
 @NgModule({
12 14
   declarations: [AppComponent],
13
-  imports: [BrowserModule, AppRoutingModule, ClarityModule, CdsModule],
14
-  providers: [],
15
+  imports: [
16
+    BrowserModule, 
17
+    AppRoutingModule, 
18
+    ClarityModule, 
19
+    CdsModule,
20
+    FormsModule
21
+  ],
22
+  providers: [
23
+    ShoutboxDataService,
24
+    {
25
+      provide: APP_INITIALIZER,
26
+      useFactory: initShoutboxSvc,
27
+      deps: [ShoutboxDataService],
28
+      multi: true
29
+    }
30
+  ],
15 31
   bootstrap: [AppComponent],
16 32
 })
17 33
 export class AppModule {

+ 104
- 0
src/frontend/src/app/services/ShoutboxData.service.ts View File

@@ -0,0 +1,104 @@
1
+import { Injectable } from "@angular/core";
2
+import { DataParser } from "../util/Dataparser";
3
+import { makeTestnetWallet } from "../util/TestnetUtils";
4
+
5
+declare const xrpIO: any
6
+declare const xrpl: any
7
+
8
+const xrpNode = "wss://s.altnet.rippletest.net:51233"
9
+
10
+@Injectable()
11
+export class ShoutboxDataService {
12
+
13
+    public history: any[] = []
14
+
15
+    private userWallet1: any
16
+    private userWallet2: any
17
+    private shoutboxAddress = "rBnbBMZrbWEVHsyi1EWxv3gidzbreJzbgC"
18
+    private rippleApi: any;
19
+    private xrpio: any;
20
+
21
+    private getTransactions = async () => {
22
+        const resp = await this.rippleApi.request({
23
+            command: "account_tx",
24
+            account: this.shoutboxAddress,
25
+            forward: false,
26
+        })
27
+        return resp.result.transactions.map((entry: any) => entry.tx)
28
+    }
29
+
30
+    private submit = async (shoutHash: string) => {
31
+        return await this.xrpio.writeRaw({ data: shoutHash }, this.shoutboxAddress, this.userWallet1.secret)
32
+    }
33
+
34
+    public submitShout = async (shout: any) => {
35
+        const shoutHash = await this.xrpio.treeWrite(JSON.stringify(shout), this.userWallet1.address, this.userWallet2.secret)
36
+        return await this.submit(shoutHash)
37
+    }
38
+
39
+    private parseMemos = async (memos: any) => {
40
+        const shouts = await Promise.all(memos
41
+            .map((memo: any) => {
42
+                if (!memo.Memo || !memo.Memo.MemoData)
43
+                    return
44
+
45
+                try {
46
+                    return DataParser.parse('TxHash', hex_to_ascii(memo.Memo.MemoData))
47
+                } catch (e) {
48
+                    return
49
+                }
50
+            })
51
+            .filter((hash: string) => hash != undefined)
52
+            .map((root_hash: string) => this.xrpio.treeRead([root_hash]))
53
+        )
54
+        return shouts.map((jsonStr: string) => JSON.parse(jsonStr))
55
+    }
56
+
57
+    private loadHistory = async () => {
58
+        const raw_txs = await this.getTransactions()
59
+        return await this.parseMemos(raw_txs.flatMap((htx: any) => htx.Memos))
60
+    }
61
+
62
+    private subscribeTxs = async (callback: Function) => {
63
+        this.rippleApi.on('transaction', (tx: any) => callback(tx))
64
+        await this.rippleApi.connection.request({
65
+            command: 'subscribe',
66
+            accounts: [this.shoutboxAddress]
67
+        })
68
+    }
69
+
70
+    private listen = async () => {
71
+        await this.subscribeTxs(async (raw_tx: any) => {
72
+            const shouts = await this.parseMemos(raw_tx.transaction.Memos)
73
+            this.history.unshift(...shouts)
74
+        })
75
+    }
76
+
77
+    initialize = async () => {
78
+        this.userWallet1 = await makeTestnetWallet()
79
+        this.userWallet2 = await makeTestnetWallet()
80
+
81
+        this.rippleApi = new xrpl.Client(xrpNode)
82
+        await this.rippleApi.connect()
83
+
84
+        this.xrpio = new xrpIO(xrpNode);
85
+        await this.xrpio.connect()
86
+
87
+        this.history = await this.loadHistory()
88
+
89
+        await this.listen();
90
+    }
91
+}
92
+
93
+export function initShoutboxSvc(svc: ShoutboxDataService): () => Promise<any> {
94
+    return svc.initialize;
95
+}
96
+
97
+function hex_to_ascii(input: any) {
98
+    var hex = input.toString();
99
+    var str = '';
100
+    for (var n = 0; n < hex.length; n += 2) {
101
+        str += String.fromCharCode(parseInt(hex.substr(n, 2), 16));
102
+    }
103
+    return str;
104
+}

+ 73
- 0
src/frontend/src/app/util/Dataparser.ts View File

@@ -0,0 +1,73 @@
1
+import { AMOUNT_DECIMALS, AMOUNT_FORMAT, NON_ZERO_TX_HASH } from "./protocol.constants"
2
+import { Amount, ArgumentType, TxHash } from "./types"
3
+
4
+export class DataParser {
5
+    public static parse(type: ArgumentType | string[], value: any){
6
+        if(typeof type === 'object' && type[0] != null){ //enum value type
7
+            if(! (type as string[]).includes(value)){
8
+                throw new Error(`Invalid enum value type: ${value} is not included in ${type}`)
9
+            }
10
+            return value
11
+        }else if(typeof type === 'string'){ //known defined type
12
+            if(!this.typeMap[type]){
13
+                throw new Error('Unknown parameter type'+type)
14
+            }
15
+            return this.typeMap[type](value)
16
+        }else{
17
+            throw new Error('FRAMEWORK_ERR: Datachecker.check(..): unknown argument type '+String(type))
18
+        }
19
+    }
20
+
21
+    private static typeMap : {[key in ArgumentType] : Function} = {
22
+        Amount: DataParser.parseAmount,
23
+        Boolean: DataParser.parseBoolean,
24
+        String: DataParser.parseString,
25
+        TxHash: DataParser.parseTxHash,
26
+        any: DataParser.parseAny
27
+    }
28
+
29
+
30
+    private static parseAmount(input: any): Amount{
31
+        if(typeof input === 'string' && typeof input !== 'number')
32
+            throw new Error('Input is not a number')
33
+
34
+        if(typeof input === 'string')
35
+            input = Number.parseFloat(input)
36
+
37
+        if(! AMOUNT_FORMAT.test(''+input)){
38
+            throw new Error('Input did not match the specification for `Amount`. The maximum number of decimals is '+AMOUNT_DECIMALS)
39
+        }
40
+
41
+        return input
42
+    }
43
+
44
+
45
+    private static parseString(input: any): string{
46
+        if(typeof input !== 'string')
47
+            throw new Error('Input is not a string')
48
+        return input
49
+    }
50
+
51
+    private static parseBoolean(input: any): boolean{
52
+        switch (typeof input) {
53
+            case 'boolean': return input;
54
+            case 'string': {
55
+                if(input === 'true') return true;
56
+                if(input === 'false') return false;
57
+                break;
58
+            }
59
+        }
60
+        throw new Error('Input is not a boolean')
61
+    }
62
+    
63
+    private static parseAny(input: any): any{
64
+        return input
65
+    }
66
+
67
+    private static parseTxHash(input: any): TxHash{
68
+        if(typeof input !== 'string' || !NON_ZERO_TX_HASH.test(input)){
69
+            throw new Error('Input is not a trasnaction hash')
70
+        }
71
+        return input
72
+    }
73
+}

+ 14
- 0
src/frontend/src/app/util/TestnetUtils.ts View File

@@ -0,0 +1,14 @@
1
+export const makeTestnetWallet = () : Promise<{ secret: string, address: string }> => fetch('https://faucet.altnet.rippletest.net/accounts', {
2
+    method: 'POST',
3
+    headers: {
4
+        'Accept': 'application/json',
5
+        'Content-Type': 'application/json'
6
+    },
7
+}).then((raw:any) => {
8
+    return raw.json().then((content:any) => {
9
+        return({
10
+            secret: content.account.secret,
11
+            address: content.account.address
12
+        });
13
+    })
14
+});

+ 14
- 0
src/frontend/src/app/util/protocol.constants.ts View File

@@ -0,0 +1,14 @@
1
+export const MSG_DELIM: string = ' '
2
+export const MSG_DATA_MAX: number = 925
3
+export const PUBKEY_LEN: number = 66
4
+export const NON_ZERO_TX_HASH = new RegExp(`[0-9A-F]{64}`)
5
+export const PTR_FORMAT = new RegExp(`^((${NON_ZERO_TX_HASH.source})|0)`)
6
+export const DATA_FORMAT = new RegExp(`(.{1,${MSG_DATA_MAX}})`)
7
+export const SIGNATURE_FORMAT = new RegExp(`(\\S{140}|\\S{142})$`)
8
+export const SIGNER_FORMAT = new RegExp(`(\\S{${PUBKEY_LEN}})`)
9
+export const MSG_FORMAT = new RegExp(`${PTR_FORMAT.source}${MSG_DELIM}${DATA_FORMAT.source}`, 'm')
10
+export const AMOUNT_DECIMALS = 18
11
+export const MAX_SUPPLY = 20_000_000
12
+export const AMOUNT_FORMAT = new RegExp(`\d+(\.\d{1,${AMOUNT_DECIMALS}})?`)
13
+export const MIN_XRP_FEE = "0.00001"
14
+export const MIN_XRP_TX_VALUE = "0.000001"

+ 56
- 0
src/frontend/src/app/util/types.ts View File

@@ -0,0 +1,56 @@
1
+export type Memo = {
2
+    type?: string
3
+    format?: string
4
+    data?: string
5
+}
6
+
7
+export type Signature = {signature: string, signer: PublicKey}
8
+
9
+export type Environment = {
10
+    msg: {
11
+        sender: Address,
12
+        value: number,
13
+        data: XrpTransaction,
14
+        rawTx: string
15
+    }
16
+}
17
+
18
+export type Transition<Model> = {
19
+    call: keyof Model,
20
+    params: any[]
21
+}
22
+
23
+export type ArgumentType = 'String' | 'Amount' | 'TxHash' | 'Boolean' | 'any'
24
+export type ReturnType = ArgumentType | 'Void'
25
+export type MutableStateOperation = 'ARRAY_PUSH' | 'ARRAY_UNSHIFT' | 'ARRAY_SHIFT' | 'ARRAY_POP' | 'VALUE_SET' | 'VALUE_DELETE'
26
+export const MutableStateOperationStrings = ['ARRAY_PUSH', 'ARRAY_UNSHIFT', 'ARRAY_SHIFT', 'ARRAY_POP', 'VALUE_SET', 'VALUE_DELETE']
27
+export type ArgumentDefiniton = {
28
+    type: ArgumentType | string[],
29
+    name: string
30
+}
31
+export type ContractFunctionSignature<Of = any> = {
32
+    name: keyof Of
33
+    argTypes: ArgumentDefiniton[]
34
+    returnType: ReturnType
35
+    documentation?: string,
36
+    modifier?: Modifier[]
37
+}
38
+
39
+export type Modifier = "OWNER_ONLY" | "PAYABLE"
40
+export type TransferListener = (transfer : { _from: Address, _to: Address, _value: Amount}) => void 
41
+export type ApprovalListener = (approval : { _from: Address, _spender: Address, _value: Amount}) => void 
42
+
43
+export type Address = string
44
+export type Secret = string
45
+export type PublicKey = string
46
+export type Amount = number
47
+export type TxHash = string
48
+
49
+export type XrpTransaction = {
50
+    hash: TxHash,
51
+    sender: Address,
52
+    receiver: Address,
53
+    value: Amount,
54
+    fee: Amount,
55
+    ledger_index: number
56
+}

+ 3
- 1
src/frontend/src/index.html View File

@@ -2,10 +2,12 @@
2 2
 <html lang="en">
3 3
   <head>
4 4
     <meta charset="utf-8" />
5
-    <title>AngularCli</title>
5
+    <title>httXrp</title>
6 6
     <base href="" />
7 7
     <meta name="viewport" content="width=device-width, initial-scale=1" />
8 8
     <link rel="icon" type="image/x-icon" href="favicon.ico" />
9
+    <script src="https://cdn.jsdelivr.net/npm/xrpl@2.1.1"></script>
10
+    <script src="https://cdn.jsdelivr.net/npm/xrpio@0.1.7/lib/browser/xrpio.browser.js"></script>
9 11
   </head>
10 12
 
11 13
   <body cds-text="body">

Loading…
Cancel
Save