Skip to content

Commit 7dd9550

Browse files
authored
Merge pull request #1 from fpco/broadcast
KOL-27 | Support for broadcasting messages
2 parents 40873a3 + e348cc7 commit 7dd9550

File tree

11 files changed

+473
-0
lines changed

11 files changed

+473
-0
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @renra

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI/CD
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
pull_request:
9+
branches:
10+
- main
11+
12+
jobs:
13+
build:
14+
name: Build
15+
runs-on: ubuntu-latest
16+
permissions:
17+
id-token: write
18+
contents: read
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Use Node.js 20
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: "20"
27+
cache: "npm"
28+
29+
- name: Install
30+
run: npm i
31+
32+
- name: Typecheck
33+
run: |
34+
npx tsc

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
11
# Kolme client
22

33
This is a library you can use to interact with kolme-powered applications.
4+
5+
Things you can do:
6+
7+
```TypeScript
8+
import { KolmeClient } from 'kolme-client'
9+
10+
const client = new KolmeClient('https://yourkolme.app')
11+
12+
const privateKey = client.generatePrivateKey()
13+
14+
const block = await client.broadcast(privateKey, [{
15+
YourAppMessage: {
16+
YourAppPayload: {}
17+
}
18+
}])
19+
```

package-lock.json

Lines changed: 124 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "kolme-client",
3+
"version": "1.0.0",
4+
"main": "src/index.js",
5+
"scripts": {},
6+
"author": "",
7+
"license": "ISC",
8+
"description": "",
9+
"devDependencies": {
10+
"typescript": "^5.8.3"
11+
},
12+
"dependencies": {
13+
"@noble/secp256k1": "^2.3.0",
14+
"buffer": "^6.0.3",
15+
"io-ts": "^2.2.22"
16+
}
17+
}

src/HttpApi.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import * as t from 'io-ts';
2+
import { isRight } from 'fp-ts/Either';
3+
import { PathReporter } from 'io-ts/lib/PathReporter';
4+
import waitFor from './waitFor';
5+
6+
const processResponse = async <T>(response: Response, decoder: t.Decoder<unknown, T>) : Promise<t.Validation<T>> => {
7+
if(!response.ok) {
8+
throw new Error(`Got response with status ${response.status}`)
9+
}
10+
11+
const rawData = await response.json()
12+
return decoder.decode(rawData)
13+
}
14+
15+
export type BroadcastInputs = {
16+
signature: string,
17+
recoveryId: number,
18+
message: string
19+
}
20+
21+
const broadcastResult = t.type({
22+
txhash: t.string
23+
})
24+
25+
export type BroadcastResult = t.TypeOf<typeof broadcastResult>
26+
27+
export const broadcast = async (base: string, { message, recoveryId, signature }: BroadcastInputs): Promise<BroadcastResult> => {
28+
const response = await fetch(`${base}/broadcast`, {
29+
method: 'PUT',
30+
headers: {
31+
'Content-Type': 'application/json',
32+
},
33+
body: JSON.stringify({
34+
message,
35+
signature,
36+
recovery_id: recoveryId
37+
}),
38+
});
39+
40+
const parsedData = await processResponse(response, broadcastResult)
41+
42+
if(isRight(parsedData)) {
43+
return parsedData.right
44+
} else {
45+
throw new Error(`${PathReporter.report(parsedData)}`)
46+
}
47+
}
48+
49+
const nextNonce = t.type({
50+
next_nonce: t.number,
51+
})
52+
53+
export const getNextNonce = async (base: string, kolmePublicKey: string) : Promise<number> => {
54+
const response = await fetch(`${base}/get-next-nonce?pubkey=${kolmePublicKey}`);
55+
56+
const parsedData = await processResponse(response, nextNonce)
57+
58+
if(isRight(parsedData)) {
59+
return parsedData.right.next_nonce
60+
} else {
61+
throw new Error(`${PathReporter.report(parsedData)}`)
62+
}
63+
}
64+
65+
function taggedJson<C extends t.Mixed>(inner: C) {
66+
return new t.Type<
67+
t.TypeOf<C>, // the decoded output
68+
string, // the encoded input (we accept a JSON string)
69+
unknown // overall unknown input
70+
>(
71+
`TaggedJson<${inner.name}>`,
72+
inner.is,
73+
(u, c) => {
74+
if (typeof u !== 'string') {
75+
return t.failure(u, c, 'expected a JSON‐string');
76+
}
77+
let parsed: unknown;
78+
try {
79+
parsed = JSON.parse(u);
80+
} catch (_e) {
81+
return t.failure(u, c, 'invalid JSON string');
82+
}
83+
// now validate the parsed object against `inner`
84+
return inner.validate(parsed, c);
85+
},
86+
// when encoding back out, we simply JSON.stringify
87+
(a) => JSON.stringify(a),
88+
);
89+
}
90+
91+
const signedTaggedJson = <C extends t.Mixed>(inner: C) =>
92+
t.type({
93+
message: taggedJson(inner),
94+
signature: t.string,
95+
recovery_id: t.number,
96+
});
97+
98+
export const transactionCodec = t.type({
99+
pubkey: t.string,
100+
nonce: t.number,
101+
created: t.string,
102+
messages: t.array(t.any),
103+
max_height: t.union([t.number, t.null, t.undefined]),
104+
});
105+
106+
const blockCodec = t.type({
107+
tx: signedTaggedJson(transactionCodec),
108+
timestamp: t.string,
109+
processor: t.string,
110+
height: t.number,
111+
parent: t.string,
112+
framework_state: t.string,
113+
app_state: t.string,
114+
logs: t.string,
115+
loads: t.array(t.type({ request: t.string, response: t.string })),
116+
});
117+
118+
const block = t.type({
119+
blockhash: t.string,
120+
txhash: t.string,
121+
block: signedTaggedJson(blockCodec),
122+
logs: t.array(t.array(t.string))
123+
})
124+
125+
export type Block = t.TypeOf<typeof block>
126+
127+
export const loadBlock = async (base: string, txHash: string): Promise<Block> => {
128+
const response = await fetch(`${base}/block-by-tx-hash/${txHash}`);
129+
130+
const parsedData = await processResponse(response, block)
131+
132+
if(isRight(parsedData)) {
133+
return parsedData.right
134+
} else {
135+
throw new Error(`${PathReporter.report(parsedData)}`)
136+
}
137+
}
138+
139+
export const waitForTx = async(base: string, txHash: string) : Promise<Block> => {
140+
let attempt = 1
141+
const before = Date.now()
142+
143+
while(attempt < 100) {
144+
try {
145+
return await loadBlock(base, txHash)
146+
} catch {
147+
const nextWaitTime = 1000 + (Math.floor(attempt / 10) * 1000)
148+
await waitFor(nextWaitTime)
149+
}
150+
151+
attempt += 1
152+
}
153+
154+
const after = Date.now()
155+
156+
throw new Error(`Kolme block with tx ${txHash} did not appear on the side chain after ${(after - before) / 1000} seconds`)
157+
}
158+
159+
export class WithBase {
160+
base: string;
161+
162+
constructor(base: string) {
163+
this.base = base;
164+
}
165+
166+
async getNextNonce(kolmePublicKey: string): Promise<number> {
167+
return getNextNonce(this.base, kolmePublicKey)
168+
}
169+
170+
async broadcast(inputs: BroadcastInputs): Promise<BroadcastResult> {
171+
return broadcast(this.base, inputs)
172+
}
173+
174+
async loadBlock(txHash: string): Promise<Block> {
175+
return loadBlock(this.base, txHash)
176+
}
177+
178+
async waitForTx(txHash: string): Promise<Block> {
179+
return waitForTx(this.base, txHash)
180+
}
181+
}

src/crypto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const DIGEST_ALGO = 'SHA-256';
2+
3+
export const digest = async (message: string): Promise<Uint8Array> => {
4+
const msgBuffer = new TextEncoder().encode(message);
5+
const hashBuffer = await crypto.subtle.digest(DIGEST_ALGO, msgBuffer);
6+
return new Uint8Array(hashBuffer);
7+
};
8+

0 commit comments

Comments
 (0)