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 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 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/contract/contract.js b/contract/contract.js index f661e5fc..e3c90bc1 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -1,240 +1,112 @@ -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); - }); - } +const STATE_KEY = 'fo_state_v1'; - /** - * 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'); - } - } +function asInt(v) { + const n = Number(v); + if (!Number.isFinite(n) || !Number.isInteger(n)) throw new Error('INVALID_INT'); + return n; +} + +function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} - /** - * 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; - } +export default class FractionalOwnershipContract { - // of course the same works with assert (always use this.assert) - this.assert(this.value.some_key !== '', new Error('Cannot be empty')); + emptyPromise() { return Promise.resolve(); } - // btw, please use safeBigInt provided by the contract protocol's superclass - // to calculate big integers: - const bigint = this.protocol.safeBigInt("1000000000000000000"); + isReservedKey() { return false; } - // making sure it didn't fail - this.assert(bigint !== null); + async _getState(storage) { + const row = await storage.get(STATE_KEY); + if (!row || row.value == null) return { assets: {}, holders: {} }; + return clone(row.value); + } - // you can also convert a bigint string into its decimal representation (as string) - const decimal = this.protocol.fromBigIntString(bigint.toString(), 18); + async execute(op, storage) { - // and back into a bigint string - const bigint_string = this.protocol.toBigIntString(decimal, 18); + if (op.type !== 'tx') return null; - // let's clone the value - const cloned = this.protocol.safeClone(this.value); + const dispatch = op.value.dispatch; - // 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'); + const type = dispatch.type; + const args = dispatch.value || {}; - // making sure it didn't fail (be aware of false-positives if null is passed to safeClone) - this.assert(cloned !== null); + const state = await this._getState(storage); - // and now let's stringify the cloned value - const stringified = this.protocol.safeJsonStringify(cloned); + if (type === 'create_asset') { - // and, you guessed it, best is to assert against null once more - this.assert(stringified !== null); + const assetId = args.assetId; + const totalShares = asInt(args.totalShares); - // and guess we are parsing it back - const parsed = this.protocol.safeJsonParse(stringified); + const owner = op.value.ipk; - // parsing the json is a bit different: instead of null, we check against undefined: - this.assert(parsed !== undefined); + state.assets[assetId] = { - // finally we are storing what address submitted the tx and what the value was - await this.put('submitted_by/'+this.address, parsed.some_key); + assetId, + totalShares - // printing into the terminal works, too of course: - console.log('submitted by', this.address, parsed); - } + }; + + state.holders[assetId] = { + + [owner]: totalShares + + }; + + return { + + type: 'put', + key: STATE_KEY, + value: state + + }; - 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); + if (type === 'transfer_shares') { + + const assetId = args.assetId; + const to = args.to; + const shares = asInt(args.shares); + + const from = op.value.ipk; + + const holders = state.holders[assetId]; + + if (!holders) throw new Error('NO_ASSET'); + + if (!holders[from]) throw new Error('NO_BALANCE'); + + if (holders[from] < shares) throw new Error('INSUFFICIENT'); + + holders[from] -= shares; + + holders[to] = (holders[to] || 0) + shares; + + return { + + type: 'put', + key: STATE_KEY, + value: state + + }; + } - async readChatLast(){ - const last = await this.get('chat_last'); - console.log('chat_last:', last); + if (type === 'read_holders') { + + return state.holders; + } - async readTimer(){ - const currentTime = await this.get('currentTime'); - console.log('currentTime:', currentTime); + if (type === 'read_asset') { + + return state.assets; + } -} -export default SampleContract; + return null; + + } + +} 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; 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. 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": {} +} 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. 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/demo-2peers.md b/examples/demo-2peers.md new file mode 100644 index 00000000..0c04ab25 --- /dev/null +++ b/examples/demo-2peers.md @@ -0,0 +1,25 @@ +# Demo: Run 2 Peers (Admin + Joiner) + +This demo proves that your Intercom fork runs correctly and that peers can connect to the same subnet. + +You will: + +- start one admin peer +- copy its writer key +- start one joiner peer + +This is the foundation for fractional asset ownership later. + +--- + +# Requirements + +You must do this later on your local computer, not on GitHub. + +Install Node.js 22 or newer. + +Install Pear runtime: + +```bash +npm install -g pear +pear -v 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/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; diff --git a/scripts/run-admin.sh b/scripts/run-admin.sh new file mode 100755 index 00000000..0d5df10b --- /dev/null +++ b/scripts/run-admin.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SUBNET_CHANNEL="${SUBNET_CHANNEL:-card-rwa-dev}" +PEER_STORE="${PEER_STORE:-admin}" +MSB_STORE="${MSB_STORE:-admin-msb}" +DHT_BOOTSTRAP="${DHT_BOOTSTRAP:-127.0.0.1:49737}" + +SC_BRIDGE_HOST="${SC_BRIDGE_HOST:-127.0.0.1}" +SC_BRIDGE_PORT="${SC_BRIDGE_PORT:-8787}" +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 "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}" \ + --sc-bridge-token "${SC_BRIDGE_TOKEN}" diff --git a/scripts/run-joiner.sh b/scripts/run-joiner.sh new file mode 100755 index 00000000..6ec33082 --- /dev/null +++ b/scripts/run-joiner.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +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 + +SUBNET_CHANNEL="${SUBNET_CHANNEL:-card-rwa-dev}" +PEER_STORE="${PEER_STORE:-joiner1}" +MSB_STORE="${MSB_STORE:-joiner1-msb}" +DHT_BOOTSTRAP="${DHT_BOOTSTRAP:-127.0.0.1:49737}" + +# 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}" + +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 "DHT bootstrap: ${DHT_BOOTSTRAP}" +echo "SC-Bridge: ws://${SC_BRIDGE_HOST}:${SC_BRIDGE_PORT}" +echo "" + +pear run . \ + --peer-store-name "${PEER_STORE}" \ + --msb-store-name "${MSB_STORE}" \ + --subnet-channel "${SUBNET_CHANNEL}" \ + --subnet-bootstrap "${SUBNET_BOOTSTRAP}" \ + --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}"