From 60ce8d483957f274ee6118155a194530bd8495f9 Mon Sep 17 00:00:00 2001 From: nitowa Date: Wed, 23 Nov 2022 10:47:34 +0100 Subject: [PATCH] working prototype and updated README --- README.md | 54 ++++++++- src/frontend/src/app/app.component.html | 77 +++++++++---- src/frontend/src/app/app.component.ts | 37 ++++++- src/frontend/src/app/app.module.ts | 22 +++- .../src/app/services/ShoutboxData.service.ts | 104 ++++++++++++++++++ src/frontend/src/app/util/Dataparser.ts | 73 ++++++++++++ src/frontend/src/app/util/TestnetUtils.ts | 14 +++ .../src/app/util/protocol.constants.ts | 14 +++ src/frontend/src/app/util/types.ts | 56 ++++++++++ src/frontend/src/index.html | 4 +- 10 files changed, 419 insertions(+), 36 deletions(-) create mode 100644 src/frontend/src/app/services/ShoutboxData.service.ts create mode 100644 src/frontend/src/app/util/Dataparser.ts create mode 100644 src/frontend/src/app/util/TestnetUtils.ts create mode 100644 src/frontend/src/app/util/protocol.constants.ts create mode 100644 src/frontend/src/app/util/types.ts diff --git a/README.md b/README.md index ffd9df6..d994bd2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Overview -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. +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? # How it works @@ -8,11 +8,11 @@ httXrp is a proof of concept for a truly serverless web architecture. If serverl Transactions on the ripple blockchain are allowed to carry up to 1kB of arbitrary data via the memo field. 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. -In order to generate these transactions a library called [xrpio](https://gitea.nitowa.xyz/npm-packages/xrpio.git) is used. +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. Highly simplified, you can visualize the process like this: -![xrpio treewrite](https://i.imgur.com/G2HofSE.gif) +xrpio ## 2: Abstracting the webserver away from the web @@ -20,11 +20,53 @@ Using tools like `webpack`, it is possible to condense even modern complex singl 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. -![Webserverless web](https://i.imgur.com/Y0TgzVi.gif) +serverless web -## 3: Dynamic web applications without a backend +## 3: Backendless dynamic web applications: Databases without databases -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. +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. + +All necessary mechanisms can easily be embedded within that webpage, which allows us to build complex webapplications without any need for a backend server. + +To prove the feasibility of this approach, this project contains a small example application in the form of a shoutbox: + +shoutbox + +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. + +### Submitting a new shout to the shoutbox +```js +//When submitting a new shout, first the user creates a xrpio write between two of their own wallets +submitShout = async (shout: any) => { + const shoutHash = await xrpio.treeWrite(shout, userWallet1.address, userWallet2.secret) + return await submit(shoutHash) +} + +//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 +submit = async (shoutHash: string) => { + return await xrpio.writeRaw({ data: shoutHash }, shoutboxAddress, userWallet1.secret) +} +``` + +### Loading the application state and live updating it +```js +//Loading old data is as easy as parsing the historical transactions of the shoutboxAddress +loadHistory = async () => { + const raw_txs = await getTransactions(shoutboxAddress) + //Extracts hashes from memos and reads them with xrpio + const shouts = await parseMemos(raw_txs.map(getMemo)) + history = shouts +} + +//Fetching new data as it comes in is also possible by simply subscribing to new transactions for the shoutboxAddress +listen = async () => { + await subscribeTxs(async (raw_tx: any) => { + //Extracts hashes from memos and reads them with xrpio + const shout = await parseMemos(getMemo(raw_tx)) + history.push(shout) + }) +} +``` # Credits diff --git a/src/frontend/src/app/app.component.html b/src/frontend/src/app/app.component.html index 5650d8c..4f2d4c8 100644 --- a/src/frontend/src/app/app.component.html +++ b/src/frontend/src/app/app.component.html @@ -1,26 +1,57 @@
-
- -
- Home -
-
-
-
-

Clarity Starter Instructions:

+
+ +
+ Shoutbox +
+
+
+
+
+
+
+

Shout Something

+
-
    -
  • Start by clicking Fork in the toolbar above.
  • -
  • Implement the problem in the new editor.
  • -
  • - Save the result, and share the url in a GitHub or StackOverflow issue. -
  • -
+
+ + + + + + + +
+ +
+
+
+
+ +
+
+
+

{{shout.title}}

+
+
+ {{shout.body}} +
+
+ +
+
+
+
-
-
+
\ No newline at end of file diff --git a/src/frontend/src/app/app.component.ts b/src/frontend/src/app/app.component.ts index 42f6d0f..6a508cb 100644 --- a/src/frontend/src/app/app.component.ts +++ b/src/frontend/src/app/app.component.ts @@ -1,10 +1,41 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { ShoutboxDataService } from './services/ShoutboxData.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], }) -export class AppComponent { - title = 'angular-cli'; +export class AppComponent implements OnInit{ + title = 'httXrp'; + shouts: any[] = [] + sending = false + newShout = { + title: "", + body: "", + from: "" + } + + constructor( + private dataService: ShoutboxDataService + ){} + + submitShout = () => { + this.sending = true + this.dataService.submitShout(this.newShout) + .then(() => { + this.newShout = { + title: "", + body: "", + from: "" + } + }) + .finally(() => { + this.sending = false + }) + } + + ngOnInit(){ + this.shouts = this.dataService.history + } } diff --git a/src/frontend/src/app/app.module.ts b/src/frontend/src/app/app.module.ts index ea2c5eb..223fd6f 100644 --- a/src/frontend/src/app/app.module.ts +++ b/src/frontend/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppRoutingModule } from './app-routing.module'; @@ -7,11 +7,27 @@ import { CdsModule } from '@cds/angular'; import { ClarityModule } from '@clr/angular'; import { ClarityIcons, homeIcon } from '@cds/core/icon'; +import { ShoutboxDataService, initShoutboxSvc } from './services/ShoutboxData.service'; +import { FormsModule } from '@angular/forms'; @NgModule({ declarations: [AppComponent], - imports: [BrowserModule, AppRoutingModule, ClarityModule, CdsModule], - providers: [], + imports: [ + BrowserModule, + AppRoutingModule, + ClarityModule, + CdsModule, + FormsModule + ], + providers: [ + ShoutboxDataService, + { + provide: APP_INITIALIZER, + useFactory: initShoutboxSvc, + deps: [ShoutboxDataService], + multi: true + } + ], bootstrap: [AppComponent], }) export class AppModule { diff --git a/src/frontend/src/app/services/ShoutboxData.service.ts b/src/frontend/src/app/services/ShoutboxData.service.ts new file mode 100644 index 0000000..5ed3ec3 --- /dev/null +++ b/src/frontend/src/app/services/ShoutboxData.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from "@angular/core"; +import { DataParser } from "../util/Dataparser"; +import { makeTestnetWallet } from "../util/TestnetUtils"; + +declare const xrpIO: any +declare const xrpl: any + +const xrpNode = "wss://s.altnet.rippletest.net:51233" + +@Injectable() +export class ShoutboxDataService { + + public history: any[] = [] + + private userWallet1: any + private userWallet2: any + private shoutboxAddress = "rBnbBMZrbWEVHsyi1EWxv3gidzbreJzbgC" + private rippleApi: any; + private xrpio: any; + + private getTransactions = async () => { + const resp = await this.rippleApi.request({ + command: "account_tx", + account: this.shoutboxAddress, + forward: false, + }) + return resp.result.transactions.map((entry: any) => entry.tx) + } + + private submit = async (shoutHash: string) => { + return await this.xrpio.writeRaw({ data: shoutHash }, this.shoutboxAddress, this.userWallet1.secret) + } + + public submitShout = async (shout: any) => { + const shoutHash = await this.xrpio.treeWrite(JSON.stringify(shout), this.userWallet1.address, this.userWallet2.secret) + return await this.submit(shoutHash) + } + + private parseMemos = async (memos: any) => { + const shouts = await Promise.all(memos + .map((memo: any) => { + if (!memo.Memo || !memo.Memo.MemoData) + return + + try { + return DataParser.parse('TxHash', hex_to_ascii(memo.Memo.MemoData)) + } catch (e) { + return + } + }) + .filter((hash: string) => hash != undefined) + .map((root_hash: string) => this.xrpio.treeRead([root_hash])) + ) + return shouts.map((jsonStr: string) => JSON.parse(jsonStr)) + } + + private loadHistory = async () => { + const raw_txs = await this.getTransactions() + return await this.parseMemos(raw_txs.flatMap((htx: any) => htx.Memos)) + } + + private subscribeTxs = async (callback: Function) => { + this.rippleApi.on('transaction', (tx: any) => callback(tx)) + await this.rippleApi.connection.request({ + command: 'subscribe', + accounts: [this.shoutboxAddress] + }) + } + + private listen = async () => { + await this.subscribeTxs(async (raw_tx: any) => { + const shouts = await this.parseMemos(raw_tx.transaction.Memos) + this.history.unshift(...shouts) + }) + } + + initialize = async () => { + this.userWallet1 = await makeTestnetWallet() + this.userWallet2 = await makeTestnetWallet() + + this.rippleApi = new xrpl.Client(xrpNode) + await this.rippleApi.connect() + + this.xrpio = new xrpIO(xrpNode); + await this.xrpio.connect() + + this.history = await this.loadHistory() + + await this.listen(); + } +} + +export function initShoutboxSvc(svc: ShoutboxDataService): () => Promise { + return svc.initialize; +} + +function hex_to_ascii(input: any) { + var hex = input.toString(); + var str = ''; + for (var n = 0; n < hex.length; n += 2) { + str += String.fromCharCode(parseInt(hex.substr(n, 2), 16)); + } + return str; +} \ No newline at end of file diff --git a/src/frontend/src/app/util/Dataparser.ts b/src/frontend/src/app/util/Dataparser.ts new file mode 100644 index 0000000..d2143f6 --- /dev/null +++ b/src/frontend/src/app/util/Dataparser.ts @@ -0,0 +1,73 @@ +import { AMOUNT_DECIMALS, AMOUNT_FORMAT, NON_ZERO_TX_HASH } from "./protocol.constants" +import { Amount, ArgumentType, TxHash } from "./types" + +export class DataParser { + public static parse(type: ArgumentType | string[], value: any){ + if(typeof type === 'object' && type[0] != null){ //enum value type + if(! (type as string[]).includes(value)){ + throw new Error(`Invalid enum value type: ${value} is not included in ${type}`) + } + return value + }else if(typeof type === 'string'){ //known defined type + if(!this.typeMap[type]){ + throw new Error('Unknown parameter type'+type) + } + return this.typeMap[type](value) + }else{ + throw new Error('FRAMEWORK_ERR: Datachecker.check(..): unknown argument type '+String(type)) + } + } + + private static typeMap : {[key in ArgumentType] : Function} = { + Amount: DataParser.parseAmount, + Boolean: DataParser.parseBoolean, + String: DataParser.parseString, + TxHash: DataParser.parseTxHash, + any: DataParser.parseAny + } + + + private static parseAmount(input: any): Amount{ + if(typeof input === 'string' && typeof input !== 'number') + throw new Error('Input is not a number') + + if(typeof input === 'string') + input = Number.parseFloat(input) + + if(! AMOUNT_FORMAT.test(''+input)){ + throw new Error('Input did not match the specification for `Amount`. The maximum number of decimals is '+AMOUNT_DECIMALS) + } + + return input + } + + + private static parseString(input: any): string{ + if(typeof input !== 'string') + throw new Error('Input is not a string') + return input + } + + private static parseBoolean(input: any): boolean{ + switch (typeof input) { + case 'boolean': return input; + case 'string': { + if(input === 'true') return true; + if(input === 'false') return false; + break; + } + } + throw new Error('Input is not a boolean') + } + + private static parseAny(input: any): any{ + return input + } + + private static parseTxHash(input: any): TxHash{ + if(typeof input !== 'string' || !NON_ZERO_TX_HASH.test(input)){ + throw new Error('Input is not a trasnaction hash') + } + return input + } +} \ No newline at end of file diff --git a/src/frontend/src/app/util/TestnetUtils.ts b/src/frontend/src/app/util/TestnetUtils.ts new file mode 100644 index 0000000..378bb04 --- /dev/null +++ b/src/frontend/src/app/util/TestnetUtils.ts @@ -0,0 +1,14 @@ +export const makeTestnetWallet = () : Promise<{ secret: string, address: string }> => fetch('https://faucet.altnet.rippletest.net/accounts', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, +}).then((raw:any) => { + return raw.json().then((content:any) => { + return({ + secret: content.account.secret, + address: content.account.address + }); + }) +}); \ No newline at end of file diff --git a/src/frontend/src/app/util/protocol.constants.ts b/src/frontend/src/app/util/protocol.constants.ts new file mode 100644 index 0000000..1a86b42 --- /dev/null +++ b/src/frontend/src/app/util/protocol.constants.ts @@ -0,0 +1,14 @@ +export const MSG_DELIM: string = ' ' +export const MSG_DATA_MAX: number = 925 +export const PUBKEY_LEN: number = 66 +export const NON_ZERO_TX_HASH = new RegExp(`[0-9A-F]{64}`) +export const PTR_FORMAT = new RegExp(`^((${NON_ZERO_TX_HASH.source})|0)`) +export const DATA_FORMAT = new RegExp(`(.{1,${MSG_DATA_MAX}})`) +export const SIGNATURE_FORMAT = new RegExp(`(\\S{140}|\\S{142})$`) +export const SIGNER_FORMAT = new RegExp(`(\\S{${PUBKEY_LEN}})`) +export const MSG_FORMAT = new RegExp(`${PTR_FORMAT.source}${MSG_DELIM}${DATA_FORMAT.source}`, 'm') +export const AMOUNT_DECIMALS = 18 +export const MAX_SUPPLY = 20_000_000 +export const AMOUNT_FORMAT = new RegExp(`\d+(\.\d{1,${AMOUNT_DECIMALS}})?`) +export const MIN_XRP_FEE = "0.00001" +export const MIN_XRP_TX_VALUE = "0.000001" \ No newline at end of file diff --git a/src/frontend/src/app/util/types.ts b/src/frontend/src/app/util/types.ts new file mode 100644 index 0000000..992651e --- /dev/null +++ b/src/frontend/src/app/util/types.ts @@ -0,0 +1,56 @@ +export type Memo = { + type?: string + format?: string + data?: string +} + +export type Signature = {signature: string, signer: PublicKey} + +export type Environment = { + msg: { + sender: Address, + value: number, + data: XrpTransaction, + rawTx: string + } +} + +export type Transition = { + call: keyof Model, + params: any[] +} + +export type ArgumentType = 'String' | 'Amount' | 'TxHash' | 'Boolean' | 'any' +export type ReturnType = ArgumentType | 'Void' +export type MutableStateOperation = 'ARRAY_PUSH' | 'ARRAY_UNSHIFT' | 'ARRAY_SHIFT' | 'ARRAY_POP' | 'VALUE_SET' | 'VALUE_DELETE' +export const MutableStateOperationStrings = ['ARRAY_PUSH', 'ARRAY_UNSHIFT', 'ARRAY_SHIFT', 'ARRAY_POP', 'VALUE_SET', 'VALUE_DELETE'] +export type ArgumentDefiniton = { + type: ArgumentType | string[], + name: string +} +export type ContractFunctionSignature = { + name: keyof Of + argTypes: ArgumentDefiniton[] + returnType: ReturnType + documentation?: string, + modifier?: Modifier[] +} + +export type Modifier = "OWNER_ONLY" | "PAYABLE" +export type TransferListener = (transfer : { _from: Address, _to: Address, _value: Amount}) => void +export type ApprovalListener = (approval : { _from: Address, _spender: Address, _value: Amount}) => void + +export type Address = string +export type Secret = string +export type PublicKey = string +export type Amount = number +export type TxHash = string + +export type XrpTransaction = { + hash: TxHash, + sender: Address, + receiver: Address, + value: Amount, + fee: Amount, + ledger_index: number +} \ No newline at end of file diff --git a/src/frontend/src/index.html b/src/frontend/src/index.html index de8591e..5e1f8a6 100644 --- a/src/frontend/src/index.html +++ b/src/frontend/src/index.html @@ -2,10 +2,12 @@ - AngularCli + httXrp + +