diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e11f351 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +SHELL := /bin/bash + +DFX ?= $(HOME)/.local/share/dfx/bin/dfx +DFX_ENV ?= TERM=xterm-256color +ICP_DEMO_DIR ?= packages/icp-intercanister +ICP_NETWORK ?= local + +.PHONY: icp-check icp-new icp-port-check icp-replica-start icp-deploy icp-call icp-ocapn-case icp-replica-stop icp-hello icp-intercanister icp-ocapn-deliver-with-resolver ocapn + +icp-check: + @test -x "$(DFX)" || (echo "dfx not found at $(DFX)"; exit 1) + @$(DFX_ENV) "$(DFX)" --version + +icp-new: icp-check + @if [ -f "$(ICP_DEMO_DIR)/dfx.json" ]; then \ + echo "exists: $(ICP_DEMO_DIR)"; \ + else \ + echo "missing $(ICP_DEMO_DIR)/dfx.json"; \ + exit 1; \ + fi + +icp-port-check: + @if command -v lsof >/dev/null 2>&1; then \ + if lsof -nP -iTCP:4943 -sTCP:LISTEN >/dev/null 2>&1; then \ + echo "port 4943 already in use; dfx cannot start local replica."; \ + lsof -nP -iTCP:4943 -sTCP:LISTEN; \ + echo "stop that process (or kill its PID) and retry."; \ + exit 1; \ + fi; \ + fi + +icp-replica-start: icp-check + cd "$(ICP_DEMO_DIR)" && ( $(DFX_ENV) "$(DFX)" stop >/dev/null 2>&1 || true ) + @$(MAKE) icp-port-check + cd "$(ICP_DEMO_DIR)" && $(DFX_ENV) "$(DFX)" start --clean --background + +icp-deploy: icp-check + cd "$(ICP_DEMO_DIR)" && $(DFX_ENV) "$(DFX)" deploy --network "$(ICP_NETWORK)" + +icp-call: icp-check + cd "$(ICP_DEMO_DIR)" && $(DFX_ENV) "$(DFX)" canister call --network "$(ICP_NETWORK)" "byte_sender" ocapn_deliver_with_resolver_ok + +icp-ocapn-case: icp-check + cd "$(ICP_DEMO_DIR)" && $(DFX_ENV) "$(DFX)" canister call --network "$(ICP_NETWORK)" "byte_sender" ocapn_deliver_with_resolver_ok + +icp-replica-stop: icp-check + cd "$(ICP_DEMO_DIR)" && $(DFX_ENV) "$(DFX)" stop + +icp-hello: icp-new icp-replica-start icp-deploy icp-call + @echo "ICP onboarding/inter-canister demo done." + +icp-intercanister: icp-new icp-replica-start icp-deploy icp-call + @echo "ICP inter-canister demo done." + +icp-ocapn-deliver-with-resolver: icp-new icp-replica-start icp-deploy icp-ocapn-case + @echo "ICP OCapN selected-case demo done." + +ocapn: icp-ocapn-deliver-with-resolver diff --git a/packages/icp-intercanister/.gitignore b/packages/icp-intercanister/.gitignore new file mode 100644 index 0000000..49c89a1 --- /dev/null +++ b/packages/icp-intercanister/.gitignore @@ -0,0 +1,25 @@ +# Various IDEs and Editors +.vscode/ +.idea/ +**/*~ + +# Mac OSX temporary files +.DS_Store +**/.DS_Store + +# dfx temporary files +.dfx/ + +# generated files +**/declarations/ + +# rust +target/ + +# frontend code +node_modules/ +dist/ +.svelte-kit/ + +# environment variables +.env diff --git a/packages/icp-intercanister/CONTRIBUTING.md b/packages/icp-intercanister/CONTRIBUTING.md new file mode 100644 index 0000000..419acb7 --- /dev/null +++ b/packages/icp-intercanister/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing (ICP Inter-Canister Prototype) + +## Interop Constraint Rule + +This package models protocol-facing data. Type and wire-shape changes can break +interoperability even when local tests pass. + +When you add or change protocol/message/value types (for example in +`src/ocapn_types.mo`), you must: + +1. cite the external constraint source in code comments near the changed type +2. include the source link in the PR/commit message +3. explain whether the change is: + - required by an external spec/test suite, or + - a local prototype choice that is explicitly temporary + +Minimum acceptable sources: + +- OCapN spec/drafts (`ocapn/ocapn`) +- OCapN test suite behavior (`ocapn/ocapn-test-suite`) +- ICP/Candid reference docs (for wire-level Candid choices) + +If no external source exists yet, add a `TODO(spec-gap)` comment and describe +the interoperability risk. diff --git a/packages/icp-intercanister/README.md b/packages/icp-intercanister/README.md new file mode 100644 index 0000000..3eb33f3 --- /dev/null +++ b/packages/icp-intercanister/README.md @@ -0,0 +1,107 @@ +# OCapN-on-ICP Inter-Canister Prototype + +This package explores one concrete OCapN interoperability goal on ICP: + +- implement the selected OCapN deliver test behavior + (`op_delivers::OpDeliverTest::test_deliver_with_resolver`) +- represent OCapN-style messages/values in Motoko +- execute the scenario across two canisters on a local ICP replica + +The inter-canister plumbing in this package is in service of that goal. + +Current canisters: + +- `byte_receiver`: inbound handler canister; `handleInbound` is the sole public entrypoint. +- `byte_sender`: test driver that performs inter-canister calls, validates expected resolution shape, and also supports `handleInbound`. + +Before changing protocol-facing types, read `CONTRIBUTING.md`. + +## Layout + +- `src/byte_receiver/main.mo` +- `src/byte_sender/main.mo` +- `src/vattp_boundary.mo` (transport/session boundary interfaces) +- `src/ocapn_protocol.mo` (OCapN/CapTP message and reference-lifecycle boundary interfaces) +- `dfx.json` + +## Run + +From repo root: + +```bash +make icp-intercanister +``` + +This command: + +1. start local replica +2. deploy both canisters +3. calls `byte_sender.ocapn_deliver_with_resolver_ok` + +Run the selected OCapN deliver-with-resolver case between canisters: + +```bash +make icp-ocapn-deliver-with-resolver +``` + +This package targets an +[OCapN test-suite](https://github.com/ocapn/ocapn-test-suite) case: + +- `tests/op_delivers.py::OpDeliverTest::test_deliver_with_resolver` + +In this prototype, that case is exercised by calling +`byte_sender.ocapn_deliver_with_resolver_ok`, which expects: + +```candid +(true) +``` + +The selected-case method models: + +- message: `op:deliver` +- args: `["foo", 1, false, b"bar", ["baz"]]` +- expected resolution args: `[symbol("fulfill"), original_args]` +- transport in this prototype path: `to_candid` / `from_candid` bytes for `OcapnMessage` and `OcapnResolution` + +Stop local replica when done: + +```bash +make icp-replica-stop +``` + +## Manual Call + +```bash +cd packages/icp-intercanister +TERM=xterm-256color ~/.local/share/dfx/bin/dfx canister call --network local byte_sender ocapn_deliver_with_resolver_ok +``` + +Expected response: + +```candid +(true) +``` + +## Where Local State Lives + +Documented by ICP `dfx start` docs: + +- shared local network data root (Linux): + - `$HOME/.local/share/dfx/network/local` +- project-specific local network data root: + - `/.dfx/network/local` + +Source: + +- https://internetcomputer.org/docs/building-apps/developer-tools/dfx/dfx-start + +- Project-local `.dfx/`: + - build outputs, generated interfaces, local canister IDs +- Replica runtime internals under the shared-root path above: + - observed on this setup (`dfx 0.30.2`) under subpaths like + `.../state/replicated_state/...` + - exact internal directory/file layout is implementation detail and may change + across `dfx`/replica versions. + +So if you are looking for raw local canister state, inspect the shared local +network data root as well as project `.dfx`. diff --git a/packages/icp-intercanister/dfx.json b/packages/icp-intercanister/dfx.json new file mode 100644 index 0000000..35fe6cd --- /dev/null +++ b/packages/icp-intercanister/dfx.json @@ -0,0 +1,23 @@ +{ + "canisters": { + "byte_receiver": { + "main": "src/byte_receiver/main.mo", + "type": "motoko" + }, + "byte_sender": { + "dependencies": [ + "byte_receiver" + ], + "main": "src/byte_sender/main.mo", + "type": "motoko" + } + }, + "defaults": { + "build": { + "args": "", + "packtool": "" + } + }, + "output_env_file": ".env", + "version": 1 +} diff --git a/packages/icp-intercanister/src/byte_receiver/main.mo b/packages/icp-intercanister/src/byte_receiver/main.mo new file mode 100644 index 0000000..46e5e16 --- /dev/null +++ b/packages/icp-intercanister/src/byte_receiver/main.mo @@ -0,0 +1,55 @@ +import OcapnProtocol "../ocapn_protocol"; +import OcapnTypes "../ocapn_types"; +import Vattp "../vattp_boundary"; + +persistent actor { + // TODO(ocap-authority): integrate `src/swissnum.mo` for bootstrap/discovery + // refs and enforce descriptor-table validation in `ocapn_handle`. + // Minimal echo callable; `call` mirrors callable-object naming conventions. + func call(args : [OcapnTypes.OcapnValue]) : [OcapnTypes.OcapnValue] { + args + }; + + /// Minimal selected-case behavior for + /// `op_delivers::OpDeliverTest::test_deliver_with_resolver`. + func ocapn_handle( + message : OcapnProtocol.OcapnMessage + ) : async OcapnProtocol.OcapnResolution { + switch (message) { + case (#deliver(deliver)) { + let resolver = switch (deliver.resolve_me_desc) { + case (?desc) desc; + case null "missing-resolver"; + }; + { + resolver; + args = [#symbol("fulfill"), #list(call(deliver.args))]; + }; + }; + case (#deliver_only(_)) { + { + resolver = "deliver-only"; + args = [#symbol("break"), #text("deliver-only has no response in this prototype")]; + }; + }; + case (_) { + { + resolver = "unsupported-op"; + args = [#symbol("break"), #text("unsupported op for selected-case prototype")]; + }; + }; + }; + }; + + public func handleInbound( + envelope : Vattp.TurnEnvelope + ) : async Vattp.TurnResult { + let parsed : ?OcapnProtocol.OcapnMessage = from_candid(envelope.payload); + switch (parsed) { + case (?message) #ok(to_candid(await ocapn_handle(message))); + // Fallback keeps the byte-echo prototype path alive while handleInbound + // is the sole public entrypoint. + case null #ok(envelope.payload); + }; + }; +}; diff --git a/packages/icp-intercanister/src/byte_sender/main.mo b/packages/icp-intercanister/src/byte_sender/main.mo new file mode 100644 index 0000000..0dc87dd --- /dev/null +++ b/packages/icp-intercanister/src/byte_sender/main.mo @@ -0,0 +1,63 @@ +import Blob "mo:base/Blob"; +import Principal "mo:base/Principal"; +import Receiver "canister:byte_receiver"; +import OcapnProtocol "../ocapn_protocol"; +import OcapnTypes "../ocapn_types"; +import RefSend "../ref_send"; +import Vattp "../vattp_boundary"; + +persistent actor { + let theChannel : RefSend.Channel = { + peerPrincipal = Principal.fromActor(Receiver); + sessionId = "selected-case"; + channelId = "selected-case"; + var turnId : Nat64 = 1; + }; + + private func selectedCaseArgs() : [OcapnTypes.OcapnValue] { + [ + #text("foo"), + #int(1), + #bool(false), + #bytes(Blob.fromArray([98, 97, 114])), + #list([#text("baz")]), + ]; + }; + + public func ocapn_deliver_with_resolver_ok() : async Bool { + let echoProxy = object { + let myChannel = theChannel; + // TODO(ocap-authority): replace this forgeable textual target with a + // swissnum/bootstrap flow and descriptor-table positions. + let to = "echo-gc"; + + public func call( + args : [OcapnTypes.OcapnValue], + resolveMeDesc : ?Text, + ) : async OcapnProtocol.OcapnResolution { + let message : OcapnProtocol.OcapnMessage = #deliver({ + to; + args; + answerPosition = null; + resolve_me_desc = resolveMeDesc; + }); + await RefSend.sendMessage(myChannel, message); + }; + }; + + let args = selectedCaseArgs(); + let resolution = await echoProxy.call(args, ?"$0"); + + let expected : OcapnProtocol.OcapnResolution = { + resolver = "$0"; + args = [#symbol("fulfill"), #list(args)]; + }; + resolution == expected + }; + + public func handleInbound( + _envelope : Vattp.TurnEnvelope + ) : async Vattp.TurnResult { + #err("byte_sender does not handle inbound protocol messages in this prototype") + }; +}; diff --git a/packages/icp-intercanister/src/ocapn_protocol.mo b/packages/icp-intercanister/src/ocapn_protocol.mo new file mode 100644 index 0000000..a9f3007 --- /dev/null +++ b/packages/icp-intercanister/src/ocapn_protocol.mo @@ -0,0 +1,78 @@ +import OcapnTypes "ocapn_types"; + +module { + /* + * Refs: + * - OCapN test suite: + * https://github.com/ocapn/ocapn-test-suite + * - OCapN netlayer draft: + * https://github.com/ocapn/ocapn/blob/main/draft-specifications/Netlayers.md + * + * OCapN/CapTP protocol data structures (messages only). + * No method interfaces are defined here. + */ + + public type RefId = Text; + // TODO(ocap-authority): `Desc` is currently a forgeable `Text` placeholder. + // Replace this with session-scoped import/export descriptor positions (or + // equivalent opaque ids) validated against per-session tables on inbound + // messages, so descriptor guessing cannot escalate authority. + // For bootstrap/discovery refs, require unguessable swissnum-strength tokens. + public type Desc = Text; + public type PublicKey = Blob; + public type Location = Text; + public type Signature = Blob; + + public type OpStartSession = { + captpVersion : Text; + sessionPubkey : PublicKey; + location : Location; + locationSig : Signature; + }; + + public type OpListen = { + to : Desc; + resolveMeDesc : Desc; + }; + + public type OpDeliverOnly = { + to : Desc; + args : [OcapnTypes.OcapnValue]; + }; + + public type OpDeliver = { + to : Desc; + args : [OcapnTypes.OcapnValue]; + answerPosition : ?Nat; + resolve_me_desc : ?Text; + }; + + public type OpAbort = { + reason : Text; + }; + + public type OpGcExport = { + exportPosition : Nat; + wireDelta : Int; + }; + + public type OpGcAnswer = { + answerPosition : Nat; + }; + + // Top-level OCapN/CapTP protocol messages. + public type OcapnMessage = { + #start_session : OpStartSession; + #listen : OpListen; + #deliver_only : OpDeliverOnly; + #deliver : OpDeliver; + #abort : OpAbort; + #gc_export : OpGcExport; + #gc_answer : OpGcAnswer; + }; + + public type OcapnResolution = { + resolver : Text; + args : [OcapnTypes.OcapnValue]; + }; +}; diff --git a/packages/icp-intercanister/src/ocapn_types.mo b/packages/icp-intercanister/src/ocapn_types.mo new file mode 100644 index 0000000..f33def7 --- /dev/null +++ b/packages/icp-intercanister/src/ocapn_types.mo @@ -0,0 +1,19 @@ +import Blob "mo:base/Blob"; + +module { + /* + * Refs: + * - https://github.com/ocapn/ocapn-test-suite/blob/main/tests/op_delivers.py + * (`OpDeliverTest::test_deliver_with_resolver`) + * - https://github.com/ocapn/ocapn/issues/3#issuecomment-1552187671 + */ + public type OcapnValue = { + #unit; + #bool : Bool; + #int : Int; + #text : Text; + #symbol : Text; + #bytes : Blob; + #list : [OcapnValue]; + }; +}; diff --git a/packages/icp-intercanister/src/ref_send.mo b/packages/icp-intercanister/src/ref_send.mo new file mode 100644 index 0000000..4650033 --- /dev/null +++ b/packages/icp-intercanister/src/ref_send.mo @@ -0,0 +1,49 @@ +import Principal "mo:base/Principal"; +import OcapnProtocol "./ocapn_protocol"; +import OcapnTypes "./ocapn_types"; +import Vattp "./vattp_boundary"; + +/// Refs: +/// - E `Ref.send` abstraction in e-on-java: +/// https://github.com/kpreid/e-on-java/tree/master/src/jsrc/org/erights/e/elib/ref +/// - OCapN message model (`deliver`, `deliver-only`, resolution): +/// https://github.com/ocapn/ocapn/blob/main/draft-specifications/Protocol.md +/// - OCapN selected test case (`deliver_with_resolver`): +/// https://github.com/ocapn/ocapn-test-suite/blob/main/tests/op_delivers.py +module { + public type Channel = { + peerPrincipal : Principal; + sessionId : Text; + channelId : Text; + var turnId : Nat64; + }; + + public func sendMessage( + channel : Channel, + message : OcapnProtocol.OcapnMessage, + ) : async OcapnProtocol.OcapnResolution { + let turnId = channel.turnId; + channel.turnId += 1; + let peer : Vattp.VattpBoundary = actor (Principal.toText(channel.peerPrincipal)); + let envelope : Vattp.TurnEnvelope = { + sessionId = channel.sessionId; + channelId = channel.channelId; + turnId; + payload = to_candid(message); + }; + let turnResult = await peer.handleInbound(envelope); + let resolutionDecoded : ?OcapnProtocol.OcapnResolution = switch (turnResult) { + case (#ok(payload)) from_candid(payload); + case (#err(_)) null; + }; + switch (resolutionDecoded) { + case (?value) value; + case null { + { + resolver = "decode-error"; + args = [#symbol("break"), #text("unable to decode OcapnResolution from candid bytes")]; + }; + }; + }; + }; +}; diff --git a/packages/icp-intercanister/src/swissnum.mo b/packages/icp-intercanister/src/swissnum.mo new file mode 100644 index 0000000..f928d21 --- /dev/null +++ b/packages/icp-intercanister/src/swissnum.mo @@ -0,0 +1,56 @@ +import Blob "mo:base/Blob"; +import Nat64 "mo:base/Nat64"; +import Principal "mo:base/Principal"; +import Text "mo:base/Text"; + +module { + /// Refs: + /// - OCapN swissnum object lookup usage in tests (`fetch` arg is opaque bytes): + /// https://github.com/ocapn/ocapn-test-suite/blob/main/utils/captp.py + /// - ICP randomness source (`raw_rand`) via management canister. + /// + /// Notes: + /// - Descriptor-table positions (import/export/answer) are authority-safe only + /// when scoped to a session and validated on inbound messages. + /// - Swissnums are bearer secrets for bootstrap/discovery, so they must be + /// high-entropy and unguessable. + /// - This helper mints swissnum tokens from IC entropy and context data. + /// A follow-on hardening step should hash/KDF this to a fixed-width token. + + public type State = { + var nextCounter : Nat64; + }; + + public func emptyState() : State { + { + var nextCounter = 0; + }; + }; + + let IC : actor { + raw_rand : () -> async Blob; + } = actor ("aaaaa-aa"); + + public func mint( + state : State, + caller : Principal, + sessionId : Text, + ) : async Blob { + state.nextCounter += 1; + let entropy = await IC.raw_rand(); + + // Domain-separated context so minted values are namespaced to this usage. + let context = Text.encodeUtf8( + "ocapn-swissnum:v1:" + # Principal.toText(caller) + # ":" + # sessionId + # ":" + # Nat64.toText(state.nextCounter) + ); + + // TODO(ocap-authority): hash/KDF (entropy || context) to a fixed-size token. + // For now we return opaque bytes with fresh entropy and context mixed in. + Blob.fromArray(Blob.toArray(entropy) # Blob.toArray(context)); + }; +}; diff --git a/packages/icp-intercanister/src/vattp_boundary.mo b/packages/icp-intercanister/src/vattp_boundary.mo new file mode 100644 index 0000000..9a01e71 --- /dev/null +++ b/packages/icp-intercanister/src/vattp_boundary.mo @@ -0,0 +1,67 @@ +import Principal "mo:base/Principal"; +import Blob "mo:base/Blob"; + +module { + /* + * Refs: + * - e-on-java net/vattp interfaces: + * https://github.com/kpreid/e-on-java/tree/master/src/jsrc/net/vattp + * - Connection.java + * - Handler.java + * - Reactor.java + * - OCapN netlayer draft: + * https://github.com/ocapn/ocapn/blob/main/draft-specifications/Netlayers.md + * + * This file captures VatTP/netlayer-like responsibilities that remain above + * ICP transport substrate concerns. + */ + + public type SessionId = Text; + public type ChannelId = Text; + public type Locator = Text; + + public type Endpoint = { + canister : Principal; + exportId : Text; + }; + + // Transport envelope only; payload semantics live in `ocapn_protocol.mo`. + public type TurnEnvelope = { + sessionId : SessionId; + channelId : ChannelId; + turnId : Nat64; + payload : Blob; + }; + + public type TurnResult = { + #ok : Blob; + #err : Text; + }; + + // Java analogue: Handler.run(record, len) + // Remaining responsibility: transport-level inbound turn handling. + public type InboundHandler = { + handleInbound : shared TurnEnvelope -> async TurnResult; + }; + + /* + * Outstanding wrinkle: + * - Historically, VatTP had explicit connection/session lifecycle concerns + * (see e-on-java `Connection`/`Reactor`). + * - In the current split for this prototype, start-session handshake semantics + * are modeled at OCapN protocol level (`ocapn_protocol.mo` `#start_session`), + * not as separate VatTP-boundary callbacks. + * - For pure inter-canister ICP flows, vat-pair state plus sequence tracking + * acts as the implicit logical channel. + */ + + // Remaining responsibility: locator routing above raw canister ids. + public type Routing = { + resolveLocator : shared Locator -> async ?Endpoint; + }; + + // Consolidated VatTP/netlayer boundary interface. + public type VattpBoundary = actor { + handleInbound : shared TurnEnvelope -> async TurnResult; + }; +};