Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default tseslint.config(
'./crates/bindings-typescript/test-app/tsconfig.json',
'./templates/react-ts/tsconfig.json',
'./templates/chat-react-ts/tsconfig.json',
'./templates/money-exchange-react-ts/tsconfig.json',
'./templates/hangman-react-ts/tsconfig.json',
'./templates/basic-ts/tsconfig.json',
'./templates/angular-ts/tsconfig.app.json',
Expand Down
64 changes: 64 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ packages:
- 'crates/bindings-typescript/test-app'
- 'crates/bindings-typescript/case-conversion-test-client'
- 'templates/chat-react-ts'
- 'templates/money-exchange-react-ts'
- 'templates/hangman-react-ts'
- 'templates/react-ts'
- 'templates/basic-ts'
Expand Down
7 changes: 7 additions & 0 deletions templates/money-exchange-react-ts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
*.log

.DS_Store

spacetime.local.json
24 changes: 24 additions & 0 deletions templates/money-exchange-react-ts/.template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"description": "Private account money exchange demo with React and TypeScript server",
"client_framework": "React",
"client_lang": "typescript",
"server_lang": "typescript",
"tags": ["Launchpad"],
"builtWith": [
"react",
"react-dom",
"eslint",
"testing-library",
"vitejs",
"eslint-plugin-react-hooks",
"eslint-plugin-react-refresh",
"globals",
"jsdom",
"prettier",
"typescript",
"typescript-eslint",
"vite",
"vitest",
"spacetimedb"
]
}
50 changes: 50 additions & 0 deletions templates/money-exchange-react-ts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Money Exchange

A small React and TypeScript demo for a hackathon: every participant receives a
private account, claims a public nickname, and transfers money to other named
participants in real time.

The example demonstrates:

- Private identity-owned accounts and an automatic starter balance
- Atomic transfers implemented as a SpacetimeDB reducer
- Private account changes represented as credit and debit entries
- A public recipient directory without exposing other users' balances

## Run The Template

Create and run the app with the SpacetimeDB CLI:

```bash
spacetime dev --template money-exchange-react-ts
```

Open [http://localhost:5173](http://localhost:5173), then open a second
private browser window to create another identity and send payments between
the two users.

## Explore The Code

The server module is in `spacetimedb/src/index.ts`. On first connection it
creates a private account containing `$100.00`. Users must claim a unique
nickname before they appear in the recipient directory.

The `transfer` reducer accepts a recipient identity and a cent amount. It
validates ownership and available funds, debits the sender, credits the
recipient, and writes a `Debit` account change for the sender and a `Credit`
account change for the recipient in one transaction. Errors abort the whole
transaction, so a failed payment never partially changes balances or history.

The `account` and `account_change` tables are private. The `my_account` and
`my_account_changes` views let each connected identity subscribe only to its
own balance and change history. The public `directory` table contains names
and identities for choosing whom to pay.

The React client is in `src/App.tsx`; generated type-safe bindings live in
`src/module_bindings`.

## Extend It

This example uses play money. Natural hackathon extensions include payment
memos, payment requests, shared wallets, an administrator faucet, or
authenticated user profiles.
12 changes: 12 additions & 0 deletions templates/money-exchange-react-ts/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Money Exchange</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
42 changes: 42 additions & 0 deletions templates/money-exchange-react-ts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@clockworklabs/money-exchange-react-ts",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"format": "prettier . --write --ignore-path ../../.prettierignore",
"lint": "eslint . && prettier . --check --ignore-path ../../.prettierignore",
"preview": "vite preview",
"test": "vitest run",
"generate": "cargo run -p gen-bindings -- --out-dir src/module_bindings --module-path spacetimedb && prettier --write src/module_bindings",
"spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb",
"spacetime:publish:local": "spacetime publish --module-path spacetimedb --server local",
"spacetime:publish": "spacetime publish --module-path spacetimedb --server maincloud"
},
"dependencies": {
"spacetimedb": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"prettier": "^3.3.3",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^7.1.5",
"vitest": "3.2.4"
}
}
17 changes: 17 additions & 0 deletions templates/money-exchange-react-ts/spacetimedb/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "money-exchange-module",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"build": "spacetime build",
"publish": "spacetime publish"
},
"license": "ISC",
"dependencies": {
"spacetimedb": "workspace:*"
},
"devDependencies": {
"typescript": "~5.6.2"
}
}
154 changes: 154 additions & 0 deletions templates/money-exchange-react-ts/spacetimedb/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { schema, SenderError, table, t } from 'spacetimedb/server';

const STARTING_BALANCE_CENTS = 10_000n;
const MAX_NAME_LENGTH = 20;
const MAX_U64 = (1n << 64n) - 1n;

const directory = table(
{ name: 'directory', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
nameKey: t.string().unique(),
}
);

const account = table(
{ name: 'account' },
{
identity: t.identity().primaryKey(),
balanceCents: t.u64(),
}
);

const changeDirection = t.enum('ChangeDirection', {
Credit: t.unit(),
Debit: t.unit(),
});

const accountChange = table(
{ name: 'account_change' },
{
id: t.u64().primaryKey().autoInc(),
accountIdentity: t.identity().index('btree'),
counterpartyIdentity: t.identity(),
direction: changeDirection,
amountCents: t.u64(),
createdAt: t.timestamp(),
}
);

const spacetimedb = schema({ directory, account, accountChange });
export default spacetimedb;

export const onConnect = spacetimedb.clientConnected(ctx => {
if (!ctx.db.account.identity.find(ctx.sender)) {
ctx.db.account.insert({
identity: ctx.sender,
balanceCents: STARTING_BALANCE_CENTS,
});
}
});

export const my_account = spacetimedb.view(
{ name: 'my_account', public: true },
account.rowType.optional(),
ctx => ctx.db.account.identity.find(ctx.sender) ?? undefined
);

export const my_account_changes = spacetimedb.view(
{ name: 'my_account_changes', public: true },
t.array(accountChange.rowType),
ctx => [...ctx.db.accountChange.accountIdentity.filter(ctx.sender)]
);

export const set_name = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
if (!ctx.db.account.identity.find(ctx.sender)) {
throw new SenderError('Account is not ready yet');
}

const displayName = name.trim();
if (displayName.length === 0 || displayName.length > MAX_NAME_LENGTH) {
throw new SenderError('Names must be between 1 and 20 characters');
}

const nameKey = displayName.toLowerCase();
const owner = ctx.db.directory.nameKey.find(nameKey);
if (owner && !owner.identity.isEqual(ctx.sender)) {
throw new SenderError('That name is already in use');
}

const existing = ctx.db.directory.identity.find(ctx.sender);
if (existing) {
ctx.db.directory.identity.update({
identity: ctx.sender,
name: displayName,
nameKey,
});
} else {
ctx.db.directory.insert({
identity: ctx.sender,
name: displayName,
nameKey,
});
}
}
);

export const transfer = spacetimedb.reducer(
{ recipient: t.identity(), amountCents: t.u64() },
(ctx, { recipient: recipientIdentity, amountCents }) => {
const sender = ctx.db.directory.identity.find(ctx.sender);
if (!sender) {
throw new SenderError('Choose a name before sending money');
}
if (recipientIdentity.isEqual(ctx.sender)) {
throw new SenderError('You cannot send money to yourself');
}
if (amountCents === 0n) {
throw new SenderError('Amount must be greater than zero');
}
if (!ctx.db.directory.identity.find(recipientIdentity)) {
throw new SenderError('Recipient does not exist');
}

const fromAccount = ctx.db.account.identity.find(ctx.sender);
const toAccount = ctx.db.account.identity.find(recipientIdentity);
if (!fromAccount || !toAccount) {
throw new SenderError('Account does not exist');
}
if (fromAccount.balanceCents < amountCents) {
throw new SenderError('Insufficient funds');
}
if (toAccount.balanceCents > MAX_U64 - amountCents) {
throw new SenderError('Recipient balance is too large');
}

ctx.db.account.identity.update({
...fromAccount,
balanceCents: fromAccount.balanceCents - amountCents,
});
ctx.db.account.identity.update({
...toAccount,
balanceCents: toAccount.balanceCents + amountCents,
});
ctx.db.accountChange.insert({
id: 0n,
accountIdentity: ctx.sender,
counterpartyIdentity: recipientIdentity,
direction: { tag: 'Debit' },
amountCents,
createdAt: ctx.timestamp,
});
ctx.db.accountChange.insert({
id: 0n,
accountIdentity: recipientIdentity,
counterpartyIdentity: ctx.sender,
direction: { tag: 'Credit' },
amountCents,
createdAt: ctx.timestamp,
});
}
);
Loading
Loading