From ac3f95a58702c9cf6cfa2a497330d168169dd3c3 Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:02:41 +0100 Subject: [PATCH 01/20] Create TradingCards This is an experimental project exploring fractional ownership using Intercom. Trading cards were chosen as the initial use case because they already represent a strong bridge between physical objects and digital ownership. Their ongoing digitalization and active trading culture make them a natural starting point for fractional, peer-to-peer ownership models. These fundamentals make trading cards a perfect fit for this project. Future development will expand beyond trading cards and dive deeper into broader Real-World Asset (RWA) use cases, exploring how physical assets can become natively owned, coordinated, and traded through decentralized infrastructure. --- TradingCards | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 TradingCards diff --git a/TradingCards b/TradingCards new file mode 100644 index 00000000..8f8eeada --- /dev/null +++ b/TradingCards @@ -0,0 +1,90 @@ +# Fractional Ownership for Trading Cards using Intercom + +What if you could own a fraction of a rare trading card—for as little as $10? + +This project explores how real-world collectibles like Pokémon, sports cards, or other high-value trading cards can become shared, peer-to-peer owned assets. Instead of relying on centralized platforms, ownership, communication, and trading happen directly between participants using Intercom’s decentralized infrastructure. + +Each card becomes its own digital coordination space where holders can: + +* own fractions of the asset +* trade shares directly with others +* coordinate decisions (e.g. sell, hold, auction) +* communicate peer-to-peer without intermediaries + +This lowers the barrier to entry for collectors and investors, and enables new forms of shared ownership around real-world assets. + +--- + +# Why Intercom + +Intercom provides a peer-to-peer networking stack designed for an “internet of agents,” enabling secure communication, shared state, and optional value transfer without centralized servers. + +By combining Intercom with fractional ownership logic, each trading card becomes: + +* a **peer-to-peer asset channel** +* a **replicated ownership state** managed by contracts +* a **coordination layer** for holders +* a **marketplace without a platform** + +This means the asset itself becomes the platform. + +--- + +# Concept Overview + +Each trading card is represented as: + +**1. Asset Channel (Sidechannel)** +A dedicated Intercom sidechannel for real-time communication and coordination between holders. + +**2. Ownership Contract (Subnet plane)** +A deterministic replicated state storing: + +* total shares +* ownership distribution +* transfer history + +**3. Peer-to-Peer Trading** +Participants can transfer ownership directly, without centralized custody. + +**4. Optional Settlement Layer** +Value transfer can be integrated using crypto settlement if required. + +--- + +# Goals of this Repository + +This fork serves as an experimental foundation to explore: + +* Fractional ownership of real-world collectibles +* Peer-to-peer asset coordination +* Decentralized asset communities +* Agent-native ownership and trading models + +--- + +# Vision + +Fractional ownership will transform how real-world assets are owned and traded. + +Trading cards are the ideal starting point: + +* highly liquid +* globally recognized +* culturally significant +* crypto-native audience + +This project is a step toward a future where any real-world asset can exist as a peer-to-peer owned, digitally coordinated entity. + +--- + +# Status + +Experimental. Early exploration and prototype phase. + +--- + +# Based on + +Intercom by Trac Systems +https://github.com/Trac-Systems/intercom From 37fbd878f47454a5f5044118232534b4037a55f6 Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:15:39 +0100 Subject: [PATCH 02/20] Update README.md --- README.md | 86 +++++++++++++------------------------------------------ 1 file changed, 20 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index d00cef36..e2b0a83f 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,31 @@ -# Intercom +# Fractional Trading Cards (RWA) on Intercom -This repository is a reference implementation of the **Intercom** stack on Trac Network for an **internet of agents**. +This is an experimental project exploring **fractional ownership** using **Intercom**. -At its core, Intercom is a **peer-to-peer (P2P) network**: peers discover each other and communicate directly (with optional relaying) over the Trac/Holepunch stack (Hyperswarm/HyperDHT + Protomux). There is no central server required for sidechannel messaging. +Trading cards are the initial focus because they already sit at the intersection of **physical assets** and **digital ownership**. Their ongoing digitalization and active trading culture make them a natural starting point for testing decentralized, fractional ownership models. -Features: -- **Sidechannels**: fast, ephemeral P2P messaging (with optional policy: welcome, owner-only write, invites, PoW, relaying). -- **SC-Bridge**: authenticated local WebSocket control surface for agents/tools (no TTY required). -- **Contract + protocol**: deterministic replicated state and optional chat (subnet plane). -- **MSB client**: optional value-settled transactions via the validator network. +This repo is a fork-based exploration built on Intercom’s peer-to-peer stack: direct communication, deterministic shared state (contracts), and optional value settlement — without relying on a central platform. -Additional references: https://www.moltbook.com/post/9ddd5a47-4e8d-4f01-9908-774669a11c21 and moltbook m/intercom +## What this aims to build (MVP) -For full, agent‑oriented instructions and operational guidance, **start with `SKILL.md`**. -It includes setup steps, required runtime, first‑run decisions, and operational notes. +**Each trading card becomes its own peer-to-peer asset network:** +- A dedicated **P2P channel** for real-time coordination between holders +- A replicated **ownership state** (shares / cap table) maintained by a contract +- A simple **offer/accept** flow for peer-to-peer share transfers +- Optional settlement later (crypto payments), once the core ownership model is proven -## What this repo is for -- A working, pinned example to bootstrap agents and peers onto Trac Network. -- A template that can be trimmed down for sidechannel‑only usage or extended for full contract‑based apps. +## Why Intercom -## How to use -Use the **Pear runtime only** (never native node). -Follow the steps in `SKILL.md` to install dependencies, run the admin peer, and join peers correctly. +Intercom provides: +- **Sidechannels**: fast P2P messaging for coordination +- **Subnet contracts**: deterministic replicated state for ownership +- **SC-Bridge**: a local authenticated WebSocket interface for apps/agents (no TTY required) +- Optional **settlement** for value transfer -## Architecture (ASCII map) -Intercom is a single long-running Pear process that participates in three distinct networking "planes": -- **Subnet plane**: deterministic state replication (Autobase/Hyperbee over Hyperswarm/Protomux). -- **Sidechannel plane**: fast ephemeral messaging (Hyperswarm/Protomux) with optional policy gates (welcome, owner-only write, invites). -- **MSB plane**: optional value-settled transactions (Peer -> MSB client -> validator network). +## Status -```text - Pear runtime (mandatory) - pear run . --peer-store-name --msb-store-name - | - v - +-------------------------------------------------------------------------+ - | Intercom peer process | - | | - | Local state: | - | - stores//... (peer identity, subnet state, etc) | - | - stores//... (MSB wallet/client state) | - | | - | Networking planes: | - | | - | [1] Subnet plane (replication) | - | --subnet-channel | - | --subnet-bootstrap (joiners only) | - | | - | [2] Sidechannel plane (ephemeral messaging) | - | entry: 0000intercom (name-only, open to all) | - | extras: --sidechannels chan1,chan2 | - | policy (per channel): welcome / owner-only write / invites | - | relay: optional peers forward plaintext payloads to others | - | | - | [3] MSB plane (transactions / settlement) | - | Peer -> MsbClient -> MSB validator network | - | | - | Agent control surface (preferred): | - | SC-Bridge (WebSocket, auth required) | - | JSON: auth, send, join, open, stats, info, ... | - +------------------------------+------------------------------+-----------+ - | | - | SC-Bridge (ws://host:port) | P2P (Hyperswarm) - v v - +-----------------+ +-----------------------+ - | Agent / tooling | | Other peers (P2P) | - | (no TTY needed) |<---------->| subnet + sidechannels | - +-----------------+ +-----------------------+ +Experimental — research & prototype phase. - Optional for local testing: - - --dht-bootstrap "" overrides the peer's HyperDHT bootstraps - (all peers that should discover each other must use the same list). -``` +## Based on ---- -If you plan to build your own app, study the existing contract/protocol and remove example logic as needed (see `SKILL.md`). +Intercom by Trac Systems: https://github.com/Trac-Systems/intercom From 797bc5eeb014a189973cd6c188b7932235e1adb9 Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:17:53 +0100 Subject: [PATCH 03/20] Create docs --- docs | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs diff --git a/docs b/docs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs @@ -0,0 +1 @@ + From 2edbd22b9910af2abf3a08ffc0ffc938ec72e720 Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:32:42 +0100 Subject: [PATCH 04/20] Delete docs --- docs | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs diff --git a/docs b/docs deleted file mode 100644 index 8b137891..00000000 --- a/docs +++ /dev/null @@ -1 +0,0 @@ - From b1bcdbb5f21893359dd466cb740ccf3254030f7b Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:38:23 +0100 Subject: [PATCH 05/20] docs: add initial fractional ownership spec --- docs/spec.md | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/spec.md diff --git a/docs/spec.md b/docs/spec.md new file mode 100644 index 00000000..6819b5c4 --- /dev/null +++ b/docs/spec.md @@ -0,0 +1,151 @@ +# Spec: Fractional Trading Card Ownership (Intercom) + +This document defines the minimal specification for fractional ownership of trading cards on top of Intercom. + +It is intentionally simple and designed to be implemented step-by-step. + +--- + +## 1) Core Idea + +A **trading card** is treated as a **real-world asset (RWA)** with: + +- an **Asset ID** (deterministic identifier) +- a **Share Supply** (fixed total shares) +- an **Ownership Table** (who owns how many shares) +- an optional **Offer Book** (peer-to-peer share transfer offers) + +**Intercom mapping** +- **Sidechannels**: coordination + announcements + offers broadcast +- **Contracts/Subnet**: ownership truth (replicated, deterministic state) +- **SC-Bridge**: app/API control surface (WebSocket) + +--- + +## 2) Asset ID (AssetID) + +### 2.1 Format (human-readable) +AssetIDs use a colon-separated namespace: + +`tc:::::` + +Examples: +- `tc:poke:base:charizard:psa10:12345678` +- `tc:mtg:alpha:blacklotus:bgs95:000001` +- `tc:sports:nba:lebron-rookie:psa9:99887766` + +### 2.2 Rules +- All lowercase +- Use `-` for spaces +- Must be unique per physical item (certificate/serial should ensure uniqueness) + +--- + +## 3) Shares & Ownership Model + +### 3.1 Supply +Each asset has a fixed share supply: + +- `totalShares` (integer, e.g. 10_000) +- `decimals` (optional later; MVP assumes integer shares) + +Default MVP: +- `totalShares = 10_000` +- Minimum trade size: `1` share + +### 3.2 Ownership Table +Ownership is tracked as: + +`holders[addressOrPubKey] -> shares` + +Constraints: +- `sum(holders[*]) == totalShares` +- shares are non-negative integers +- transfers must not create or destroy shares (no mint/burn in MVP) + +### 3.3 Roles (MVP) +- **Issuer**: initial creator of an asset (creates supply + initial allocation) +- **Holder**: owns shares, can transfer shares +- **Observer**: can read coordination messages (depending on channel policy) + +--- + +## 4) Offer & Transfer (MVP) + +This MVP supports a minimal peer-to-peer offer flow. + +### 4.1 Offer structure +An offer represents: “I will sell X shares for price P”. + +Fields: +- `offerId` (unique string) +- `assetId` +- `seller` +- `shares` +- `price` (string; unit is defined by the app; may be “offchain” in MVP) +- `expiresAt` (unix ms) +- `createdAt` (unix ms) + +### 4.2 Offer rules +- Only a seller who currently owns `>= shares` may create an offer +- Offers expire automatically after `expiresAt` +- Offers can be cancelled by the seller + +### 4.3 Accept rules +- Accepting an offer transfers shares: + - `seller -= shares` + - `buyer += shares` +- Settlement/payment is **out of scope** for the first MVP. + - The first version only proves ownership transitions + replication. + +--- + +## 5) Channel Naming (Sidechannels) + +Each asset gets a dedicated coordination channel. + +MVP naming: +- `asset:` + +Example: +- `asset:tc:poke:base:charizard:psa10:12345678` + +Notes: +- Later we may switch to hashed channel names for length/compatibility. +- Entry/rendezvous can remain `0000intercom` for discovery. + +--- + +## 6) Minimal Commands (Planned) + +These are the minimal operations the contract/protocol will expose: + +### Asset +- `createAsset(assetId, totalShares, initialOwner)` +- `getAsset(assetId)` + +### Ownership +- `transferShares(assetId, to, shares)` + +### Offers +- `createOffer(assetId, shares, price, expiresAt)` +- `cancelOffer(offerId)` +- `acceptOffer(offerId)` + +--- + +## 7) Non-goals (for now) + +Explicitly out of scope for MVP: +- fiat/crypto payments (settlement) +- custody / vault logistics / legal ownership enforcement +- disputes, arbitration +- fractional redemption / buyout +- pricing or oracle integrations + +--- + +## 8) Disclaimer + +This is an experimental prototype specification. +It does not constitute legal ownership, custody guarantees, or financial advice. From 3f29b4ccea6350cec35583cbbe2f02d3484b74ea Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:40:39 +0100 Subject: [PATCH 06/20] docs: add architecture --- docs/architecture.md | 125 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/architecture.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..911c3535 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,125 @@ +# Architecture: Fractional Trading Cards on Intercom + +This document explains how fractional ownership of trading cards is implemented using Intercom. + +The system maps each real-world card to its own peer-to-peer coordination environment. + +--- + +# Overview + +Each trading card becomes its own distributed asset system. + +There are three main layers: + +1. Sidechannel (communication layer) +2. Contract / Subnet (ownership state layer) +3. App / SC-Bridge (user interface layer) + +--- + +# Layer 1: Sidechannel + +Purpose: + +Real-time coordination between participants. + +Each card has its own channel: + +asset: + +Example: + +asset:tc:poke:base:charizard:psa10:12345678 + +Sidechannel is used for: + +- announcing offers +- coordination +- messaging +- governance (later) + +No ownership is stored here. + +--- + +# Layer 2: Contract / Subnet + +Purpose: + +This is the source of truth. + +The contract stores: + +- totalShares +- holders +- ownership changes + +This state is: + +- deterministic +- replicated across peers +- serverless + +Every peer has the same ownership state. + +--- + +# Layer 3: App / SC-Bridge + +Purpose: + +Interface between user and Intercom network. + +The app connects via SC-Bridge. + +The app allows users to: + +- view assets +- transfer shares +- create offers +- accept offers + +Users do not interact with Intercom directly. + +The app handles this. + +--- + +# Flow Example + +1. Asset created +2. Channel created +3. Ownership defined +4. User creates offer +5. Another user accepts offer +6. Ownership updates +7. State replicates to all peers + +--- + +# Diagram + +User App + ↓ +SC-Bridge + ↓ +Intercom Peer + ↓ +Contract State (ownership) + ↓ +Sidechannel (coordination) + +--- + +# Summary + +Intercom provides the network. + +Contracts provide ownership truth. + +Sidechannels provide coordination. + +Apps provide usability. + +This creates peer-to-peer fractional ownership. From 7ba543dbf739c4e8736320e477f41ddbf148b1e8 Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:48:05 +0100 Subject: [PATCH 07/20] scripts: add admin run script --- scripts/run-admin.sh | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 scripts/run-admin.sh diff --git a/scripts/run-admin.sh b/scripts/run-admin.sh new file mode 100644 index 00000000..cb0b52bf --- /dev/null +++ b/scripts/run-admin.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============ +# Admin Peer Runner (Intercom / Pear) +# Creates a new subnet/app and exposes SC-Bridge for local clients/apps. +# +# Requirements: +# - Node.js >= 22 +# - pear installed globally: npm i -g pear +# +# Usage: +# bash scripts/run-admin.sh +# +# After first run: +# - Copy the "Peer Writer" key (32-byte hex) from the output. +# You will need it as --subnet-bootstrap for joiners. +# ============ + +# Pick a stable subnet channel name for your dev app. +# Change this if you want a new "app instance". +SUBNET_CHANNEL="${SUBNET_CHANNEL:-card-rwa-dev}" + +# Local store names (folders under stores/). +PEER_STORE="${PEER_STORE:-admin}" +MSB_STORE="${MSB_STORE:-admin-msb}" + +# SC-Bridge +SC_BRIDGE_HOST="${SC_BRIDGE_HOST:-127.0.0.1}" +SC_BRIDGE_PORT="${SC_BRIDGE_PORT:-8787}" + +# Set a token for SC-Bridge authentication. +# IMPORTANT: Change this value before sharing anything publicly. +SC_BRIDGE_TOKEN="${SC_BRIDGE_TOKEN:-CHANGE_ME_TO_A_LONG_RANDOM_TOKEN}" + +echo "=== Intercom Admin Runner ===" +echo "Subnet channel: ${SUBNET_CHANNEL}" +echo "Peer store name: ${PEER_STORE}" +echo "MSB store name: ${MSB_STORE}" +echo "SC-Bridge endpoint: ws://${SC_BRIDGE_HOST}:${SC_BRIDGE_PORT}" +echo "SC-Bridge token set: ${SC_BRIDGE_TOKEN}" +echo "" +echo "Starting peer via Pear..." +echo "" + +pear run . \ + --peer-store-name "${PEER_STORE}" \ + --msb-store-name "${MSB_STORE}" \ + --subnet-channel "${SUBNET_CHANNEL}" \ + --sc-bridge 1 \ + --sc-bridge-host "${SC_BRIDGE_HOST}" \ + --sc-bridge-port "${SC_BRIDGE_PORT}" \ + --sc-bridge-token "${SC_BRIDGE_TOKEN}" From 926c0bdd27733b15e317f19e68003efb742b83a0 Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:49:31 +0100 Subject: [PATCH 08/20] scripts: add joiner run script --- scripts/run-joiner.sh | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 scripts/run-joiner.sh diff --git a/scripts/run-joiner.sh b/scripts/run-joiner.sh new file mode 100644 index 00000000..c899add8 --- /dev/null +++ b/scripts/run-joiner.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============ +# Joiner Peer Runner (Intercom / Pear) +# Joins an existing subnet/app created by the admin peer. +# +# Requirements: +# - Node.js >= 22 +# - pear installed globally: npm i -g pear +# +# Usage: +# SUBNET_BOOTSTRAP= bash scripts/run-joiner.sh +# +# Notes: +# - SUBNET_BOOTSTRAP is the 32-byte hex "Peer Writer" key shown by the admin peer. +# ============ + +# Must be provided (admin peer writer key hex) +SUBNET_BOOTSTRAP="${SUBNET_BOOTSTRAP:-}" + +if [[ -z "${SUBNET_BOOTSTRAP}" ]]; then + echo "ERROR: SUBNET_BOOTSTRAP is required." + echo "Example:" + echo " SUBNET_BOOTSTRAP= bash scripts/run-joiner.sh" + exit 1 +fi + +# Must match the admin's subnet channel +SUBNET_CHANNEL="${SUBNET_CHANNEL:-card-rwa-dev}" + +# Local store names (folders under stores/) +PEER_STORE="${PEER_STORE:-joiner1}" +MSB_STORE="${MSB_STORE:-joiner1-msb}" + +# SC-Bridge (optional for joiner; keep off by default) +SC_BRIDGE="${SC_BRIDGE:-0}" +SC_BRIDGE_HOST="${SC_BRIDGE_HOST:-127.0.0.1}" +SC_BRIDGE_PORT="${SC_BRIDGE_PORT:-8788}" +SC_BRIDGE_TOKEN="${SC_BRIDGE_TOKEN:-CHANGE_ME_TO_A_LONG_RANDOM_TOKEN}" + +echo "=== Intercom Joiner Runner ===" +echo "Subnet channel: ${SUBNET_CHANNEL}" +echo "Subnet bootstrap: ${SUBNET_BOOTSTRAP}" +echo "Peer store name: ${PEER_STORE}" +echo "MSB store name: ${MSB_STORE}" +echo "SC-Bridge enabled: ${SC_BRIDGE}" +if [[ "${SC_BRIDGE}" == "1" ]]; then + echo "SC-Bridge endpoint: ws://${SC_BRIDGE_HOST}:${SC_BRIDGE_PORT}" +fi +echo "" +echo "Starting peer via Pear..." +echo "" + +# Build command +CMD=(pear run . \ + --peer-store-name "${PEER_STORE}" \ + --msb-store-name "${MSB_STORE}" \ + --subnet-channel "${SUBNET_CHANNEL}" \ + --subnet-bootstrap "${SUBNET_BOOTSTRAP}" \ +) + +# Optional SC-Bridge for joiner (handy for multi-client demos) +if [[ "${SC_BRIDGE}" == "1" ]]; then + CMD+=(--sc-bridge 1 --sc-bridge-host "${SC_BRIDGE_HOST}" --sc-bridge-port "${SC_BRIDGE_PORT}" --sc-bridge-token "${SC_BRIDGE_TOKEN}") +fi + +"${CMD[@]}" From 1668991aee50978548030dc543045d09868e2ab1 Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:52:10 +0100 Subject: [PATCH 09/20] docs: add 2-peer demo --- examples/demo-2peers.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 examples/demo-2peers.md diff --git a/examples/demo-2peers.md b/examples/demo-2peers.md new file mode 100644 index 00000000..56aa5817 --- /dev/null +++ b/examples/demo-2peers.md @@ -0,0 +1,17 @@ +# Demo: Run 2 Peers (Admin + Joiner) + +This demo proves the base networking works: +- one **admin** peer creates the subnet/app +- one **joiner** peer joins using the admin writer key (bootstrap) + +> Important: Intercom must be run via **Pear** (not plain Node). + +--- + +## 0) Requirements + +- Node.js >= 22 +- Pear runtime: + ```bash + npm install -g pear + pear -v From aa036f2d9ca0baf25ac217dedccf77fd541a017a Mon Sep 17 00:00:00 2001 From: ordinalsog-ctrl Date: Sat, 28 Feb 2026 14:59:34 +0100 Subject: [PATCH 10/20] Update demo-2peers.md --- examples/demo-2peers.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/examples/demo-2peers.md b/examples/demo-2peers.md index 56aa5817..0c04ab25 100644 --- a/examples/demo-2peers.md +++ b/examples/demo-2peers.md @@ -1,17 +1,25 @@ # Demo: Run 2 Peers (Admin + Joiner) -This demo proves the base networking works: -- one **admin** peer creates the subnet/app -- one **joiner** peer joins using the admin writer key (bootstrap) +This demo proves that your Intercom fork runs correctly and that peers can connect to the same subnet. -> Important: Intercom must be run via **Pear** (not plain Node). +You will: + +- start one admin peer +- copy its writer key +- start one joiner peer + +This is the foundation for fractional asset ownership later. --- -## 0) Requirements +# Requirements + +You must do this later on your local computer, not on GitHub. + +Install Node.js 22 or newer. + +Install Pear runtime: -- Node.js >= 22 -- Pear runtime: - ```bash - npm install -g pear - pear -v +```bash +npm install -g pear +pear -v From 2e61bed22b66083be2c424d6a30a31d9069e97b6 Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 16:17:15 +0100 Subject: [PATCH 11/20] docs: add sidechannel message schema --- docs/message-schema.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/message-schema.md diff --git a/docs/message-schema.md b/docs/message-schema.md new file mode 100644 index 00000000..4f397c5b --- /dev/null +++ b/docs/message-schema.md @@ -0,0 +1,14 @@ +# Message Schema (Sidechannel) — Trading Card Assets + +This project uses Intercom sidechannels for real-time coordination. + +All sidechannel messages must be JSON objects with this structure: + +```json +{ + "v": 1, + "assetId": "tc:poke:base:charizard:psa10:12345678", + "type": "offer:create", + "ts": 0, + "data": {} +} From fd281d6829e629b4067859f7228df47151b9c2fe Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 17:47:17 +0100 Subject: [PATCH 12/20] examples: add working fractional offerbook demo with create/cancel/accept --- examples/bridge-client.js | 51 ++++++++++++++++++++ examples/offerbook-listen.js | 88 +++++++++++++++++++++++++++++++++++ examples/send-offer-accept.js | 55 ++++++++++++++++++++++ examples/send-offer-cancel.js | 55 ++++++++++++++++++++++ package-lock.json | 30 +++++++++++- package.json | 3 +- scripts/run-admin.sh | 30 ++---------- scripts/run-joiner.sh | 48 ++++--------------- 8 files changed, 295 insertions(+), 65 deletions(-) create mode 100644 examples/bridge-client.js create mode 100644 examples/offerbook-listen.js create mode 100644 examples/send-offer-accept.js create mode 100644 examples/send-offer-cancel.js mode change 100644 => 100755 scripts/run-admin.sh mode change 100644 => 100755 scripts/run-joiner.sh diff --git a/examples/bridge-client.js b/examples/bridge-client.js new file mode 100644 index 00000000..61ed94e2 --- /dev/null +++ b/examples/bridge-client.js @@ -0,0 +1,51 @@ +import WebSocket from 'ws'; +import crypto from 'crypto'; + +const WS_URL = "ws://127.0.0.1:8788"; // <-- JOINER SC-BRIDGE +const TOKEN = "CHANGE_ME_TO_A_LONG_RANDOM_TOKEN"; + +const ASSET_ID = "tc:poke:base:charizard:psa10:12345678"; +const CHANNEL = "asset:" + ASSET_ID; + +const offerId = crypto.randomBytes(8).toString("hex"); + +const ws = new WebSocket(WS_URL); + +ws.on("open", () => { + console.log("Connected (joiner bridge)"); + ws.send(JSON.stringify({ type: "auth", token: TOKEN })); +}); + +ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + console.log("Received:", msg); + + if (msg.type === "auth_ok") { + ws.send(JSON.stringify({ type: "join", channel: CHANNEL })); + return; + } + + if (msg.type === "joined") { + const event = { + v: 1, + assetId: ASSET_ID, + type: "offer:create", + ts: Date.now(), + data: { + offerId, + seller: "demo-seller", + shares: 100, + price: "100", + expiresAt: Date.now() + 3600000 + } + }; + + console.log("Sending offer:create event (from joiner)"); + + ws.send(JSON.stringify({ + type: "send", + channel: CHANNEL, + message: event + })); + } +}); diff --git a/examples/offerbook-listen.js b/examples/offerbook-listen.js new file mode 100644 index 00000000..2c86aa52 --- /dev/null +++ b/examples/offerbook-listen.js @@ -0,0 +1,88 @@ +import WebSocket from 'ws'; + +// === CONFIG === +const WS_URL = "ws://127.0.0.1:8787"; +const TOKEN = "CHANGE_ME_TO_A_LONG_RANDOM_TOKEN"; + +const ASSET_ID = "tc:poke:base:charizard:psa10:12345678"; +const CHANNEL = "asset:" + ASSET_ID; + +// In-memory offer book +const offers = new Map(); + +function printOffers() { + const list = Array.from(offers.values()).sort((a, b) => a.ts - b.ts); + console.log("\n=== OFFER BOOK ==="); + if (list.length === 0) { + console.log("(empty)"); + return; + } + for (const o of list) { + const d = o.data || {}; + console.log( + `offerId=${d.offerId} seller=${d.seller} shares=${d.shares} price=${d.price} expiresAt=${new Date(d.expiresAt).toISOString()}` + ); + if (d.acceptedBy) { + console.log(` acceptedBy=${d.acceptedBy} acceptedAt=${new Date(d.acceptedAt).toISOString()}`); + } + } +} + +const ws = new WebSocket(WS_URL); + +ws.on("open", () => { + console.log("Connected (offerbook listener)"); + ws.send(JSON.stringify({ type: "auth", token: TOKEN })); +}); + +ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + + if (msg.type === "auth_ok") { + ws.send(JSON.stringify({ type: "join", channel: CHANNEL })); + return; + } + + if (msg.type === "joined") { + console.log("Joined:", msg.channel); + console.log("Listening for offer events..."); + printOffers(); + return; + } + + // IMPORTANT: + // SC-Bridge forwards sidechannel messages as: + // { type:"sidechannel_message", channel, from, id, ts, message } + if (msg.type === "sidechannel_message") { + const event = msg.message; + + // Only handle our schema version + asset + if (!event || event.v !== 1 || event.assetId !== ASSET_ID) return; + + if (event.type === "offer:create") { + offers.set(event.data.offerId, event); + console.log("\n[offer:create]", event.data.offerId); + printOffers(); + } + + if (event.type === "offer:cancel") { + offers.delete(event.data.offerId); + console.log("\n[offer:cancel]", event.data.offerId); + printOffers(); + } + + if (event.type === "offer:accept") { + const existing = offers.get(event.data.offerId); + if (existing) { + existing.data.acceptedBy = event.data.buyer; + existing.data.acceptedAt = event.ts; + offers.set(event.data.offerId, existing); + } + console.log("\n[offer:accept]", event.data.offerId); + printOffers(); + } + } +}); + +ws.on("close", () => console.log("Disconnected")); +ws.on("error", (err) => console.error("Error:", err)); diff --git a/examples/send-offer-accept.js b/examples/send-offer-accept.js new file mode 100644 index 00000000..25dfd278 --- /dev/null +++ b/examples/send-offer-accept.js @@ -0,0 +1,55 @@ +import WebSocket from 'ws'; + +const WS_URL = "ws://127.0.0.1:8788"; // joiner bridge +const TOKEN = "CHANGE_ME_TO_A_LONG_RANDOM_TOKEN"; + +const ASSET_ID = "tc:poke:base:charizard:psa10:12345678"; +const CHANNEL = "asset:" + ASSET_ID; + +// USAGE: +// node examples/send-offer-accept.js +const offerId = process.argv[2]; +const buyer = process.argv[3] || "demo-buyer"; + +if (!offerId) { + console.error("Usage: node examples/send-offer-accept.js "); + process.exit(1); +} + +const ws = new WebSocket(WS_URL); + +ws.on("open", () => { + ws.send(JSON.stringify({ type: "auth", token: TOKEN })); +}); + +ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + + if (msg.type === "auth_ok") { + ws.send(JSON.stringify({ type: "join", channel: CHANNEL })); + return; + } + + if (msg.type === "joined") { + const event = { + v: 1, + assetId: ASSET_ID, + type: "offer:accept", + ts: Date.now(), + data: { + offerId, + buyer + } + }; + + console.log("Sending offer:accept", offerId, "buyer=", buyer); + + ws.send(JSON.stringify({ + type: "send", + channel: CHANNEL, + message: event + })); + + setTimeout(() => process.exit(0), 500); + } +}); diff --git a/examples/send-offer-cancel.js b/examples/send-offer-cancel.js new file mode 100644 index 00000000..a1df9add --- /dev/null +++ b/examples/send-offer-cancel.js @@ -0,0 +1,55 @@ +import WebSocket from 'ws'; + +const WS_URL = "ws://127.0.0.1:8788"; // joiner bridge +const TOKEN = "CHANGE_ME_TO_A_LONG_RANDOM_TOKEN"; + +const ASSET_ID = "tc:poke:base:charizard:psa10:12345678"; +const CHANNEL = "asset:" + ASSET_ID; + +// USAGE: +// node examples/send-offer-cancel.js +const offerId = process.argv[2]; + +if (!offerId) { + console.error("Usage: node examples/send-offer-cancel.js "); + process.exit(1); +} + +const ws = new WebSocket(WS_URL); + +ws.on("open", () => { + ws.send(JSON.stringify({ type: "auth", token: TOKEN })); +}); + +ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + + if (msg.type === "auth_ok") { + ws.send(JSON.stringify({ type: "join", channel: CHANNEL })); + return; + } + + if (msg.type === "joined") { + const event = { + v: 1, + assetId: ASSET_ID, + type: "offer:cancel", + ts: Date.now(), + data: { + offerId, + seller: "demo-seller" + } + }; + + console.log("Sending offer:cancel", offerId); + + ws.send(JSON.stringify({ + type: "send", + channel: CHANNEL, + message: event + })); + + // exit shortly after + setTimeout(() => process.exit(0), 500); + } +}); diff --git a/package-lock.json b/package-lock.json index 08897245..a0fde562 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "trac-msb": "git+https://github.com/Trac-Systems/main_settlement_bus.git#5088921", "trac-peer": "git+https://github.com/Trac-Systems/trac-peer.git#d108f52", "trac-wallet": "1.0.1", - "util": "npm:bare-node-util" + "util": "npm:bare-node-util", + "ws": "^8.19.0" } }, "node_modules/@ethereumjs/common": { @@ -2638,6 +2639,27 @@ "bare-worker": "*" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xache": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/xache/-/xache-1.2.1.tgz", @@ -4814,6 +4836,12 @@ "bare-worker": "*" } }, + "ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "requires": {} + }, "xache": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/xache/-/xache-1.2.1.tgz", diff --git a/package.json b/package.json index 5961dfd1..137efca7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "trac-msb": "git+https://github.com/Trac-Systems/main_settlement_bus.git#5088921", "trac-peer": "git+https://github.com/Trac-Systems/trac-peer.git#d108f52", "trac-wallet": "1.0.1", - "util": "npm:bare-node-util" + "util": "npm:bare-node-util", + "ws": "^8.19.0" }, "overrides": { "trac-wallet": "1.0.1" diff --git a/scripts/run-admin.sh b/scripts/run-admin.sh old mode 100644 new mode 100755 index cb0b52bf..0d5df10b --- a/scripts/run-admin.sh +++ b/scripts/run-admin.sh @@ -1,52 +1,32 @@ #!/usr/bin/env bash set -euo pipefail -# ============ -# Admin Peer Runner (Intercom / Pear) -# Creates a new subnet/app and exposes SC-Bridge for local clients/apps. -# -# Requirements: -# - Node.js >= 22 -# - pear installed globally: npm i -g pear -# -# Usage: -# bash scripts/run-admin.sh -# -# After first run: -# - Copy the "Peer Writer" key (32-byte hex) from the output. -# You will need it as --subnet-bootstrap for joiners. -# ============ - -# Pick a stable subnet channel name for your dev app. -# Change this if you want a new "app instance". SUBNET_CHANNEL="${SUBNET_CHANNEL:-card-rwa-dev}" - -# Local store names (folders under stores/). PEER_STORE="${PEER_STORE:-admin}" MSB_STORE="${MSB_STORE:-admin-msb}" +DHT_BOOTSTRAP="${DHT_BOOTSTRAP:-127.0.0.1:49737}" -# SC-Bridge SC_BRIDGE_HOST="${SC_BRIDGE_HOST:-127.0.0.1}" SC_BRIDGE_PORT="${SC_BRIDGE_PORT:-8787}" - -# Set a token for SC-Bridge authentication. -# IMPORTANT: Change this value before sharing anything publicly. SC_BRIDGE_TOKEN="${SC_BRIDGE_TOKEN:-CHANGE_ME_TO_A_LONG_RANDOM_TOKEN}" echo "=== Intercom Admin Runner ===" echo "Subnet channel: ${SUBNET_CHANNEL}" echo "Peer store name: ${PEER_STORE}" echo "MSB store name: ${MSB_STORE}" +echo "DHT bootstrap: ${DHT_BOOTSTRAP}" echo "SC-Bridge endpoint: ws://${SC_BRIDGE_HOST}:${SC_BRIDGE_PORT}" echo "SC-Bridge token set: ${SC_BRIDGE_TOKEN}" echo "" -echo "Starting peer via Pear..." +echo "DEV MODE: sidechannel welcome requirement is DISABLED" echo "" pear run . \ --peer-store-name "${PEER_STORE}" \ --msb-store-name "${MSB_STORE}" \ --subnet-channel "${SUBNET_CHANNEL}" \ + --dht-bootstrap "${DHT_BOOTSTRAP}" \ + --sidechannel-welcome-required 0 \ --sc-bridge 1 \ --sc-bridge-host "${SC_BRIDGE_HOST}" \ --sc-bridge-port "${SC_BRIDGE_PORT}" \ diff --git a/scripts/run-joiner.sh b/scripts/run-joiner.sh old mode 100644 new mode 100755 index c899add8..6ec33082 --- a/scripts/run-joiner.sh +++ b/scripts/run-joiner.sh @@ -1,24 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# ============ -# Joiner Peer Runner (Intercom / Pear) -# Joins an existing subnet/app created by the admin peer. -# -# Requirements: -# - Node.js >= 22 -# - pear installed globally: npm i -g pear -# -# Usage: -# SUBNET_BOOTSTRAP= bash scripts/run-joiner.sh -# -# Notes: -# - SUBNET_BOOTSTRAP is the 32-byte hex "Peer Writer" key shown by the admin peer. -# ============ - -# Must be provided (admin peer writer key hex) SUBNET_BOOTSTRAP="${SUBNET_BOOTSTRAP:-}" - if [[ -z "${SUBNET_BOOTSTRAP}" ]]; then echo "ERROR: SUBNET_BOOTSTRAP is required." echo "Example:" @@ -26,15 +9,12 @@ if [[ -z "${SUBNET_BOOTSTRAP}" ]]; then exit 1 fi -# Must match the admin's subnet channel SUBNET_CHANNEL="${SUBNET_CHANNEL:-card-rwa-dev}" - -# Local store names (folders under stores/) PEER_STORE="${PEER_STORE:-joiner1}" MSB_STORE="${MSB_STORE:-joiner1-msb}" +DHT_BOOTSTRAP="${DHT_BOOTSTRAP:-127.0.0.1:49737}" -# SC-Bridge (optional for joiner; keep off by default) -SC_BRIDGE="${SC_BRIDGE:-0}" +# Enable SC-Bridge on joiner (so we can SEND from joiner) SC_BRIDGE_HOST="${SC_BRIDGE_HOST:-127.0.0.1}" SC_BRIDGE_PORT="${SC_BRIDGE_PORT:-8788}" SC_BRIDGE_TOKEN="${SC_BRIDGE_TOKEN:-CHANGE_ME_TO_A_LONG_RANDOM_TOKEN}" @@ -44,25 +24,17 @@ echo "Subnet channel: ${SUBNET_CHANNEL}" echo "Subnet bootstrap: ${SUBNET_BOOTSTRAP}" echo "Peer store name: ${PEER_STORE}" echo "MSB store name: ${MSB_STORE}" -echo "SC-Bridge enabled: ${SC_BRIDGE}" -if [[ "${SC_BRIDGE}" == "1" ]]; then - echo "SC-Bridge endpoint: ws://${SC_BRIDGE_HOST}:${SC_BRIDGE_PORT}" -fi -echo "" -echo "Starting peer via Pear..." +echo "DHT bootstrap: ${DHT_BOOTSTRAP}" +echo "SC-Bridge: ws://${SC_BRIDGE_HOST}:${SC_BRIDGE_PORT}" echo "" -# Build command -CMD=(pear run . \ +pear run . \ --peer-store-name "${PEER_STORE}" \ --msb-store-name "${MSB_STORE}" \ --subnet-channel "${SUBNET_CHANNEL}" \ --subnet-bootstrap "${SUBNET_BOOTSTRAP}" \ -) - -# Optional SC-Bridge for joiner (handy for multi-client demos) -if [[ "${SC_BRIDGE}" == "1" ]]; then - CMD+=(--sc-bridge 1 --sc-bridge-host "${SC_BRIDGE_HOST}" --sc-bridge-port "${SC_BRIDGE_PORT}" --sc-bridge-token "${SC_BRIDGE_TOKEN}") -fi - -"${CMD[@]}" + --dht-bootstrap "${DHT_BOOTSTRAP}" \ + --sc-bridge 1 \ + --sc-bridge-host "${SC_BRIDGE_HOST}" \ + --sc-bridge-port "${SC_BRIDGE_PORT}" \ + --sc-bridge-token "${SC_BRIDGE_TOKEN}" From aec54f96ddcb09927d06142d8f7dd0f7904bae69 Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 18:08:09 +0100 Subject: [PATCH 13/20] contract: add fractional ownership state + tx protocol mapping --- contract.js | 106 ++++++++++++++++++++++++++++++++++++++++++ protocol.js | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 contract.js create mode 100644 protocol.js diff --git a/contract.js b/contract.js new file mode 100644 index 00000000..2cc95f27 --- /dev/null +++ b/contract.js @@ -0,0 +1,106 @@ +/** + * Fractional Trading Card Ownership Contract (MVP) + * + * Deterministic replicated state: + * - assets[assetId] = { assetId, totalShares, createdAt } + * - holders[assetId][address] = shares + * + * Commands: + * - create_asset + * - transfer_shares + * - read_asset + * - read_holders + * + * Notes: + * - Minimal prototype. No settlement. + * - Compatible with Protocol.mapTxCommand() which provides { type, value }. + */ + +export default function contract (state = {}, message = {}) { + state.assets ??= {}; + state.holders ??= {}; + + const { type, from } = message; + + // IMPORTANT: tx protocol may deliver payload as message.value + const args = message.args ?? message.value ?? {}; + + const asInt = (v) => { + const n = Number(v); + if (!Number.isFinite(n) || !Number.isInteger(n)) throw new Error("INVALID_INT"); + return n; + }; + + const ensureAsset = (assetId) => { + const a = state.assets[assetId]; + if (!a) throw new Error("ASSET_NOT_FOUND"); + state.holders[assetId] ??= {}; + return a; + }; + + if (type === "create_asset") { + const assetId = String(args.assetId || "").trim(); + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + + const totalShares = asInt(args.totalShares); + if (totalShares <= 0) throw new Error("TOTAL_SHARES_INVALID"); + + const initialOwner = String(args.initialOwner || from || "").trim(); + if (!initialOwner) throw new Error("INITIAL_OWNER_REQUIRED"); + + if (state.assets[assetId]) throw new Error("ASSET_ALREADY_EXISTS"); + + state.assets[assetId] = { + assetId, + totalShares, + createdAt: Date.now() + }; + + state.holders[assetId] = { + [initialOwner]: totalShares + }; + + return state; + } + + if (type === "transfer_shares") { + const assetId = String(args.assetId || "").trim(); + const to = String(args.to || "").trim(); + const shares = asInt(args.shares); + + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + if (!to) throw new Error("TO_REQUIRED"); + if (shares <= 0) throw new Error("SHARES_INVALID"); + + ensureAsset(assetId); + + const fromAddr = String(from || "").trim(); + if (!fromAddr) throw new Error("FROM_REQUIRED"); + + const holders = (state.holders[assetId] ??= {}); + const fromBal = asInt(holders[fromAddr] || 0); + if (fromBal < shares) throw new Error("INSUFFICIENT_SHARES"); + + holders[fromAddr] = fromBal - shares; + holders[to] = asInt(holders[to] || 0) + shares; + + if (holders[fromAddr] === 0) delete holders[fromAddr]; + return state; + } + + if (type === "read_asset") { + const assetId = String(args.assetId || "").trim(); + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + const asset = state.assets[assetId] || null; + return { asset }; + } + + if (type === "read_holders") { + const assetId = String(args.assetId || "").trim(); + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + const holders = state.holders[assetId] || {}; + return { holders }; + } + + return state; +} diff --git a/protocol.js b/protocol.js new file mode 100644 index 00000000..47e5533c --- /dev/null +++ b/protocol.js @@ -0,0 +1,130 @@ +import { Protocol } from "trac-peer"; + +/** + * Fractional Trading Cards Protocol (MVP) + * + * Purpose: + * - Map `/tx --command ""` into contract messages: { type, value } + * + * Supported commands: + * - create_asset [initialOwner] + * - transfer_shares + * - read_asset + * - read_holders + * + * Also supports JSON form: + * /tx --command '{"op":"create_asset","assetId":"...","totalShares":10000,"initialOwner":"..."}' + */ + +class FractionalProtocol extends Protocol { + constructor(peer, base, options = {}) { + super(peer, base, options); + } + + async extendApi() { + // Keep empty for now (we'll add convenience helpers later). + } + + _safeJsonParse(text) { + try { + return JSON.parse(text); + } catch (_e) { + return null; + } + } + + mapTxCommand(command) { + const raw = String(command ?? "").trim(); + if (!raw) return null; + + // 1) JSON form + if (raw.startsWith("{")) { + const json = this._safeJsonParse(raw); + if (!json || !json.op) return null; + + if (json.op === "create_asset") { + return { + type: "create_asset", + value: { + assetId: json.assetId, + totalShares: json.totalShares, + initialOwner: json.initialOwner + } + }; + } + + if (json.op === "transfer_shares") { + return { + type: "transfer_shares", + value: { + assetId: json.assetId, + to: json.to, + shares: json.shares + } + }; + } + + if (json.op === "read_asset") { + return { type: "read_asset", value: { assetId: json.assetId } }; + } + + if (json.op === "read_holders") { + return { type: "read_holders", value: { assetId: json.assetId } }; + } + + return null; + } + + // 2) Plain string form + const parts = raw.split(/\s+/); + const op = parts[0]; + + if (op === "create_asset") { + // create_asset [initialOwner] + const assetId = parts[1]; + const totalShares = parts[2]; + const initialOwner = parts[3]; // optional + return { + type: "create_asset", + value: { assetId, totalShares: Number(totalShares), initialOwner } + }; + } + + if (op === "transfer_shares") { + // transfer_shares + const assetId = parts[1]; + const to = parts[2]; + const shares = parts[3]; + return { + type: "transfer_shares", + value: { assetId, to, shares: Number(shares) } + }; + } + + if (op === "read_asset") { + const assetId = parts[1]; + return { type: "read_asset", value: { assetId } }; + } + + if (op === "read_holders") { + const assetId = parts[1]; + return { type: "read_holders", value: { assetId } }; + } + + return null; + } + + async printOptions() { + console.log(" "); + console.log("- Fractional Ownership Commands (Contract TX):"); + console.log('- /tx --command "create_asset [initialOwner]"'); + console.log('- /tx --command "transfer_shares "'); + console.log('- /tx --command "read_asset "'); + console.log('- /tx --command "read_holders "'); + console.log(" "); + console.log("- JSON Form:"); + console.log(`- /tx --command '{"op":"create_asset","assetId":"tc:...","totalShares":10000,"initialOwner":"trac1..."}'`); + } +} + +export default FractionalProtocol; From 7383cb6829946346d359653346c53c42387e5dae Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 18:27:21 +0100 Subject: [PATCH 14/20] contract: use fractional protocol mapping for /tx commands --- contract/protocol.js | 661 +++++-------------------------------------- 1 file changed, 76 insertions(+), 585 deletions(-) diff --git a/contract/protocol.js b/contract/protocol.js index 7345bdab..db809751 100644 --- a/contract/protocol.js +++ b/contract/protocol.js @@ -1,599 +1,90 @@ -import {Protocol} from "trac-peer"; -import { bufferToBigInt, bigIntToDecimalString } from "trac-msb/src/utils/amountSerialization.js"; -import b4a from "b4a"; -import PeerWallet from "trac-wallet"; -import fs from "fs"; - -const stableStringify = (value) => { - if (value === null || value === undefined) return 'null'; - if (typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) { - return `[${value.map(stableStringify).join(',')}]`; - } - const keys = Object.keys(value).sort(); - return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; -}; - -const normalizeInvitePayload = (payload) => { - return { - channel: String(payload?.channel ?? ''), - inviteePubKey: String(payload?.inviteePubKey ?? '').trim().toLowerCase(), - inviterPubKey: String(payload?.inviterPubKey ?? '').trim().toLowerCase(), - inviterAddress: payload?.inviterAddress ?? null, - issuedAt: Number(payload?.issuedAt), - expiresAt: Number(payload?.expiresAt), - nonce: String(payload?.nonce ?? ''), - version: Number.isFinite(payload?.version) ? Number(payload.version) : 1, - }; -}; - -const normalizeWelcomePayload = (payload) => { - return { - channel: String(payload?.channel ?? ''), - ownerPubKey: String(payload?.ownerPubKey ?? '').trim().toLowerCase(), - text: String(payload?.text ?? ''), - issuedAt: Number(payload?.issuedAt), - version: Number.isFinite(payload?.version) ? Number(payload.version) : 1, - }; -}; - -const parseInviteArg = (raw) => { +import { Protocol } from "trac-peer"; + +/** + * Fractional Trading Cards Protocol (MVP) + * + * Maps /tx --command "" into contract messages: { type, value } + * + * Supported: + * - create_asset [initialOwner] + * - transfer_shares + * - read_asset + * - read_holders + * + * Also supports JSON: + * /tx --command '{"op":"create_asset","assetId":"...","totalShares":10000,"initialOwner":"..."}' + */ + +export default class FractionalProtocol extends Protocol { + _safeJsonParse(text) { + try { return JSON.parse(text); } catch { return null; } + } + + mapTxCommand(command) { + const raw = String(command ?? "").trim(); if (!raw) return null; - let text = String(raw || '').trim(); - if (!text) return null; - if (text.startsWith('@')) { - try { - text = fs.readFileSync(text.slice(1), 'utf8').trim(); - } catch (_e) { - return null; - } - } - if (text.startsWith('b64:')) text = text.slice(4); - if (text.startsWith('{')) { - try { - return JSON.parse(text); - } catch (_e) {} - } - try { - const decoded = b4a.toString(b4a.from(text, 'base64')); - return JSON.parse(decoded); - } catch (_e) {} - return null; -}; -const parseWelcomeArg = (raw) => { - if (!raw) return null; - let text = String(raw || '').trim(); - if (!text) return null; - if (text.startsWith('@')) { - try { - text = fs.readFileSync(text.slice(1), 'utf8').trim(); - } catch (_e) { - return null; - } + // JSON form + if (raw.startsWith("{")) { + const json = this._safeJsonParse(raw); + if (!json || typeof json !== "object" || !json.op) return null; + + if (json.op === "create_asset") { + return { type: "create_asset", value: { assetId: json.assetId, totalShares: json.totalShares, initialOwner: json.initialOwner } }; + } + if (json.op === "transfer_shares") { + return { type: "transfer_shares", value: { assetId: json.assetId, to: json.to, shares: json.shares } }; + } + if (json.op === "read_asset") { + return { type: "read_asset", value: { assetId: json.assetId } }; + } + if (json.op === "read_holders") { + return { type: "read_holders", value: { assetId: json.assetId } }; + } + + return null; } - if (text.startsWith('b64:')) text = text.slice(4); - if (text.startsWith('{')) { - try { - return JSON.parse(text); - } catch (_e) {} - } - try { - const decoded = b4a.toString(b4a.from(text, 'base64')); - return JSON.parse(decoded); - } catch (_e) {} - return null; -}; -class SampleProtocol extends Protocol{ + // Plain string form + const parts = raw.split(/\s+/); + const op = parts[0]; - /** - * Extending from Protocol inherits its capabilities and allows you to define your own protocol. - * The protocol supports the corresponding contract. Both files come in pairs. - * - * Instances of this class do NOT run in contract context. The constructor is only called once on Protocol - * instantiation. - * - * this.peer: an instance of the entire Peer class, the actual node that runs the contract and everything else. - * this.base: the database engine, provides await this.base.view.get('key') to get unsigned data (not finalized data). - * this.options: the option stack passed from Peer instance. - * - * @param peer - * @param base - * @param options - */ - constructor(peer, base, options = {}) { - // calling super and passing all parameters is required. - super(peer, base, options); + if (op === "create_asset") { + const assetId = parts[1]; + const totalShares = Number(parts[2]); + const initialOwner = parts[3]; // optional + return { type: "create_asset", value: { assetId, totalShares, initialOwner } }; } - /** - * The Protocol superclass ProtocolApi instance already provides numerous api functions. - * You can extend the built-in api based on your protocol requirements. - * - * @returns {Promise} - */ - async extendApi(){ - this.api.getSampleData = function(){ - return 'Some sample data'; - } + if (op === "transfer_shares") { + const assetId = parts[1]; + const to = parts[2]; + const shares = Number(parts[3]); + return { type: "transfer_shares", value: { assetId, to, shares } }; } - /** - * In order for a transaction to successfully trigger, - * you need to create a mapping for the incoming tx command, - * pointing at the contract function to execute. - * - * You can perform basic sanitization here, but do not use it to protect contract execution. - * Instead, use the built-in schema support for in-contract sanitization instead - * (Contract.addSchema() in contract constructor). - * - * @param command - * @returns {{type: string, value: *}|null} - */ - mapTxCommand(command){ - // prepare the payload - let obj = { type : '', value : null }; - /* - Triggering contract function in terminal will look like this: - - /tx --command 'something' - - You can also simulate a tx prior broadcast - - /tx --command 'something' --sim 1 - - To programmatically execute a transaction from "outside", - the api function "this.api.tx()" needs to be exposed by adding - "api_tx_exposed : true" to the Peer instance options. - Once exposed, it can be used directly through peer.protocol_instance.api.tx() - - Please study the superclass of this Protocol and Protocol.api to learn more. - */ - if(command === 'something'){ - // type points at the "storeSomething" function in the contract. - obj.type = 'storeSomething'; - // value can be null as there is no other payload, but the property must exist. - obj.value = null; - // return the payload to be used in your contract - return obj; - } else if (command === 'read_snapshot') { - obj.type = 'readSnapshot'; - obj.value = null; - return obj; - } else if (command === 'read_chat_last') { - obj.type = 'readChatLast'; - obj.value = null; - return obj; - } else if (command === 'read_timer') { - obj.type = 'readTimer'; - obj.value = null; - return obj; - } else { - /* - now we assume our protocol allows to submit a json string with information - what to do (the op) then we pass the parsed object to the value. - the accepted json string can be executed as tx like this: - - /tx --command '{ "op" : "do_something", "some_key" : "some_data" }' - - Of course we can simulate this, as well: - - /tx --command '{ "op" : "do_something", "some_key" : "some_data" }' --sim 1 - */ - const json = this.safeJsonParse(command); - if(json.op !== undefined && json.op === 'do_something'){ - obj.type = 'submitSomething'; - obj.value = json; - return obj; - } else if (json.op !== undefined && json.op === 'read_key') { - obj.type = 'readKey'; - obj.value = json; - return obj; - } else if (json.op !== undefined && json.op === 'read_chat_last') { - obj.type = 'readChatLast'; - obj.value = null; - return obj; - } else if (json.op !== undefined && json.op === 'read_timer') { - obj.type = 'readTimer'; - obj.value = null; - return obj; - } - } - // return null if no case matches. - // if you do not return null, your protocol might behave unexpected. - return null; + if (op === "read_asset") { + const assetId = parts[1]; + return { type: "read_asset", value: { assetId } }; } - /** - * Prints additional options for your protocol underneath the system ones in terminal. - * - * @returns {Promise} - */ - async printOptions(){ - console.log(' '); - console.log('- Sample Commands:'); - console.log("- /print | use this flag to print some text to the terminal: '--text \"I am printing\""); - console.log('- /get --key "" [--confirmed true|false] | reads subnet state key (confirmed defaults to true).'); - console.log('- /msb | prints MSB txv + lengths (local MSB node view).'); - console.log('- /tx --command "read_chat_last" | prints last chat message captured by contract.'); - console.log('- /tx --command "read_timer" | prints current timer feature value.'); - console.log('- /sc_join --channel "" | join an ephemeral sidechannel (no autobase).'); - console.log('- /sc_open --channel "" [--via ""] [--invite ] [--welcome ] | request others to open a sidechannel.'); - console.log('- /sc_send --channel "" --message "" [--invite ] | send message over sidechannel.'); - console.log('- /sc_invite --channel "" --pubkey "" [--ttl ] [--welcome ] | create a signed invite.'); - console.log('- /sc_welcome --channel "" --text "" | create a signed welcome.'); - console.log('- /sc_stats | show sidechannel channels + connection count.'); - // further protocol specific options go here + if (op === "read_holders") { + const assetId = parts[1]; + return { type: "read_holders", value: { assetId } }; } - /** - * Extend the terminal system commands and execute your custom ones for your protocol. - * This is not transaction execution itself (though can be used for it based on your requirements). - * For transactions, use the built-in /tx command in combination with command mapping (see above) - * - * @param input - * @returns {Promise} - */ - async customCommand(input) { - await super.tokenizeInput(input); - if (this.input.startsWith("/get")) { - const m = input.match(/(?:^|\s)--key(?:=|\s+)(\"[^\"]+\"|'[^']+'|\S+)/); - const raw = m ? m[1].trim() : null; - if (!raw) { - console.log('Usage: /get --key "" [--confirmed true|false] [--unconfirmed 1]'); - return; - } - const key = raw.replace(/^\"(.*)\"$/, "$1").replace(/^'(.*)'$/, "$1"); - const confirmedMatch = input.match(/(?:^|\s)--confirmed(?:=|\s+)(\S+)/); - const unconfirmedMatch = input.match(/(?:^|\s)--unconfirmed(?:=|\s+)?(\S+)?/); - const confirmed = unconfirmedMatch ? false : confirmedMatch ? confirmedMatch[1] === "true" || confirmedMatch[1] === "1" : true; - const v = confirmed ? await this.getSigned(key) : await this.get(key); - console.log(v); - return; - } - if (this.input.startsWith("/msb")) { - const txv = await this.peer.msbClient.getTxvHex(); - const peerMsbAddress = this.peer.msbClient.pubKeyHexToAddress(this.peer.wallet.publicKey); - const entry = await this.peer.msbClient.getNodeEntryUnsigned(peerMsbAddress); - const balance = entry?.balance ? bigIntToDecimalString(bufferToBigInt(entry.balance)) : 0; - const feeBuf = this.peer.msbClient.getFee(); - const fee = feeBuf ? bigIntToDecimalString(bufferToBigInt(feeBuf)) : 0; - const validators = this.peer.msbClient.getConnectedValidatorsCount(); - console.log({ - networkId: this.peer.msbClient.networkId, - msbBootstrap: this.peer.msbClient.bootstrapHex, - txv, - msbSignedLength: this.peer.msbClient.getSignedLength(), - msbUnsignedLength: this.peer.msbClient.getUnsignedLength(), - connectedValidators: validators, - peerMsbAddress, - peerMsbBalance: balance, - msbFee: fee, - }); - return; - } - if (this.input.startsWith("/sc_join")) { - const args = this.parseArgs(input); - const name = args.channel || args.ch || args.name; - const inviteArg = args.invite || args.invite_b64 || args.invitebase64; - const welcomeArg = args.welcome || args.welcome_b64 || args.welcomebase64; - if (!name) { - console.log('Usage: /sc_join --channel "" [--invite ] [--welcome ]'); - return; - } - if (!this.peer.sidechannel) { - console.log('Sidechannel not initialized.'); - return; - } - let invite = null; - if (inviteArg) { - invite = parseInviteArg(inviteArg); - if (!invite) { - console.log('Invalid invite. Pass JSON, base64, or @file.'); - return; - } - } - let welcome = null; - if (welcomeArg) { - welcome = parseWelcomeArg(welcomeArg); - if (!welcome) { - console.log('Invalid welcome. Pass JSON, base64, or @file.'); - return; - } - } - if (invite || welcome) { - this.peer.sidechannel.acceptInvite(String(name), invite, welcome); - } - const ok = await this.peer.sidechannel.addChannel(String(name)); - if (!ok) { - console.log('Join denied (invite required or invalid).'); - return; - } - console.log('Joined sidechannel:', name); - return; - } - if (this.input.startsWith("/sc_send")) { - const args = this.parseArgs(input); - const name = args.channel || args.ch || args.name; - const message = args.message || args.msg; - const inviteArg = args.invite || args.invite_b64 || args.invitebase64; - const welcomeArg = args.welcome || args.welcome_b64 || args.welcomebase64; - if (!name || message === undefined) { - console.log('Usage: /sc_send --channel "" --message "" [--invite ] [--welcome ]'); - return; - } - if (!this.peer.sidechannel) { - console.log('Sidechannel not initialized.'); - return; - } - let invite = null; - if (inviteArg) { - invite = parseInviteArg(inviteArg); - if (!invite) { - console.log('Invalid invite. Pass JSON, base64, or @file.'); - return; - } - } - let welcome = null; - if (welcomeArg) { - welcome = parseWelcomeArg(welcomeArg); - if (!welcome) { - console.log('Invalid welcome. Pass JSON, base64, or @file.'); - return; - } - } - if (invite || welcome) { - this.peer.sidechannel.acceptInvite(String(name), invite, welcome); - } - const ok = await this.peer.sidechannel.addChannel(String(name)); - if (!ok) { - console.log('Send denied (invite required or invalid).'); - return; - } - const sent = this.peer.sidechannel.broadcast(String(name), message, invite ? { invite } : undefined); - if (!sent) { - console.log('Send denied (owner-only or invite required).'); - } - return; - } - if (this.input.startsWith("/sc_open")) { - const args = this.parseArgs(input); - const name = args.channel || args.ch || args.name; - const via = args.via || args.channel_via; - const inviteArg = args.invite || args.invite_b64 || args.invitebase64; - const welcomeArg = args.welcome || args.welcome_b64 || args.welcomebase64; - if (!name) { - console.log('Usage: /sc_open --channel "" [--via ""] [--invite ] [--welcome ]'); - return; - } - if (!this.peer.sidechannel) { - console.log('Sidechannel not initialized.'); - return; - } - let invite = null; - if (inviteArg) { - invite = parseInviteArg(inviteArg); - if (!invite) { - console.log('Invalid invite. Pass JSON, base64, or @file.'); - return; - } - } - let welcome = null; - if (welcomeArg) { - welcome = parseWelcomeArg(welcomeArg); - if (!welcome) { - console.log('Invalid welcome. Pass JSON, base64, or @file.'); - return; - } - } else if (typeof this.peer.sidechannel.getWelcome === 'function') { - welcome = this.peer.sidechannel.getWelcome(String(name)); - } - const viaChannel = via || this.peer.sidechannel.entryChannel || null; - if (!viaChannel) { - console.log('No entry channel configured. Pass --via "".'); - return; - } - this.peer.sidechannel.requestOpen(String(name), String(viaChannel), invite, welcome); - console.log('Requested channel:', name); - return; - } - if (this.input.startsWith("/sc_invite")) { - const args = this.parseArgs(input); - const channel = args.channel || args.ch || args.name; - const invitee = args.pubkey || args.invitee || args.peer || args.key; - const ttlRaw = args.ttl || args.ttl_sec || args.ttl_s; - const welcomeArg = args.welcome || args.welcome_b64 || args.welcomebase64; - if (!channel || !invitee) { - console.log('Usage: /sc_invite --channel "" --pubkey "" [--ttl ] [--welcome ]'); - return; - } - if (!this.peer.sidechannel) { - console.log('Sidechannel not initialized.'); - return; - } - if (this.peer?.wallet?.ready) { - try { - await this.peer.wallet.ready; - } catch (_e) {} - } - const walletPub = this.peer?.wallet?.publicKey; - const inviterPubKey = walletPub - ? typeof walletPub === 'string' - ? walletPub.trim().toLowerCase() - : b4a.toString(walletPub, 'hex') - : null; - if (!inviterPubKey) { - console.log('Wallet not ready; cannot sign invite.'); - return; - } - let inviterAddress = null; - try { - if (this.peer?.msbClient) { - inviterAddress = this.peer.msbClient.pubKeyHexToAddress(inviterPubKey); - } - } catch (_e) {} - const issuedAt = Date.now(); - let ttlMs = null; - if (ttlRaw !== undefined) { - const ttlSec = Number.parseInt(String(ttlRaw), 10); - ttlMs = Number.isFinite(ttlSec) ? Math.max(ttlSec, 0) * 1000 : null; - } else if (Number.isFinite(this.peer.sidechannel.inviteTtlMs) && this.peer.sidechannel.inviteTtlMs > 0) { - ttlMs = this.peer.sidechannel.inviteTtlMs; - } else { - ttlMs = 0; - } - if (!ttlMs || ttlMs <= 0) { - console.log('Invite TTL is required. Pass --ttl or set --sidechannel-invite-ttl.'); - return; - } - const expiresAt = issuedAt + ttlMs; - const payload = normalizeInvitePayload({ - channel: String(channel), - inviteePubKey: String(invitee).trim().toLowerCase(), - inviterPubKey, - inviterAddress, - issuedAt, - expiresAt, - nonce: Math.random().toString(36).slice(2, 10), - version: 1, - }); - const message = stableStringify(payload); - const msgBuf = b4a.from(message); - let sig = this.peer.wallet.sign(msgBuf); - let sigHex = ''; - if (typeof sig === 'string') { - sigHex = sig; - } else if (sig && sig.length > 0) { - sigHex = b4a.toString(sig, 'hex'); - } - if (!sigHex) { - const walletSecret = this.peer?.wallet?.secretKey; - const secretBuf = walletSecret - ? b4a.isBuffer(walletSecret) - ? walletSecret - : typeof walletSecret === 'string' - ? b4a.from(walletSecret, 'hex') - : b4a.from(walletSecret) - : null; - if (secretBuf) { - const sigBuf = PeerWallet.sign(msgBuf, secretBuf); - if (sigBuf && sigBuf.length > 0) { - sigHex = b4a.toString(sigBuf, 'hex'); - } - } - } - let welcome = null; - if (welcomeArg) { - welcome = parseWelcomeArg(welcomeArg); - if (!welcome) { - console.log('Invalid welcome. Pass JSON, base64, or @file.'); - return; - } - } else if (typeof this.peer.sidechannel.getWelcome === 'function') { - welcome = this.peer.sidechannel.getWelcome(String(channel)); - } - const invite = { payload, sig: sigHex, welcome: welcome || undefined }; - const inviteJson = JSON.stringify(invite); - const inviteB64 = b4a.toString(b4a.from(inviteJson), 'base64'); - if (!sigHex) { - console.log('Failed to sign invite; wallet secret key unavailable.'); - return; - } - console.log(inviteJson); - console.log('invite_b64:', inviteB64); - return; - } - if (this.input.startsWith("/sc_welcome")) { - const args = this.parseArgs(input); - const channel = args.channel || args.ch || args.name; - const text = args.text || args.message || args.msg; - if (!channel || text === undefined) { - console.log('Usage: /sc_welcome --channel "" --text ""'); - return; - } - if (!this.peer.sidechannel) { - console.log('Sidechannel not initialized.'); - return; - } - if (this.peer?.wallet?.ready) { - try { - await this.peer.wallet.ready; - } catch (_e) {} - } - const walletPub = this.peer?.wallet?.publicKey; - const ownerPubKey = walletPub - ? typeof walletPub === 'string' - ? walletPub.trim().toLowerCase() - : b4a.toString(walletPub, 'hex') - : null; - if (!ownerPubKey) { - console.log('Wallet not ready; cannot sign welcome.'); - return; - } - const payload = normalizeWelcomePayload({ - channel: String(channel), - ownerPubKey, - text: String(text), - issuedAt: Date.now(), - version: 1, - }); - const message = stableStringify(payload); - const msgBuf = b4a.from(message); - let sig = this.peer.wallet.sign(msgBuf); - let sigHex = ''; - if (typeof sig === 'string') { - sigHex = sig; - } else if (sig && sig.length > 0) { - sigHex = b4a.toString(sig, 'hex'); - } - if (!sigHex) { - const walletSecret = this.peer?.wallet?.secretKey; - const secretBuf = walletSecret - ? b4a.isBuffer(walletSecret) - ? walletSecret - : typeof walletSecret === 'string' - ? b4a.from(walletSecret, 'hex') - : b4a.from(walletSecret) - : null; - if (secretBuf) { - const sigBuf = PeerWallet.sign(msgBuf, secretBuf); - if (sigBuf && sigBuf.length > 0) { - sigHex = b4a.toString(sigBuf, 'hex'); - } - } - } - if (!sigHex) { - console.log('Failed to sign welcome; wallet secret key unavailable.'); - return; - } - const welcome = { payload, sig: sigHex }; - // Store the welcome in-memory so the owner peer can auto-send it to new connections - // without requiring a restart (and so /sc_invite can embed it by default). - try { - this.peer.sidechannel.acceptInvite(String(channel), null, welcome); - } catch (_e) {} - const welcomeJson = JSON.stringify(welcome); - const welcomeB64 = b4a.toString(b4a.from(welcomeJson), 'base64'); - console.log(welcomeJson); - console.log('welcome_b64:', welcomeB64); - return; - } - if (this.input.startsWith("/sc_stats")) { - if (!this.peer.sidechannel) { - console.log('Sidechannel not initialized.'); - return; - } - const channels = Array.from(this.peer.sidechannel.channels.keys()); - const connectionCount = this.peer.sidechannel.connections.size; - console.log({ channels, connectionCount }); - return; - } - if (this.input.startsWith("/print")) { - const splitted = this.parseArgs(input); - console.log(splitted.text); - } - } + return null; + } + + async printOptions() { + console.log(" "); + console.log("- Fractional Ownership Commands (Contract TX):"); + console.log('- /tx --command "create_asset [initialOwner]"'); + console.log('- /tx --command "transfer_shares "'); + console.log('- /tx --command "read_asset "'); + console.log('- /tx --command "read_holders "'); + console.log(" "); + console.log("- JSON Form:"); + console.log(`- /tx --command '{"op":"create_asset","assetId":"tc:...","totalShares":10000,"initialOwner":"trac1..."}'`); + } } - -export default SampleProtocol; From 078439d43773c867879ba7cd2baca6ec9f5f83f7 Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 18:34:00 +0100 Subject: [PATCH 15/20] contract: add fractional ownership state (assets + holders) --- contract/contract.js | 345 +++++++++++++------------------------------ 1 file changed, 106 insertions(+), 239 deletions(-) diff --git a/contract/contract.js b/contract/contract.js index f661e5fc..c133768f 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -1,240 +1,107 @@ -import {Contract} from 'trac-peer' - -class SampleContract extends Contract { - /** - * Extending from Contract inherits its capabilities and allows you to define your own contract. - * The contract supports the corresponding protocol. Both files come in pairs. - * - * Instances of this class run in contract context. The constructor is only called once on Peer - * instantiation. - * - * Please avoid using the following in your contract functions: - * - * No try-catch - * No throws - * No random values - * No http / api calls - * No super complex, costly calculations - * No massive storage of data. - * Never, ever modify "this.op" or "this.value", only read from it and use safeClone to modify. - * ... basically nothing that can lead to inconsistencies akin to Blockchain smart contracts. - * - * Running a contract on Trac gives you a lot of freedom, but it comes with additional responsibility. - * Make sure to benchmark your contract performance before release. - * - * If you need to inject data from "outside", you can utilize the Feature class and create your own - * oracles. Instances of Feature can be injected into the main Peer instance and enrich your contract. - * - * In the current version (Release 1), there is no inter-contract communication yet. - * This means it's not suitable yet for token standards. - * However, it's perfectly equipped for interoperability or standalone tasks. - * - * this.protocol: the peer's instance of the protocol managing contract concerns outside of its execution. - * this.options: the option stack passed from Peer instance - * - * @param protocol - * @param options - */ - constructor(protocol, options = {}) { - // calling super and passing all parameters is required. - super(protocol, options); - - // simple function registration. - // since this function does not expect value payload, no need to sanitize. - // note that the function must match the type as set in Protocol.mapTxCommand() - this.addFunction('storeSomething'); - - // now we register the function with a schema to prevent malicious inputs. - // the contract uses the schema generator "fastest-validator" and can be found on npmjs.org. - // - // Since this is the "value" as of Protocol.mapTxCommand(), we must take it full into account. - // $$strict : true tells the validator for the object structure to be precise after "value". - // - // note that the function must match the type as set in Protocol.mapTxCommand() - this.addSchema('submitSomething', { - value : { - $$strict : true, - $$type: "object", - op : { type : "string", min : 1, max: 128 }, - some_key : { type : "string", min : 1, max: 128 } - } - }); - - // in preparation to add an external Feature (aka oracle), we add a loose schema to make sure - // the Feature key is given properly. it's not required, but showcases that even these can be - // sanitized. - this.addSchema('feature_entry', { - key : { type : "string", min : 1, max: 256 }, - value : { type : "any" } - }); - - // read helpers (no state writes) - this.addFunction('readSnapshot'); - this.addFunction('readChatLast'); - this.addFunction('readTimer'); - this.addSchema('readKey', { - value : { - $$strict : true, - $$type: "object", - op : { type : "string", min : 1, max: 128 }, - key : { type : "string", min : 1, max: 256 } - } - }); - - // now we are registering the timer feature itself (see /features/time/ in package). - // note the naming convention for the feature name _feature. - // the feature name is given in app setup, when passing the feature classes. - const _this = this; - - // this feature registers incoming data from the Feature and if the right key is given, - // stores it into the smart contract storage. - // the stored data can then be further used in regular contract functions. - this.addFeature('timer_feature', async function(){ - if(false === _this.check.validateSchema('feature_entry', _this.op)) return; - if(_this.op.key === 'currentTime') { - if(null === await _this.get('currentTime')) console.log('timer started at', _this.op.value); - await _this.put(_this.op.key, _this.op.value); - } - }); - - // last but not least, you may intercept messages from the built-in - // chat system, and perform actions similar to features to enrich your - // contract. check the _this.op value after you enabled the chat system - // and posted a few messages. - this.messageHandler(async function(){ - if(_this.op?.type === 'msg' && typeof _this.op.msg === 'string'){ - const currentTime = await _this.get('currentTime'); - await _this.put('chat_last', { - msg: _this.op.msg, - address: _this.op.address ?? null, - at: currentTime ?? null - }); - } - console.log('message triggered contract', _this.op); - }); - } - - /** - * A simple contract function without values (=no parameters). - * - * Contract functions must be registered through either "this.addFunction" or "this.addSchema" - * or it won't execute upon transactions. "this.addFunction" does not sanitize values, so it should be handled with - * care or be used when no payload is to be expected. - * - * Schema is recommended to sanitize incoming data from the transaction payload. - * The type of payload data depends on your protocol. - * - * This particular function does not expect any payload, so it's fine to be just registered using "this.addFunction". - * - * However, as you can see below, what it does is checking if an entry for key "something" exists already. - * With the very first tx executing it, it will return "null" (default value of this.get if no value found). - * From the 2nd tx onwards, it will print the previously stored value "there is something". - * - * It is recommended to check for null existence before using put to avoid duplicate content. - * - * As a rule of thumb, all "this.put()" should go at the end of function execution to avoid code security issues. - * - * Putting data is atomic, should a Peer with a contract interrupt, the put won't be executed. - */ - async storeSomething(){ - const something = await this.get('something'); - - console.log('is there already something?', something); - - if(null === something) { - await this.put('something', 'there is something'); - } - } - - /** - * Now we are using the schema-validated function defined in the constructor. - * - * The function also showcases some of the handy features like safe functions - * to prevent throws and safe bigint/decimal conversion. - */ - async submitSomething(){ - // the value of some_key shouldn't be empty, let's check that - if(this.value.some_key === ''){ - return new Error('Cannot be empty'); - // alternatively false for generic errors: - // return false; - } - - // of course the same works with assert (always use this.assert) - this.assert(this.value.some_key !== '', new Error('Cannot be empty')); - - // btw, please use safeBigInt provided by the contract protocol's superclass - // to calculate big integers: - const bigint = this.protocol.safeBigInt("1000000000000000000"); - - // making sure it didn't fail - this.assert(bigint !== null); - - // you can also convert a bigint string into its decimal representation (as string) - const decimal = this.protocol.fromBigIntString(bigint.toString(), 18); - - // and back into a bigint string - const bigint_string = this.protocol.toBigIntString(decimal, 18); - - // let's clone the value - const cloned = this.protocol.safeClone(this.value); - - // we want to pass the time from the timer feature. - // since mmodifications of this.value is not allowed, add this to the clone instead for storing: - cloned['timestamp'] = await this.get('currentTime'); - - // making sure it didn't fail (be aware of false-positives if null is passed to safeClone) - this.assert(cloned !== null); - - // and now let's stringify the cloned value - const stringified = this.protocol.safeJsonStringify(cloned); - - // and, you guessed it, best is to assert against null once more - this.assert(stringified !== null); - - // and guess we are parsing it back - const parsed = this.protocol.safeJsonParse(stringified); - - // parsing the json is a bit different: instead of null, we check against undefined: - this.assert(parsed !== undefined); - - // finally we are storing what address submitted the tx and what the value was - await this.put('submitted_by/'+this.address, parsed.some_key); - - // printing into the terminal works, too of course: - console.log('submitted by', this.address, parsed); - } - - async readSnapshot(){ - const something = await this.get('something'); - const currentTime = await this.get('currentTime'); - const msgl = await this.get('msgl'); - const msg0 = await this.get('msg/0'); - const msg1 = await this.get('msg/1'); - console.log('snapshot', { - something, - currentTime, - msgl: msgl ?? 0, - msg0, - msg1 - }); - } - - async readKey(){ - const key = this.value?.key; - const value = key ? await this.get(key) : null; - console.log(`readKey ${key}:`, value); - } - - async readChatLast(){ - const last = await this.get('chat_last'); - console.log('chat_last:', last); - } - - async readTimer(){ - const currentTime = await this.get('currentTime'); - console.log('currentTime:', currentTime); - } +/** + * Fractional Trading Card Ownership Contract (MVP) + * + * Deterministic replicated state: + * - assets[assetId] = { assetId, totalShares, createdAt } + * - holders[assetId][address] = shares + * + * Commands: + * - create_asset + * - transfer_shares + * - read_asset + * - read_holders + * + * Notes: + * - Minimal prototype. No settlement. + * - Compatible with Protocol.mapTxCommand() providing { type, value }. + */ + +export default function contract (state = {}, message = {}) { + state.assets ??= {}; + state.holders ??= {}; + + const { type, from } = message; + + // tx protocol delivers payload as message.value + const args = message.args ?? message.value ?? {}; + + const asInt = (v) => { + const n = Number(v); + if (!Number.isFinite(n) || !Number.isInteger(n)) throw new Error("INVALID_INT"); + return n; + }; + + const ensureAsset = (assetId) => { + const a = state.assets[assetId]; + if (!a) throw new Error("ASSET_NOT_FOUND"); + state.holders[assetId] ??= {}; + return a; + }; + + if (type === "create_asset") { + const assetId = String(args.assetId || "").trim(); + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + + const totalShares = asInt(args.totalShares); + if (totalShares <= 0) throw new Error("TOTAL_SHARES_INVALID"); + + const initialOwner = String(args.initialOwner || from || "").trim(); + if (!initialOwner) throw new Error("INITIAL_OWNER_REQUIRED"); + + if (state.assets[assetId]) throw new Error("ASSET_ALREADY_EXISTS"); + + state.assets[assetId] = { + assetId, + totalShares, + createdAt: Date.now() + }; + + state.holders[assetId] = { + [initialOwner]: totalShares + }; + + return state; + } + + if (type === "transfer_shares") { + const assetId = String(args.assetId || "").trim(); + const to = String(args.to || "").trim(); + const shares = asInt(args.shares); + + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + if (!to) throw new Error("TO_REQUIRED"); + if (shares <= 0) throw new Error("SHARES_INVALID"); + + ensureAsset(assetId); + + const fromAddr = String(from || "").trim(); + if (!fromAddr) throw new Error("FROM_REQUIRED"); + + const holders = (state.holders[assetId] ??= {}); + const fromBal = asInt(holders[fromAddr] || 0); + if (fromBal < shares) throw new Error("INSUFFICIENT_SHARES"); + + holders[fromAddr] = fromBal - shares; + holders[to] = asInt(holders[to] || 0) + shares; + + if (holders[fromAddr] === 0) delete holders[fromAddr]; + + return state; + } + + if (type === "read_asset") { + const assetId = String(args.assetId || "").trim(); + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + const asset = state.assets[assetId] || null; + return { asset }; + } + + if (type === "read_holders") { + const assetId = String(args.assetId || "").trim(); + if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + const holders = state.holders[assetId] || {}; + return { holders }; + } + + return state; } - -export default SampleContract; From 8282db51d3171b9c21153f22b64a853bce2c1543 Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 18:53:24 +0100 Subject: [PATCH 16/20] contract: implement execute() storage-backed fractional ownership --- contract/contract.js | 172 +++++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 71 deletions(-) diff --git a/contract/contract.js b/contract/contract.js index c133768f..0e373b51 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -1,107 +1,137 @@ /** * Fractional Trading Card Ownership Contract (MVP) * - * Deterministic replicated state: - * - assets[assetId] = { assetId, totalShares, createdAt } - * - holders[assetId][address] = shares + * This contract is implemented as a class with an execute(op, storage) method + * because trac-peer expects peer.contract.instance.execute(...) to exist. * - * Commands: + * We store all contract state under a single deterministic storage key: + * fo_state_v1 + * + * Dispatch payload is provided via: + * op.value.dispatch -> { type, value } + * + * Supported dispatch types: * - create_asset * - transfer_shares * - read_asset * - read_holders - * - * Notes: - * - Minimal prototype. No settlement. - * - Compatible with Protocol.mapTxCommand() providing { type, value }. */ -export default function contract (state = {}, message = {}) { - state.assets ??= {}; - state.holders ??= {}; +const STATE_KEY = 'fo_state_v1'; - const { type, from } = message; +function asInt(v) { + const n = Number(v); + if (!Number.isFinite(n) || !Number.isInteger(n)) throw new Error('INVALID_INT'); + return n; +} - // tx protocol delivers payload as message.value - const args = message.args ?? message.value ?? {}; +function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} - const asInt = (v) => { - const n = Number(v); - if (!Number.isFinite(n) || !Number.isInteger(n)) throw new Error("INVALID_INT"); - return n; - }; +export default class FractionalOwnershipContract { + async _getState(storage) { + const row = await storage.get(STATE_KEY); + if (!row || row.value == null) { + return { assets: {}, holders: {} }; + } + // storage value can be stored directly as object + return clone(row.value); + } + + async _setState(storage, state) { + await storage.put(STATE_KEY, state); + } - const ensureAsset = (assetId) => { + _ensureAsset(state, assetId) { const a = state.assets[assetId]; - if (!a) throw new Error("ASSET_NOT_FOUND"); + if (!a) throw new Error('ASSET_NOT_FOUND'); state.holders[assetId] ??= {}; return a; - }; + } - if (type === "create_asset") { - const assetId = String(args.assetId || "").trim(); - if (!assetId) throw new Error("ASSET_ID_REQUIRED"); + async execute(op, storage) { + // We only care about tx operations (simulation and real tx both call execute) + if (!op || op.type !== 'tx') return null; - const totalShares = asInt(args.totalShares); - if (totalShares <= 0) throw new Error("TOTAL_SHARES_INVALID"); + const dispatch = op?.value?.dispatch; + if (!dispatch || typeof dispatch !== 'object') throw new Error('INVALID_DISPATCH'); - const initialOwner = String(args.initialOwner || from || "").trim(); - if (!initialOwner) throw new Error("INITIAL_OWNER_REQUIRED"); + const type = dispatch.type; + const args = dispatch.value ?? {}; - if (state.assets[assetId]) throw new Error("ASSET_ALREADY_EXISTS"); + // Load state + const state = await this._getState(storage); - state.assets[assetId] = { - assetId, - totalShares, - createdAt: Date.now() - }; + // ---- Commands ---- + if (type === 'create_asset') { + const assetId = String(args.assetId || '').trim(); + if (!assetId) throw new Error('ASSET_ID_REQUIRED'); - state.holders[assetId] = { - [initialOwner]: totalShares - }; + const totalShares = asInt(args.totalShares); + if (totalShares <= 0) throw new Error('TOTAL_SHARES_INVALID'); - return state; - } + // initialOwner is optional; fallback to tx initiator public key (ipk) + const initialOwner = String(args.initialOwner || op?.value?.ipk || '').trim(); + if (!initialOwner) throw new Error('INITIAL_OWNER_REQUIRED'); - if (type === "transfer_shares") { - const assetId = String(args.assetId || "").trim(); - const to = String(args.to || "").trim(); - const shares = asInt(args.shares); + if (state.assets[assetId]) throw new Error('ASSET_ALREADY_EXISTS'); - if (!assetId) throw new Error("ASSET_ID_REQUIRED"); - if (!to) throw new Error("TO_REQUIRED"); - if (shares <= 0) throw new Error("SHARES_INVALID"); + state.assets[assetId] = { + assetId, + totalShares, + createdAt: Date.now() + }; - ensureAsset(assetId); + state.holders[assetId] = { + [initialOwner]: totalShares + }; - const fromAddr = String(from || "").trim(); - if (!fromAddr) throw new Error("FROM_REQUIRED"); + await this._setState(storage, state); + return { ok: true, assetId, totalShares, initialOwner }; + } - const holders = (state.holders[assetId] ??= {}); - const fromBal = asInt(holders[fromAddr] || 0); - if (fromBal < shares) throw new Error("INSUFFICIENT_SHARES"); + if (type === 'transfer_shares') { + const assetId = String(args.assetId || '').trim(); + const to = String(args.to || '').trim(); + const shares = asInt(args.shares); - holders[fromAddr] = fromBal - shares; - holders[to] = asInt(holders[to] || 0) + shares; + if (!assetId) throw new Error('ASSET_ID_REQUIRED'); + if (!to) throw new Error('TO_REQUIRED'); + if (shares <= 0) throw new Error('SHARES_INVALID'); - if (holders[fromAddr] === 0) delete holders[fromAddr]; + this._ensureAsset(state, assetId); - return state; - } + const fromAddr = String(op?.value?.ipk || '').trim(); + if (!fromAddr) throw new Error('FROM_REQUIRED'); - if (type === "read_asset") { - const assetId = String(args.assetId || "").trim(); - if (!assetId) throw new Error("ASSET_ID_REQUIRED"); - const asset = state.assets[assetId] || null; - return { asset }; - } + const holders = (state.holders[assetId] ??= {}); + const fromBal = asInt(holders[fromAddr] || 0); + if (fromBal < shares) throw new Error('INSUFFICIENT_SHARES'); - if (type === "read_holders") { - const assetId = String(args.assetId || "").trim(); - if (!assetId) throw new Error("ASSET_ID_REQUIRED"); - const holders = state.holders[assetId] || {}; - return { holders }; - } + holders[fromAddr] = fromBal - shares; + holders[to] = asInt(holders[to] || 0) + shares; + + if (holders[fromAddr] === 0) delete holders[fromAddr]; - return state; + await this._setState(storage, state); + return { ok: true, assetId, from: fromAddr, to, shares }; + } + + // ---- Reads ---- + if (type === 'read_asset') { + const assetId = String(args.assetId || '').trim(); + if (!assetId) throw new Error('ASSET_ID_REQUIRED'); + return { asset: state.assets[assetId] || null }; + } + + if (type === 'read_holders') { + const assetId = String(args.assetId || '').trim(); + if (!assetId) throw new Error('ASSET_ID_REQUIRED'); + return { holders: state.holders[assetId] || {} }; + } + + // Unknown command: no-op + return { ok: false, error: 'UNKNOWN_COMMAND', type }; + } } From 14830f0cb4ac3f1abb9a3cc0225c9dfeee9ffba1 Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 20:58:37 +0100 Subject: [PATCH 17/20] contract: add isReservedKey for tx simulation and safety --- contract/contract.js | 45 +++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/contract/contract.js b/contract/contract.js index 0e373b51..b9e6b39d 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -1,20 +1,14 @@ /** * Fractional Trading Card Ownership Contract (MVP) * - * This contract is implemented as a class with an execute(op, storage) method - * because trac-peer expects peer.contract.instance.execute(...) to exist. + * trac-peer expects peer.contract.instance.execute(op, storage) + * and (for simulation) peer.contract.instance.isReservedKey(key). * * We store all contract state under a single deterministic storage key: * fo_state_v1 * - * Dispatch payload is provided via: - * op.value.dispatch -> { type, value } - * - * Supported dispatch types: - * - create_asset - * - transfer_shares - * - read_asset - * - read_holders + * Dispatch payload is: + * op.value.dispatch -> { type, value } */ const STATE_KEY = 'fo_state_v1'; @@ -30,12 +24,17 @@ function clone(obj) { } export default class FractionalOwnershipContract { + // Required by SimStorage in trac-peer: + // Return true only for keys you want to forbid writing to. + isReservedKey(key) { + // We only write to STATE_KEY, and we allow that. + // Keep this strict to avoid accidental overwrites of system keys. + return key !== STATE_KEY; + } + async _getState(storage) { const row = await storage.get(STATE_KEY); - if (!row || row.value == null) { - return { assets: {}, holders: {} }; - } - // storage value can be stored directly as object + if (!row || row.value == null) return { assets: {}, holders: {} }; return clone(row.value); } @@ -51,7 +50,6 @@ export default class FractionalOwnershipContract { } async execute(op, storage) { - // We only care about tx operations (simulation and real tx both call execute) if (!op || op.type !== 'tx') return null; const dispatch = op?.value?.dispatch; @@ -60,10 +58,8 @@ export default class FractionalOwnershipContract { const type = dispatch.type; const args = dispatch.value ?? {}; - // Load state const state = await this._getState(storage); - // ---- Commands ---- if (type === 'create_asset') { const assetId = String(args.assetId || '').trim(); if (!assetId) throw new Error('ASSET_ID_REQUIRED'); @@ -71,21 +67,13 @@ export default class FractionalOwnershipContract { const totalShares = asInt(args.totalShares); if (totalShares <= 0) throw new Error('TOTAL_SHARES_INVALID'); - // initialOwner is optional; fallback to tx initiator public key (ipk) const initialOwner = String(args.initialOwner || op?.value?.ipk || '').trim(); if (!initialOwner) throw new Error('INITIAL_OWNER_REQUIRED'); if (state.assets[assetId]) throw new Error('ASSET_ALREADY_EXISTS'); - state.assets[assetId] = { - assetId, - totalShares, - createdAt: Date.now() - }; - - state.holders[assetId] = { - [initialOwner]: totalShares - }; + state.assets[assetId] = { assetId, totalShares, createdAt: Date.now() }; + state.holders[assetId] = { [initialOwner]: totalShares }; await this._setState(storage, state); return { ok: true, assetId, totalShares, initialOwner }; @@ -111,14 +99,12 @@ export default class FractionalOwnershipContract { holders[fromAddr] = fromBal - shares; holders[to] = asInt(holders[to] || 0) + shares; - if (holders[fromAddr] === 0) delete holders[fromAddr]; await this._setState(storage, state); return { ok: true, assetId, from: fromAddr, to, shares }; } - // ---- Reads ---- if (type === 'read_asset') { const assetId = String(args.assetId || '').trim(); if (!assetId) throw new Error('ASSET_ID_REQUIRED'); @@ -131,7 +117,6 @@ export default class FractionalOwnershipContract { return { holders: state.holders[assetId] || {} }; } - // Unknown command: no-op return { ok: false, error: 'UNKNOWN_COMMAND', type }; } } From dc7d3267c1d74b33f0c034f513d059c41a863cae Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 21:32:14 +0100 Subject: [PATCH 18/20] contract: implement required SimStorage hooks (emptyPromise/isReservedKey) --- contract/contract.js | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/contract/contract.js b/contract/contract.js index b9e6b39d..464c339b 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -1,16 +1,3 @@ -/** - * Fractional Trading Card Ownership Contract (MVP) - * - * trac-peer expects peer.contract.instance.execute(op, storage) - * and (for simulation) peer.contract.instance.isReservedKey(key). - * - * We store all contract state under a single deterministic storage key: - * fo_state_v1 - * - * Dispatch payload is: - * op.value.dispatch -> { type, value } - */ - const STATE_KEY = 'fo_state_v1'; function asInt(v) { @@ -18,19 +5,14 @@ function asInt(v) { if (!Number.isFinite(n) || !Number.isInteger(n)) throw new Error('INVALID_INT'); return n; } - -function clone(obj) { - return JSON.parse(JSON.stringify(obj)); -} +function clone(obj) { return JSON.parse(JSON.stringify(obj)); } export default class FractionalOwnershipContract { - // Required by SimStorage in trac-peer: - // Return true only for keys you want to forbid writing to. - isReservedKey(key) { - // We only write to STATE_KEY, and we allow that. - // Keep this strict to avoid accidental overwrites of system keys. - return key !== STATE_KEY; - } + // REQUIRED by SimStorage + emptyPromise() { return Promise.resolve(); } + + // MVP: do not reserve keys (avoid SimStorage conflicts) + isReservedKey(_key) { return false; } async _getState(storage) { const row = await storage.get(STATE_KEY); @@ -57,7 +39,6 @@ export default class FractionalOwnershipContract { const type = dispatch.type; const args = dispatch.value ?? {}; - const state = await this._getState(storage); if (type === 'create_asset') { From c37a2b652253b40526c9da9fb452b4ec4716f646 Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 21:54:13 +0100 Subject: [PATCH 19/20] contract: return put ops so state is visible via /get --- contract/contract.js | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/contract/contract.js b/contract/contract.js index 464c339b..4c27fc8d 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -1,3 +1,20 @@ +/** + * Fractional Trading Card Ownership Contract (MVP) + * + * IMPORTANT: + * For real TXs, the framework expects the contract to RETURN state operations + * (e.g. put/del) that will be applied to the subnet state (base.view), + * so that `/get --key ...` can read them. + * + * Therefore execute() returns: + * - an array of ops for writes: [{ type:'put', key, value }] + * - a plain object for reads + * + * Also required by SimStorage: + * - emptyPromise() + * - isReservedKey() + */ + const STATE_KEY = 'fo_state_v1'; function asInt(v) { @@ -5,14 +22,14 @@ function asInt(v) { if (!Number.isFinite(n) || !Number.isInteger(n)) throw new Error('INVALID_INT'); return n; } -function clone(obj) { return JSON.parse(JSON.stringify(obj)); } + +function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} export default class FractionalOwnershipContract { - // REQUIRED by SimStorage emptyPromise() { return Promise.resolve(); } - - // MVP: do not reserve keys (avoid SimStorage conflicts) - isReservedKey(_key) { return false; } + isReservedKey(_key) { return false; } // MVP async _getState(storage) { const row = await storage.get(STATE_KEY); @@ -20,10 +37,6 @@ export default class FractionalOwnershipContract { return clone(row.value); } - async _setState(storage, state) { - await storage.put(STATE_KEY, state); - } - _ensureAsset(state, assetId) { const a = state.assets[assetId]; if (!a) throw new Error('ASSET_NOT_FOUND'); @@ -39,8 +52,10 @@ export default class FractionalOwnershipContract { const type = dispatch.type; const args = dispatch.value ?? {}; + const state = await this._getState(storage); + // ---------- Writes (return ops) ---------- if (type === 'create_asset') { const assetId = String(args.assetId || '').trim(); if (!assetId) throw new Error('ASSET_ID_REQUIRED'); @@ -56,8 +71,7 @@ export default class FractionalOwnershipContract { state.assets[assetId] = { assetId, totalShares, createdAt: Date.now() }; state.holders[assetId] = { [initialOwner]: totalShares }; - await this._setState(storage, state); - return { ok: true, assetId, totalShares, initialOwner }; + return [{ type: 'put', key: STATE_KEY, value: state }]; } if (type === 'transfer_shares') { @@ -82,10 +96,10 @@ export default class FractionalOwnershipContract { holders[to] = asInt(holders[to] || 0) + shares; if (holders[fromAddr] === 0) delete holders[fromAddr]; - await this._setState(storage, state); - return { ok: true, assetId, from: fromAddr, to, shares }; + return [{ type: 'put', key: STATE_KEY, value: state }]; } + // ---------- Reads (return plain objects) ---------- if (type === 'read_asset') { const assetId = String(args.assetId || '').trim(); if (!assetId) throw new Error('ASSET_ID_REQUIRED'); From dc0acbcad7d6937071f1b33ac82534a67bf314b4 Mon Sep 17 00:00:00 2001 From: Jonas Weiss Date: Sat, 28 Feb 2026 22:00:15 +0100 Subject: [PATCH 20/20] fix: correct trac-peer contract return format --- contract/contract.js | 123 +++++++++++++++++++++---------------------- 1 file changed, 59 insertions(+), 64 deletions(-) diff --git a/contract/contract.js b/contract/contract.js index 4c27fc8d..e3c90bc1 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -1,20 +1,3 @@ -/** - * Fractional Trading Card Ownership Contract (MVP) - * - * IMPORTANT: - * For real TXs, the framework expects the contract to RETURN state operations - * (e.g. put/del) that will be applied to the subnet state (base.view), - * so that `/get --key ...` can read them. - * - * Therefore execute() returns: - * - an array of ops for writes: [{ type:'put', key, value }] - * - a plain object for reads - * - * Also required by SimStorage: - * - emptyPromise() - * - isReservedKey() - */ - const STATE_KEY = 'fo_state_v1'; function asInt(v) { @@ -28,8 +11,10 @@ function clone(obj) { } export default class FractionalOwnershipContract { + emptyPromise() { return Promise.resolve(); } - isReservedKey(_key) { return false; } // MVP + + isReservedKey() { return false; } async _getState(storage) { const row = await storage.get(STATE_KEY); @@ -37,81 +22,91 @@ export default class FractionalOwnershipContract { return clone(row.value); } - _ensureAsset(state, assetId) { - const a = state.assets[assetId]; - if (!a) throw new Error('ASSET_NOT_FOUND'); - state.holders[assetId] ??= {}; - return a; - } - async execute(op, storage) { - if (!op || op.type !== 'tx') return null; - const dispatch = op?.value?.dispatch; - if (!dispatch || typeof dispatch !== 'object') throw new Error('INVALID_DISPATCH'); + if (op.type !== 'tx') return null; + + const dispatch = op.value.dispatch; const type = dispatch.type; - const args = dispatch.value ?? {}; + const args = dispatch.value || {}; const state = await this._getState(storage); - // ---------- Writes (return ops) ---------- if (type === 'create_asset') { - const assetId = String(args.assetId || '').trim(); - if (!assetId) throw new Error('ASSET_ID_REQUIRED'); + const assetId = args.assetId; const totalShares = asInt(args.totalShares); - if (totalShares <= 0) throw new Error('TOTAL_SHARES_INVALID'); - const initialOwner = String(args.initialOwner || op?.value?.ipk || '').trim(); - if (!initialOwner) throw new Error('INITIAL_OWNER_REQUIRED'); + const owner = op.value.ipk; + + state.assets[assetId] = { + + assetId, + totalShares - if (state.assets[assetId]) throw new Error('ASSET_ALREADY_EXISTS'); + }; - state.assets[assetId] = { assetId, totalShares, createdAt: Date.now() }; - state.holders[assetId] = { [initialOwner]: totalShares }; + state.holders[assetId] = { + + [owner]: totalShares + + }; + + return { + + type: 'put', + key: STATE_KEY, + value: state + + }; - return [{ type: 'put', key: STATE_KEY, value: state }]; } if (type === 'transfer_shares') { - const assetId = String(args.assetId || '').trim(); - const to = String(args.to || '').trim(); + + const assetId = args.assetId; + const to = args.to; const shares = asInt(args.shares); - if (!assetId) throw new Error('ASSET_ID_REQUIRED'); - if (!to) throw new Error('TO_REQUIRED'); - if (shares <= 0) throw new Error('SHARES_INVALID'); + const from = op.value.ipk; - this._ensureAsset(state, assetId); + const holders = state.holders[assetId]; - const fromAddr = String(op?.value?.ipk || '').trim(); - if (!fromAddr) throw new Error('FROM_REQUIRED'); + if (!holders) throw new Error('NO_ASSET'); - const holders = (state.holders[assetId] ??= {}); - const fromBal = asInt(holders[fromAddr] || 0); - if (fromBal < shares) throw new Error('INSUFFICIENT_SHARES'); + if (!holders[from]) throw new Error('NO_BALANCE'); - holders[fromAddr] = fromBal - shares; - holders[to] = asInt(holders[to] || 0) + shares; - if (holders[fromAddr] === 0) delete holders[fromAddr]; + if (holders[from] < shares) throw new Error('INSUFFICIENT'); - return [{ type: 'put', key: STATE_KEY, value: state }]; - } + holders[from] -= shares; + + holders[to] = (holders[to] || 0) + shares; + + return { + + type: 'put', + key: STATE_KEY, + value: state + + }; - // ---------- Reads (return plain objects) ---------- - if (type === 'read_asset') { - const assetId = String(args.assetId || '').trim(); - if (!assetId) throw new Error('ASSET_ID_REQUIRED'); - return { asset: state.assets[assetId] || null }; } if (type === 'read_holders') { - const assetId = String(args.assetId || '').trim(); - if (!assetId) throw new Error('ASSET_ID_REQUIRED'); - return { holders: state.holders[assetId] || {} }; + + return state.holders; + } - return { ok: false, error: 'UNKNOWN_COMMAND', type }; + if (type === 'read_asset') { + + return state.assets; + + } + + return null; + } + }