Skip to content

Commit 60899b1

Browse files
Add new money exchange TS template (#5134)
# Description of Changes Add a simple account based exchange demo, each account is created with $100.00 and create a nickname. The data is tracked with a simple double entry for both accounts, you select an account and send money. - TypeScript module - Simple React front-end # API and ABI breaking changes N/A # Expected complexity level and risk 1 - Added a small template # Testing - [x] Ran through `spacetime init` against local
1 parent 1b0ac07 commit 60899b1

33 files changed

Lines changed: 1511 additions & 0 deletions

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default tseslint.config(
5151
'./crates/bindings-typescript/test-app/tsconfig.json',
5252
'./templates/react-ts/tsconfig.json',
5353
'./templates/chat-react-ts/tsconfig.json',
54+
'./templates/money-exchange-react-ts/tsconfig.json',
5455
'./templates/hangman-react-ts/tsconfig.json',
5556
'./templates/basic-ts/tsconfig.json',
5657
'./templates/angular-ts/tsconfig.app.json',

pnpm-lock.yaml

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

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ packages:
33
- 'crates/bindings-typescript/test-app'
44
- 'crates/bindings-typescript/case-conversion-test-client'
55
- 'templates/chat-react-ts'
6+
- 'templates/money-exchange-react-ts'
67
- 'templates/hangman-react-ts'
78
- 'templates/react-ts'
89
- 'templates/basic-ts'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
dist
3+
*.log
4+
5+
.DS_Store
6+
7+
spacetime.local.json
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"description": "Private account money exchange demo with React and TypeScript server",
3+
"client_framework": "React",
4+
"client_lang": "typescript",
5+
"server_lang": "typescript",
6+
"tags": ["Launchpad"],
7+
"builtWith": [
8+
"react",
9+
"react-dom",
10+
"eslint",
11+
"testing-library",
12+
"vitejs",
13+
"eslint-plugin-react-hooks",
14+
"eslint-plugin-react-refresh",
15+
"globals",
16+
"jsdom",
17+
"prettier",
18+
"typescript",
19+
"typescript-eslint",
20+
"vite",
21+
"vitest",
22+
"spacetimedb"
23+
]
24+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Money Exchange
2+
3+
A small React and TypeScript demo for a hackathon: every participant receives a
4+
private account, claims a public nickname, and transfers money to other named
5+
participants in real time.
6+
7+
The example demonstrates:
8+
9+
- Private identity-owned accounts and an automatic starter balance
10+
- Atomic transfers implemented as a SpacetimeDB reducer
11+
- Private account changes represented as credit and debit entries
12+
- A public recipient directory without exposing other users' balances
13+
14+
## Run The Template
15+
16+
Create and run the app with the SpacetimeDB CLI:
17+
18+
```bash
19+
spacetime dev --template money-exchange-react-ts
20+
```
21+
22+
Open [http://localhost:5173](http://localhost:5173), then open a second
23+
private browser window to create another identity and send payments between
24+
the two users.
25+
26+
## Explore The Code
27+
28+
The server module is in `spacetimedb/src/index.ts`. On first connection it
29+
creates a private account containing `$100.00`. Users must claim a unique
30+
nickname before they appear in the recipient directory.
31+
32+
The `transfer` reducer accepts a recipient identity and a cent amount. It
33+
validates ownership and available funds, debits the sender, credits the
34+
recipient, and writes a `Debit` account change for the sender and a `Credit`
35+
account change for the recipient in one transaction. Errors abort the whole
36+
transaction, so a failed payment never partially changes balances or history.
37+
38+
The `account` and `account_change` tables are private. The `my_account` and
39+
`my_account_changes` views let each connected identity subscribe only to its
40+
own balance and change history. The public `directory` table contains names
41+
and identities for choosing whom to pay.
42+
43+
The React client is in `src/App.tsx`; generated type-safe bindings live in
44+
`src/module_bindings`.
45+
46+
## Extend It
47+
48+
This example uses play money. Natural hackathon extensions include payment
49+
memos, payment requests, shared wallets, an administrator faucet, or
50+
authenticated user profiles.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Money Exchange</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "@clockworklabs/money-exchange-react-ts",
3+
"private": true,
4+
"version": "0.0.1",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc -b && vite build",
9+
"format": "prettier . --write --ignore-path ../../.prettierignore",
10+
"lint": "eslint . && prettier . --check --ignore-path ../../.prettierignore",
11+
"preview": "vite preview",
12+
"test": "vitest run",
13+
"generate": "cargo run -p gen-bindings -- --out-dir src/module_bindings --module-path spacetimedb && prettier --write src/module_bindings",
14+
"spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb",
15+
"spacetime:publish:local": "spacetime publish --module-path spacetimedb --server local",
16+
"spacetime:publish": "spacetime publish --module-path spacetimedb --server maincloud"
17+
},
18+
"dependencies": {
19+
"spacetimedb": "workspace:*",
20+
"react": "^18.3.1",
21+
"react-dom": "^18.3.1"
22+
},
23+
"devDependencies": {
24+
"@eslint/js": "^9.17.0",
25+
"@testing-library/jest-dom": "^6.6.3",
26+
"@testing-library/react": "^16.2.0",
27+
"@testing-library/user-event": "^14.6.1",
28+
"@types/react": "^18.3.18",
29+
"@types/react-dom": "^18.3.5",
30+
"@vitejs/plugin-react": "^5.0.2",
31+
"eslint": "^9.17.0",
32+
"eslint-plugin-react-hooks": "^5.0.0",
33+
"eslint-plugin-react-refresh": "^0.4.16",
34+
"globals": "^15.14.0",
35+
"jsdom": "^26.0.0",
36+
"prettier": "^3.3.3",
37+
"typescript": "~5.6.2",
38+
"typescript-eslint": "^8.18.2",
39+
"vite": "^7.1.5",
40+
"vitest": "3.2.4"
41+
}
42+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "money-exchange-module",
3+
"version": "1.0.0",
4+
"description": "",
5+
"type": "module",
6+
"scripts": {
7+
"build": "spacetime build",
8+
"publish": "spacetime publish"
9+
},
10+
"license": "ISC",
11+
"dependencies": {
12+
"spacetimedb": "workspace:*"
13+
},
14+
"devDependencies": {
15+
"typescript": "~5.6.2"
16+
}
17+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { schema, SenderError, table, t } from 'spacetimedb/server';
2+
3+
const STARTING_BALANCE_CENTS = 10_000n;
4+
const MAX_NAME_LENGTH = 20;
5+
const MAX_U64 = (1n << 64n) - 1n;
6+
7+
const directory = table(
8+
{ name: 'directory', public: true },
9+
{
10+
identity: t.identity().primaryKey(),
11+
name: t.string(),
12+
nameKey: t.string().unique(),
13+
}
14+
);
15+
16+
const account = table(
17+
{ name: 'account' },
18+
{
19+
identity: t.identity().primaryKey(),
20+
balanceCents: t.u64(),
21+
}
22+
);
23+
24+
const changeDirection = t.enum('ChangeDirection', {
25+
Credit: t.unit(),
26+
Debit: t.unit(),
27+
});
28+
29+
const accountChange = table(
30+
{ name: 'account_change' },
31+
{
32+
id: t.u64().primaryKey().autoInc(),
33+
accountIdentity: t.identity().index('btree'),
34+
counterpartyIdentity: t.identity(),
35+
direction: changeDirection,
36+
amountCents: t.u64(),
37+
createdAt: t.timestamp(),
38+
}
39+
);
40+
41+
const spacetimedb = schema({ directory, account, accountChange });
42+
export default spacetimedb;
43+
44+
export const onConnect = spacetimedb.clientConnected(ctx => {
45+
if (!ctx.db.account.identity.find(ctx.sender)) {
46+
ctx.db.account.insert({
47+
identity: ctx.sender,
48+
balanceCents: STARTING_BALANCE_CENTS,
49+
});
50+
}
51+
});
52+
53+
export const my_account = spacetimedb.view(
54+
{ name: 'my_account', public: true },
55+
account.rowType.optional(),
56+
ctx => ctx.db.account.identity.find(ctx.sender) ?? undefined
57+
);
58+
59+
export const my_account_changes = spacetimedb.view(
60+
{ name: 'my_account_changes', public: true },
61+
t.array(accountChange.rowType),
62+
ctx => [...ctx.db.accountChange.accountIdentity.filter(ctx.sender)]
63+
);
64+
65+
export const set_name = spacetimedb.reducer(
66+
{ name: t.string() },
67+
(ctx, { name }) => {
68+
if (!ctx.db.account.identity.find(ctx.sender)) {
69+
throw new SenderError('Account is not ready yet');
70+
}
71+
72+
const displayName = name.trim();
73+
if (displayName.length === 0 || displayName.length > MAX_NAME_LENGTH) {
74+
throw new SenderError('Names must be between 1 and 20 characters');
75+
}
76+
77+
const nameKey = displayName.toLowerCase();
78+
const owner = ctx.db.directory.nameKey.find(nameKey);
79+
if (owner && !owner.identity.isEqual(ctx.sender)) {
80+
throw new SenderError('That name is already in use');
81+
}
82+
83+
const existing = ctx.db.directory.identity.find(ctx.sender);
84+
if (existing) {
85+
ctx.db.directory.identity.update({
86+
identity: ctx.sender,
87+
name: displayName,
88+
nameKey,
89+
});
90+
} else {
91+
ctx.db.directory.insert({
92+
identity: ctx.sender,
93+
name: displayName,
94+
nameKey,
95+
});
96+
}
97+
}
98+
);
99+
100+
export const transfer = spacetimedb.reducer(
101+
{ recipient: t.identity(), amountCents: t.u64() },
102+
(ctx, { recipient: recipientIdentity, amountCents }) => {
103+
const sender = ctx.db.directory.identity.find(ctx.sender);
104+
if (!sender) {
105+
throw new SenderError('Choose a name before sending money');
106+
}
107+
if (recipientIdentity.isEqual(ctx.sender)) {
108+
throw new SenderError('You cannot send money to yourself');
109+
}
110+
if (amountCents === 0n) {
111+
throw new SenderError('Amount must be greater than zero');
112+
}
113+
if (!ctx.db.directory.identity.find(recipientIdentity)) {
114+
throw new SenderError('Recipient does not exist');
115+
}
116+
117+
const fromAccount = ctx.db.account.identity.find(ctx.sender);
118+
const toAccount = ctx.db.account.identity.find(recipientIdentity);
119+
if (!fromAccount || !toAccount) {
120+
throw new SenderError('Account does not exist');
121+
}
122+
if (fromAccount.balanceCents < amountCents) {
123+
throw new SenderError('Insufficient funds');
124+
}
125+
if (toAccount.balanceCents > MAX_U64 - amountCents) {
126+
throw new SenderError('Recipient balance is too large');
127+
}
128+
129+
ctx.db.account.identity.update({
130+
...fromAccount,
131+
balanceCents: fromAccount.balanceCents - amountCents,
132+
});
133+
ctx.db.account.identity.update({
134+
...toAccount,
135+
balanceCents: toAccount.balanceCents + amountCents,
136+
});
137+
ctx.db.accountChange.insert({
138+
id: 0n,
139+
accountIdentity: ctx.sender,
140+
counterpartyIdentity: recipientIdentity,
141+
direction: { tag: 'Debit' },
142+
amountCents,
143+
createdAt: ctx.timestamp,
144+
});
145+
ctx.db.accountChange.insert({
146+
id: 0n,
147+
accountIdentity: recipientIdentity,
148+
counterpartyIdentity: ctx.sender,
149+
direction: { tag: 'Credit' },
150+
amountCents,
151+
createdAt: ctx.timestamp,
152+
});
153+
}
154+
);

0 commit comments

Comments
 (0)