From 70e2de3af040d18724740213ae8f1a8927c17dc2 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 18 Mar 2026 15:45:48 -0300 Subject: [PATCH 1/3] feat(lifecycle): add full lifecycle E2E test Programmatic test covering the complete Moonlight protocol lifecycle: 1. Deploy Council (Channel Auth contract) 2. Deploy Channel (Privacy Channel, linked to council + SAC) 3. Register Privacy Provider (add_provider) 4. Deposit, Send, Withdraw (reuses existing e2e/ modules) 5. Remove Privacy Provider (remove_provider) Deploys contracts via stellar-sdk (replaces setup.sh for this flow), starts a provider-platform from source, and validates governance events when the node supports it. Run locally: cd lifecycle && deno task lifecycle --- lifecycle/.gitignore | 2 + lifecycle/admin.ts | 52 +++++ lifecycle/config.ts | 83 ++++++++ lifecycle/deno.json | 13 ++ lifecycle/deno.lock | 432 ++++++++++++++++++++++++++++++++++++++++++ lifecycle/deploy.ts | 138 ++++++++++++++ lifecycle/events.ts | 142 ++++++++++++++ lifecycle/main.ts | 263 +++++++++++++++++++++++++ lifecycle/provider.ts | 281 +++++++++++++++++++++++++++ lifecycle/soroban.ts | 83 ++++++++ 10 files changed, 1489 insertions(+) create mode 100644 lifecycle/.gitignore create mode 100644 lifecycle/admin.ts create mode 100644 lifecycle/config.ts create mode 100644 lifecycle/deno.json create mode 100644 lifecycle/deno.lock create mode 100644 lifecycle/deploy.ts create mode 100644 lifecycle/events.ts create mode 100644 lifecycle/main.ts create mode 100644 lifecycle/provider.ts create mode 100644 lifecycle/soroban.ts diff --git a/lifecycle/.gitignore b/lifecycle/.gitignore new file mode 100644 index 0000000..d66ebb1 --- /dev/null +++ b/lifecycle/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +deployment.json diff --git a/lifecycle/admin.ts b/lifecycle/admin.ts new file mode 100644 index 0000000..b62e007 --- /dev/null +++ b/lifecycle/admin.ts @@ -0,0 +1,52 @@ +import { Address, Contract, Keypair, rpc } from "stellar-sdk"; +import { submitTx } from "./soroban.ts"; + +/** + * Register a Privacy Provider in the Channel Auth contract. + * Calls add_provider(provider) — requires admin authorization. + * Returns the transaction response for event extraction. + */ +export async function addProvider( + server: rpc.Server, + admin: Keypair, + networkPassphrase: string, + channelAuthId: string, + providerPublicKey: string, +): Promise { + console.log(` Registering provider ${providerPublicKey.slice(0, 8)}...`); + + const contract = new Contract(channelAuthId); + const op = contract.call( + "add_provider", + new Address(providerPublicKey).toScVal(), + ); + + const result = await submitTx(server, admin, networkPassphrase, op); + console.log(" Provider registered"); + return result; +} + +/** + * Deregister a Privacy Provider from the Channel Auth contract. + * Calls remove_provider(provider) — requires admin authorization. + * Returns the transaction response for event extraction. + */ +export async function removeProvider( + server: rpc.Server, + admin: Keypair, + networkPassphrase: string, + channelAuthId: string, + providerPublicKey: string, +): Promise { + console.log(` Removing provider ${providerPublicKey.slice(0, 8)}...`); + + const contract = new Contract(channelAuthId); + const op = contract.call( + "remove_provider", + new Address(providerPublicKey).toScVal(), + ); + + const result = await submitTx(server, admin, networkPassphrase, op); + console.log(" Provider removed"); + return result; +} diff --git a/lifecycle/config.ts b/lifecycle/config.ts new file mode 100644 index 0000000..20f5fe4 --- /dev/null +++ b/lifecycle/config.ts @@ -0,0 +1,83 @@ +import { NetworkConfig } from "@colibri/core"; +import type { StellarNetworkId } from "@moonlight/moonlight-sdk"; + +const WASM_DIR = new URL("../e2e/wasms", import.meta.url).pathname; + +export interface LifecycleConfig { + networkPassphrase: string; + rpcUrl: string; + horizonUrl: string; + friendbotUrl: string; + allowHttp: boolean; + channelAuthWasmPath: string; + privacyChannelWasmPath: string; + providerPlatformPath: string; + providerUrl?: string; + networkConfig: NetworkConfig; + networkId: StellarNetworkId; +} + +export function loadConfig(): LifecycleConfig { + const network = Deno.env.get("NETWORK") ?? "local"; + + const channelAuthWasmPath = Deno.env.get("CHANNEL_AUTH_WASM") ?? + `${WASM_DIR}/channel_auth_contract.wasm`; + const privacyChannelWasmPath = Deno.env.get("PRIVACY_CHANNEL_WASM") ?? + `${WASM_DIR}/privacy_channel.wasm`; + const providerPlatformPath = Deno.env.get("PROVIDER_PLATFORM_PATH") ?? + `${Deno.env.get("HOME")}/repos/provider-platform`; + const providerUrl = Deno.env.get("PROVIDER_URL"); + + if (network === "testnet") { + const networkPassphrase = "Test SDF Network ; September 2015"; + const rpcUrl = Deno.env.get("STELLAR_RPC_URL") ?? + "https://soroban-testnet.stellar.org"; + const horizonUrl = Deno.env.get("HORIZON_URL") ?? + "https://horizon-testnet.stellar.org"; + const friendbotUrl = Deno.env.get("FRIENDBOT_URL") ?? + "https://friendbot.stellar.org"; + + return { + networkPassphrase, + rpcUrl, + horizonUrl, + friendbotUrl, + allowHttp: false, + channelAuthWasmPath, + privacyChannelWasmPath, + providerPlatformPath, + providerUrl, + networkConfig: NetworkConfig.TestNet({ allowHttp: false }), + networkId: networkPassphrase as StellarNetworkId, + }; + } + + // Local + const networkPassphrase = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? + "Standalone Network ; February 2017"; + const rpcUrl = Deno.env.get("STELLAR_RPC_URL") ?? + "http://localhost:8000/soroban/rpc"; + const horizonUrl = rpcUrl.replace("/soroban/rpc", ""); + const friendbotUrl = Deno.env.get("FRIENDBOT_URL") ?? + "http://localhost:8000/friendbot"; + + return { + networkPassphrase, + rpcUrl, + horizonUrl, + friendbotUrl, + allowHttp: true, + channelAuthWasmPath, + privacyChannelWasmPath, + providerPlatformPath, + providerUrl, + networkConfig: NetworkConfig.CustomNet({ + networkPassphrase, + rpcUrl, + horizonUrl, + friendbotUrl, + allowHttp: true, + }), + networkId: networkPassphrase as StellarNetworkId, + }; +} diff --git a/lifecycle/deno.json b/lifecycle/deno.json new file mode 100644 index 0000000..e0674b2 --- /dev/null +++ b/lifecycle/deno.json @@ -0,0 +1,13 @@ +{ + "nodeModulesDir": "auto", + "tasks": { + "lifecycle": "deno run --allow-all main.ts", + "lifecycle:testnet": "NETWORK=testnet deno run --allow-all main.ts" + }, + "imports": { + "@colibri/core": "jsr:@colibri/core@^0.16.1", + "@moonlight/moonlight-sdk": "jsr:@moonlight/moonlight-sdk@^0.7.0", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "stellar-sdk": "npm:@stellar/stellar-sdk@14.2.0" + } +} diff --git a/lifecycle/deno.lock b/lifecycle/deno.lock new file mode 100644 index 0000000..194f86d --- /dev/null +++ b/lifecycle/deno.lock @@ -0,0 +1,432 @@ +{ + "version": "5", + "specifiers": { + "jsr:@colibri/core@~0.16.1": "0.16.1", + "jsr:@fifo/convee@~0.9.2": "0.9.2", + "jsr:@moonlight/moonlight-sdk@0.7": "0.7.0", + "jsr:@noble/curves@^1.8.0": "1.9.0", + "jsr:@noble/hashes@1.8.0": "1.8.0", + "jsr:@noble/hashes@^1.6.1": "1.8.0", + "jsr:@std/collections@^1.1.3": "1.1.6", + "jsr:@std/toml@^1.0.11": "1.0.11", + "npm:@opentelemetry/api@^1.9.0": "1.9.0", + "npm:@stellar/stellar-sdk@14.2.0": "14.2.0", + "npm:@stellar/stellar-sdk@^14.2.0": "14.6.1", + "npm:@stellar/stellar-sdk@^14.6.1": "14.6.1", + "npm:asn1js@3.0.5": "3.0.5", + "npm:buffer@6.0.3": "6.0.3", + "npm:buffer@^6.0.3": "6.0.3" + }, + "jsr": { + "@colibri/core@0.16.1": { + "integrity": "ad7e77f4647a0742369ced557e38f8848fa0f11da342a7330cf276cef7db622b", + "dependencies": [ + "jsr:@fifo/convee", + "jsr:@std/toml", + "npm:@stellar/stellar-sdk@^14.6.1", + "npm:buffer@^6.0.3" + ] + }, + "@fifo/convee@0.9.2": { + "integrity": "178066e406335a88f90558e89f9a01d7b57f9aa3e675cd2f6548534b8ff14286" + }, + "@moonlight/moonlight-sdk@0.7.0": { + "integrity": "8989c57bee12e29dc5bd545801fa076f56805be66de827bdf21e3d1ca83967e4", + "dependencies": [ + "jsr:@colibri/core", + "jsr:@noble/curves", + "jsr:@noble/hashes@^1.6.1", + "npm:@stellar/stellar-sdk@^14.2.0", + "npm:asn1js", + "npm:buffer@6.0.3" + ] + }, + "@noble/curves@1.9.0": { + "integrity": "efa55b3375b755706462a083060ee91e1f79973568cb670f02e885538ed1661b", + "dependencies": [ + "jsr:@noble/hashes@1.8.0" + ] + }, + "@noble/hashes@1.8.0": { + "integrity": "b52a2fcb4d02f8d8137871564a31f1ee9e2b0d15eedabbf32d2f7333f0abc939" + }, + "@std/collections@1.1.6": { + "integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa" + }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + } + }, + "npm": { + "@noble/curves@1.9.7": { + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dependencies": [ + "@noble/hashes" + ] + }, + "@noble/hashes@1.8.0": { + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + }, + "@opentelemetry/api@1.9.0": { + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" + }, + "@stellar/js-xdr@3.1.2": { + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==" + }, + "@stellar/stellar-base@14.1.0": { + "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "dependencies": [ + "@noble/curves", + "@stellar/js-xdr", + "base32.js", + "bignumber.js", + "buffer", + "sha.js" + ] + }, + "@stellar/stellar-sdk@14.2.0": { + "integrity": "sha512-7nh2ogzLRMhfkIC0fGjn1LHUzk3jqVw8tjAuTt5ADWfL9CSGBL18ILucE9igz2L/RU2AZgeAvhujAnW91Ut/oQ==", + "dependencies": [ + "@stellar/stellar-base", + "axios", + "bignumber.js", + "eventsource", + "feaxios", + "randombytes", + "toml", + "urijs" + ], + "scripts": true + }, + "@stellar/stellar-sdk@14.6.1": { + "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", + "dependencies": [ + "@stellar/stellar-base", + "axios", + "bignumber.js", + "commander", + "eventsource", + "feaxios", + "randombytes", + "toml", + "urijs" + ], + "bin": true + }, + "asn1js@3.0.5": { + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": [ + "pvtsutils", + "pvutils", + "tslib" + ] + }, + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "available-typed-arrays@1.0.7": { + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": [ + "possible-typed-array-names" + ] + }, + "axios@1.13.6": { + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "dependencies": [ + "follow-redirects", + "form-data", + "proxy-from-env" + ] + }, + "base32.js@0.1.0": { + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==" + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bignumber.js@9.3.1": { + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" + }, + "buffer@6.0.3": { + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bind@1.0.8": { + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "get-intrinsic", + "set-function-length" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": [ + "delayed-stream" + ] + }, + "commander@14.0.3": { + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==" + }, + "define-data-property@1.1.4": { + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": [ + "es-define-property", + "es-errors", + "gopd" + ] + }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.1": { + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": [ + "es-errors" + ] + }, + "es-set-tostringtag@2.1.0": { + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": [ + "es-errors", + "get-intrinsic", + "has-tostringtag", + "hasown" + ] + }, + "eventsource@2.0.2": { + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==" + }, + "feaxios@0.0.23": { + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "dependencies": [ + "is-retry-allowed" + ] + }, + "follow-redirects@1.15.11": { + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, + "for-each@0.3.5": { + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dependencies": [ + "is-callable" + ] + }, + "form-data@4.0.5": { + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": [ + "asynckit", + "combined-stream", + "es-set-tostringtag", + "hasown", + "mime-types" + ] + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-property-descriptors@1.0.2": { + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": [ + "es-define-property" + ] + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag@1.0.2": { + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": [ + "has-symbols" + ] + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": [ + "function-bind" + ] + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "is-callable@1.2.7": { + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" + }, + "is-retry-allowed@3.0.0": { + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==" + }, + "is-typed-array@1.1.15": { + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dependencies": [ + "which-typed-array" + ] + }, + "isarray@2.0.5": { + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, + "possible-typed-array-names@1.1.0": { + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" + }, + "proxy-from-env@1.1.0": { + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "pvtsutils@1.3.6": { + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": [ + "tslib" + ] + }, + "pvutils@1.1.5": { + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==" + }, + "randombytes@2.1.0": { + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": [ + "safe-buffer" + ] + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "set-function-length@1.2.2": { + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": [ + "define-data-property", + "es-errors", + "function-bind", + "get-intrinsic", + "gopd", + "has-property-descriptors" + ] + }, + "sha.js@2.4.12": { + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dependencies": [ + "inherits", + "safe-buffer", + "to-buffer" + ], + "bin": true + }, + "to-buffer@1.2.2": { + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dependencies": [ + "isarray", + "safe-buffer", + "typed-array-buffer" + ] + }, + "toml@3.0.0": { + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "typed-array-buffer@1.0.3": { + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dependencies": [ + "call-bound", + "es-errors", + "is-typed-array" + ] + }, + "urijs@1.19.11": { + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" + }, + "which-typed-array@1.1.20": { + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dependencies": [ + "available-typed-arrays", + "call-bind", + "call-bound", + "for-each", + "get-proto", + "gopd", + "has-tostringtag" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@colibri/core@~0.16.1", + "jsr:@moonlight/moonlight-sdk@0.7", + "npm:@opentelemetry/api@^1.9.0", + "npm:@stellar/stellar-sdk@14.2.0" + ] + } +} diff --git a/lifecycle/deploy.ts b/lifecycle/deploy.ts new file mode 100644 index 0000000..3a02ba1 --- /dev/null +++ b/lifecycle/deploy.ts @@ -0,0 +1,138 @@ +import { + Address, + Asset, + hash, + Keypair, + Operation, + rpc, + StrKey, + xdr, +} from "stellar-sdk"; +import { Buffer } from "node:buffer"; +import { submitTx } from "./soroban.ts"; + +/** + * Upload a contract WASM to the network. Returns the 32-byte WASM hash. + */ +export async function uploadWasm( + server: rpc.Server, + signer: Keypair, + networkPassphrase: string, + wasmBytes: Uint8Array, +): Promise { + console.log(` Uploading WASM (${wasmBytes.length} bytes)...`); + + const op = Operation.uploadContractWasm({ wasm: wasmBytes }); + const result = await submitTx(server, signer, networkPassphrase, op); + + // Return value is SCV_BYTES containing the 32-byte WASM hash + const wasmHash = Buffer.from(result.returnValue!.bytes()); + console.log(` WASM hash: ${wasmHash.toString("hex")}`); + return wasmHash; +} + +/** + * Deploy the Channel Auth contract with constructor(admin). + * Returns both the contract ID and the full tx response (for event extraction). + */ +export async function deployChannelAuth( + server: rpc.Server, + admin: Keypair, + networkPassphrase: string, + wasmHash: Buffer, +): Promise<{ contractId: string; txResponse: rpc.Api.GetSuccessfulTransactionResponse }> { + console.log(" Deploying Channel Auth contract..."); + + const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(32))); + const adminAddress = new Address(admin.publicKey()); + + const op = Operation.createCustomContract({ + address: adminAddress, + wasmHash, + salt, + constructorArgs: [adminAddress.toScVal()], + }); + + const txResponse = await submitTx(server, admin, networkPassphrase, op); + const contractId = Address.fromScVal(txResponse.returnValue!).toString(); + console.log(` Channel Auth: ${contractId}`); + return { contractId, txResponse }; +} + +/** + * Deploy the Privacy Channel contract with constructor(admin, auth_contract, asset). + */ +export async function deployPrivacyChannel( + server: rpc.Server, + admin: Keypair, + networkPassphrase: string, + wasmHash: Buffer, + channelAuthId: string, + assetContractId: string, +): Promise { + console.log(" Deploying Privacy Channel contract..."); + + const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(32))); + const adminAddress = new Address(admin.publicKey()); + const authAddress = new Address(channelAuthId); + const assetAddress = new Address(assetContractId); + + const op = Operation.createCustomContract({ + address: adminAddress, + wasmHash, + salt, + constructorArgs: [ + adminAddress.toScVal(), + authAddress.toScVal(), + assetAddress.toScVal(), + ], + }); + + const result = await submitTx(server, admin, networkPassphrase, op); + const contractId = Address.fromScVal(result.returnValue!).toString(); + console.log(` Privacy Channel: ${contractId}`); + return contractId; +} + +/** + * Deploy the native XLM Stellar Asset Contract. + * If already deployed, computes and returns the deterministic contract ID. + */ +export async function getOrDeployNativeSac( + server: rpc.Server, + admin: Keypair, + networkPassphrase: string, +): Promise { + try { + const op = Operation.createStellarAssetContract({ + asset: Asset.native(), + }); + const result = await submitTx(server, admin, networkPassphrase, op); + const contractId = Address.fromScVal(result.returnValue!).toString(); + console.log(` XLM SAC deployed: ${contractId}`); + return contractId; + } catch { + // Already deployed — compute the deterministic contract ID + const contractId = computeNativeSacId(networkPassphrase); + console.log(` XLM SAC (already deployed): ${contractId}`); + return contractId; + } +} + +/** + * Compute the deterministic contract ID for native XLM SAC. + */ +function computeNativeSacId(networkPassphrase: string): string { + const networkId = hash(Buffer.from(networkPassphrase)); + const preimage = xdr.HashIdPreimage.envelopeTypeContractId( + new xdr.HashIdPreimageContractId({ + networkId, + contractIdPreimage: + xdr.ContractIdPreimage.contractIdPreimageFromAsset( + Asset.native().toXDRObject(), + ), + }), + ); + const contractIdHash = hash(preimage.toXDR()); + return StrKey.encodeContract(contractIdHash); +} diff --git a/lifecycle/events.ts b/lifecycle/events.ts new file mode 100644 index 0000000..02cc89a --- /dev/null +++ b/lifecycle/events.ts @@ -0,0 +1,142 @@ +import { rpc, scValToNative, xdr } from "stellar-sdk"; + +export interface ContractEvent { + type: string; + topics: string[]; + value: unknown; +} + +/** + * Extract contract events from a successful Soroban transaction response. + * + * Tries multiple extraction paths: + * 1. diagnosticEventsXdr (requires ENABLE_SOROBAN_DIAGNOSTIC_EVENTS on the node) + * 2. resultMetaXdr → diagnosticEvents + * 3. resultMetaXdr → events + * + * Returns an empty array if contract events aren't available (node config). + */ +export function extractEvents( + txResponse: rpc.Api.GetSuccessfulTransactionResponse, +): ContractEvent[] { + const events: ContractEvent[] = []; + + // Path 1: diagnosticEventsXdr (already-parsed XDR objects from SDK) + // deno-lint-ignore no-explicit-any + const diagXdr = (txResponse as any).diagnosticEventsXdr; + if (Array.isArray(diagXdr)) { + for (const d of diagXdr) { + try { + const event = d.event(); + if (event.type().value === 1) { + // type 1 = contract + events.push(parseContractEvent(event)); + } + } catch { + // Not a DiagnosticEvent or wrong format + } + } + if (events.length > 0) return events; + } + + // Path 2: resultMetaXdr → diagnosticEvents / events + // Use 'any' to handle varying TransactionMeta versions across protocols + try { + const meta = txResponse.resultMetaXdr; + // deno-lint-ignore no-explicit-any + const metaValue = meta.value() as any; + + // Try diagnosticEvents (Protocol 25+ TransactionMetaV4) + if (typeof metaValue.diagnosticEvents === "function") { + for (const d of metaValue.diagnosticEvents()) { + try { + const event = d.event(); + if (event.type().value === 1) { + events.push(parseContractEvent(event)); + } + } catch { /* skip */ } + } + if (events.length > 0) return events; + } + + // Try events (TransactionEvent wrapper) + if (typeof metaValue.events === "function") { + for (const te of metaValue.events()) { + try { + const event = te.event(); + if (event.type().value === 1) { + events.push(parseContractEvent(event)); + } + } catch { /* skip */ } + } + } + + // Try sorobanMeta → events (Protocol 21-23 style) + if (typeof metaValue.sorobanMeta === "function") { + const sorobanMeta = metaValue.sorobanMeta(); + if (sorobanMeta && typeof sorobanMeta.events === "function") { + for (const event of sorobanMeta.events()) { + if (event.type().value === 1) { + events.push(parseContractEvent(event)); + } + } + } + } + } catch { + // Meta parsing failed + } + + // Filter out fee events (emitted by the native SAC for fee deduction/refund) + return events.filter((e) => e.topics[0] !== "fee"); +} + +function parseContractEvent(event: xdr.ContractEvent): ContractEvent { + const topics = event.body().v0().topics().map((t: xdr.ScVal) => { + try { + return String(scValToNative(t)); + } catch { + return t.toXDR("base64"); + } + }); + + const data = event.body().v0().data(); + let value: unknown; + try { + value = scValToNative(data); + } catch { + value = data.toXDR("base64"); + } + + return { type: "contract", topics, value }; +} + +/** + * Verify that an event with the given name exists. + * If events are available, asserts the event exists. + * If no events are available (node config), logs a warning instead of failing. + */ +export function verifyEvent( + events: ContractEvent[], + eventName: string, + txSuccess: boolean, +): { found: boolean; event?: ContractEvent } { + const found = events.find((e) => e.topics[0] === eventName); + + if (found) { + return { found: true, event: found }; + } + + if (events.length === 0 && txSuccess) { + // No events captured — likely node doesn't have diagnostic events enabled + console.log( + ` (event verification skipped — node may not capture contract events)`, + ); + return { found: false }; + } + + // Events ARE present but the expected one is missing — that's a real error + const names = events.map((e) => e.topics[0]); + throw new Error( + `Event "${eventName}" not found. Got: [${names.join(", ")}]`, + ); +} diff --git a/lifecycle/main.ts b/lifecycle/main.ts new file mode 100644 index 0000000..185ae96 --- /dev/null +++ b/lifecycle/main.ts @@ -0,0 +1,263 @@ +import { Keypair } from "stellar-sdk"; +import type { ContractId } from "@colibri/core"; +import type { Config } from "../e2e/config.ts"; +import { loadConfig } from "./config.ts"; +import { createServer } from "./soroban.ts"; +import { + deployChannelAuth, + deployPrivacyChannel, + getOrDeployNativeSac, + uploadWasm, +} from "./deploy.ts"; +import { addProvider, removeProvider } from "./admin.ts"; +import { extractEvents, verifyEvent } from "./events.ts"; +import { startProvider, type ProviderInstance } from "./provider.ts"; + +// Existing E2E modules for the payment flow +import { authenticate } from "../e2e/auth.ts"; +import { deposit } from "../e2e/deposit.ts"; +import { prepareReceive } from "../e2e/receive.ts"; +import { send } from "../e2e/send.ts"; +import { withdraw } from "../e2e/withdraw.ts"; + +const DEPOSIT_AMOUNT = 10; // XLM +const SEND_AMOUNT = 5; // XLM +const WITHDRAW_AMOUNT = 4; // XLM + +const DEPLOYMENT_PATH = new URL("./deployment.json", import.meta.url).pathname; + +async function fundAccount( + friendbotUrl: string, + publicKey: string, +): Promise { + const res = await fetch(`${friendbotUrl}?addr=${publicKey}`); + if (!res.ok) { + throw new Error( + `Friendbot failed for ${publicKey}: ${res.status} ${await res.text()}`, + ); + } +} + +async function main() { + const startTime = Date.now(); + const config = loadConfig(); + let providerInstance: ProviderInstance | null = null; + + console.log("\n=== Moonlight Protocol — Full Lifecycle E2E ===\n"); + + try { + // ── Setup ────────────────────────────────────────────────────── + console.log("[setup] Initializing..."); + const server = createServer(config.rpcUrl, config.allowHttp); + console.log(` Network: ${config.networkPassphrase}`); + console.log(` RPC: ${config.rpcUrl}`); + + const admin = Keypair.random(); + const provider = Keypair.random(); + const treasury = Keypair.random(); + console.log(` Admin: ${admin.publicKey()}`); + console.log(` Provider: ${provider.publicKey()}`); + console.log(` Treasury: ${treasury.publicKey()}`); + + console.log("\n[setup] Funding accounts..."); + await fundAccount(config.friendbotUrl, admin.publicKey()); + console.log(" Admin funded"); + await fundAccount(config.friendbotUrl, treasury.publicKey()); + console.log(" Treasury funded"); + + // ── Step 1: Deploy Council (Channel Auth) ────────────────────── + console.log("\n[1/7] Deploy Council (Channel Auth)"); + const channelAuthWasm = await Deno.readFile(config.channelAuthWasmPath); + const channelAuthHash = await uploadWasm( + server, + admin, + config.networkPassphrase, + channelAuthWasm, + ); + const { contractId: channelAuthId, txResponse: authDeployTx } = + await deployChannelAuth( + server, + admin, + config.networkPassphrase, + channelAuthHash, + ); + + const deployEvents = extractEvents(authDeployTx); + const initResult = verifyEvent(deployEvents, "ContractInitialized", true); + if (initResult.found) console.log(" ContractInitialized event verified"); + + // ── Step 2: Deploy Channel (Privacy Channel) ────────────────── + console.log("\n[2/7] Deploy Channel (Privacy Channel)"); + console.log(" Deploying native XLM SAC..."); + const assetContractId = await getOrDeployNativeSac( + server, + admin, + config.networkPassphrase, + ); + + const privacyChannelWasm = await Deno.readFile( + config.privacyChannelWasmPath, + ); + const privacyChannelHash = await uploadWasm( + server, + admin, + config.networkPassphrase, + privacyChannelWasm, + ); + const channelContractId = await deployPrivacyChannel( + server, + admin, + config.networkPassphrase, + privacyChannelHash, + channelAuthId, + assetContractId, + ); + + // ── Step 3: Add Privacy Provider ────────────────────────────── + console.log("\n[3/7] Add Privacy Provider"); + const addTx = await addProvider( + server, + admin, + config.networkPassphrase, + channelAuthId, + provider.publicKey(), + ); + + const addEvents = extractEvents(addTx); + const addResult = verifyEvent(addEvents, "ProviderAdded", true); + if (addResult.found) console.log(" ProviderAdded event verified"); + + // ── Start provider-platform for payment flow ────────────────── + let providerUrl: string; + if (config.providerUrl) { + // Use externally-managed provider + providerUrl = config.providerUrl; + console.log(`\n[provider] Using existing provider at ${providerUrl}`); + } else { + // Start a fresh provider configured for these contracts + console.log("\n[provider] Starting provider-platform..."); + providerInstance = await startProvider({ + providerPlatformPath: config.providerPlatformPath, + rpcUrl: config.rpcUrl, + channelContractId, + channelAuthId, + assetContractId, + providerSecretKey: provider.secret(), + treasuryPublicKey: treasury.publicKey(), + treasurySecretKey: treasury.secret(), + }); + providerUrl = providerInstance.url; + } + + // Build a Config compatible with the existing E2E modules + const e2eConfig: Config = { + networkPassphrase: config.networkPassphrase, + rpcUrl: config.rpcUrl, + horizonUrl: config.horizonUrl, + friendbotUrl: config.friendbotUrl, + providerUrl, + channelContractId: channelContractId as ContractId, + channelAuthId: channelAuthId as ContractId, + channelAssetContractId: assetContractId as ContractId, + networkConfig: config.networkConfig, + networkId: config.networkId, + providerSecretKey: provider.secret(), + }; + + const alice = Keypair.random(); + const bob = Keypair.random(); + console.log(`\n Alice: ${alice.publicKey()}`); + console.log(` Bob: ${bob.publicKey()}`); + + await fundAccount(config.friendbotUrl, alice.publicKey()); + await fundAccount(config.friendbotUrl, bob.publicKey()); + console.log(" Users funded"); + + // ── Step 4: Deposit ────────────────────────────────────────── + console.log(`\n[4/7] Deposit (${DEPOSIT_AMOUNT} XLM)`); + const aliceJwt = await authenticate(alice, e2eConfig); + console.log(" Alice authenticated"); + await deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, e2eConfig); + console.log(" Deposit complete"); + + // ── Step 5: Send ───────────────────────────────────────────── + console.log(`\n[5/7] Send (${SEND_AMOUNT} XLM)`); + const bobJwt = await authenticate(bob, e2eConfig); + console.log(" Bob authenticated"); + const receiverOps = await prepareReceive( + bob.secret(), + SEND_AMOUNT, + e2eConfig, + ); + await send( + alice.secret(), + receiverOps, + SEND_AMOUNT, + aliceJwt, + e2eConfig, + ); + console.log(" Send complete"); + + // ── Step 6: Withdraw ───────────────────────────────────────── + console.log(`\n[6/7] Withdraw (${WITHDRAW_AMOUNT} XLM)`); + await withdraw( + bob.secret(), + bob.publicKey(), + WITHDRAW_AMOUNT, + bobJwt, + e2eConfig, + ); + console.log(" Withdraw complete"); + + // ── Step 7: Remove Privacy Provider ────────────────────────── + console.log("\n[7/7] Remove Privacy Provider"); + const removeTx = await removeProvider( + server, + admin, + config.networkPassphrase, + channelAuthId, + provider.publicKey(), + ); + + const removeEvents = extractEvents(removeTx); + const removeResult = verifyEvent(removeEvents, "ProviderRemoved", true); + if (removeResult.found) console.log(" ProviderRemoved event verified"); + + // ── Summary ────────────────────────────────────────────────── + const deployment = { + channelAuthId, + channelContractId, + assetContractId, + adminPublicKey: admin.publicKey(), + providerPublicKey: provider.publicKey(), + treasuryPublicKey: treasury.publicKey(), + }; + await Deno.writeTextFile( + DEPLOYMENT_PATH, + JSON.stringify(deployment, null, 2), + ); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + console.log("\n────────────────────────────────────────────────"); + console.log(" Contract IDs:"); + console.log(` XLM SAC: ${assetContractId}`); + console.log(` Channel Auth: ${channelAuthId}`); + console.log(` Privacy Channel: ${channelContractId}`); + console.log(` Config written to: ${DEPLOYMENT_PATH}`); + console.log(`\n=== Lifecycle E2E passed in ${elapsed}s ===`); + } finally { + // Always clean up the provider + if (providerInstance) { + console.log("\n[cleanup] Stopping provider-platform..."); + await providerInstance.stop(); + console.log(" Cleaned up"); + } + } +} + +main().catch((err) => { + console.error("\n=== Lifecycle E2E FAILED ==="); + console.error(err); + Deno.exit(1); +}); diff --git a/lifecycle/provider.ts b/lifecycle/provider.ts new file mode 100644 index 0000000..78ff35a --- /dev/null +++ b/lifecycle/provider.ts @@ -0,0 +1,281 @@ +/** + * Manages a provider-platform instance for local dev lifecycle testing. + * Starts PostgreSQL, writes config, runs migrations, and starts the provider from source. + * + * In CI, docker-compose handles this instead (see docker-compose.yml). + */ + +const PG_CONTAINER = "lifecycle-e2e-db"; +const PG_PORT = 5452; +const PG_DB = "lifecycle_e2e_db"; +const PG_USER = "admin"; +const PG_PASS = "devpass"; +const PROVIDER_PORT = 3030; + +export interface ProviderInstance { + url: string; + stop: () => Promise; +} + +export interface ProviderStartOptions { + providerPlatformPath: string; + rpcUrl: string; + channelContractId: string; + channelAuthId: string; + assetContractId: string; + providerSecretKey: string; + treasuryPublicKey: string; + treasurySecretKey: string; +} + +export async function startProvider( + opts: ProviderStartOptions, +): Promise { + const { providerPlatformPath } = opts; + const databaseUrl = + `postgresql://${PG_USER}:${PG_PASS}@localhost:${PG_PORT}/${PG_DB}`; + + // 1. Start PostgreSQL + console.log(" Starting PostgreSQL..."); + await ensurePostgres(); + + // 2. Back up existing .env and write ours + const envBackup = await backupAndWriteEnv(opts, databaseUrl); + + // 3. Install deps (idempotent) + await run("deno", ["install"], providerPlatformPath, "install deps"); + + // 4. Run migrations + console.log(" Running migrations..."); + await run( + "deno", + ["-A", "--node-modules-dir", "npm:drizzle-kit", "migrate"], + providerPlatformPath, + "migrations", + ); + + // 5. Start provider-platform + console.log(" Starting provider-platform..."); + const child = new Deno.Command("deno", { + args: ["run", "--allow-all", "--unstable-kv", "src/main.ts"], + cwd: providerPlatformPath, + stdout: "piped", + stderr: "piped", + }).spawn(); + + // Drain stdout to log file + const logPath = new URL("./provider.log", import.meta.url).pathname; + const logFile = await Deno.open(logPath, { + write: true, + create: true, + truncate: true, + }); + const logWriter = logFile.writable.getWriter(); + child.stdout + .pipeTo( + new WritableStream({ + write(chunk) { + return logWriter.write(chunk); + }, + }), + ) + .catch(() => {}); + child.stderr + .pipeTo(new WritableStream({ write() {} })) + .catch(() => {}); + + // 6. Wait for ready + const url = `http://localhost:${PROVIDER_PORT}`; + await waitForReady(url); + console.log(` Provider ready at ${url} (log: ${logPath})`); + + return { + url, + async stop() { + try { + child.kill("SIGTERM"); + } catch { /* already dead */ } + try { + await child.status; + } catch { /* ignore */ } + try { + logWriter.close(); + } catch { /* ignore */ } + await restoreEnv(providerPlatformPath, envBackup); + await stopPostgres(); + }, + }; +} + +// ── PostgreSQL ──────────────────────────────────────────────────── + +async function ensurePostgres(): Promise { + const check = await docker([ + "ps", + "--filter", + `name=^${PG_CONTAINER}$`, + "--format", + "{{.Names}}", + ]); + + if (decode(check.stdout).trim() === PG_CONTAINER) { + console.log(" PostgreSQL already running"); + return; + } + + await docker(["rm", "-f", PG_CONTAINER]); + + const start = await docker([ + "run", "-d", "--name", PG_CONTAINER, + "-p", `${PG_PORT}:5432`, + "-e", `POSTGRES_USER=${PG_USER}`, + "-e", `POSTGRES_PASSWORD=${PG_PASS}`, + "-e", `POSTGRES_DB=${PG_DB}`, + "postgres:18", + ]); + + if (!start.success) { + throw new Error( + `Failed to start PostgreSQL: ${decode(start.stderr)}`, + ); + } + + for (let i = 0; i < 30; i++) { + const ready = await docker([ + "exec", PG_CONTAINER, "pg_isready", "-U", PG_USER, + ]); + if (ready.success) { + console.log(" PostgreSQL ready"); + return; + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error("PostgreSQL did not become ready after 30s"); +} + +async function stopPostgres(): Promise { + await docker(["rm", "-f", PG_CONTAINER]); +} + +// ── .env management ────────────────────────────────────────────── + +async function backupAndWriteEnv( + opts: ProviderStartOptions, + databaseUrl: string, +): Promise { + const envPath = `${opts.providerPlatformPath}/.env`; + let backupPath: string | null = null; + + try { + const existing = await Deno.readTextFile(envPath); + backupPath = `${envPath}.lifecycle-backup`; + await Deno.writeTextFile(backupPath, existing); + } catch { + // No existing .env + } + + const content = `# Generated by lifecycle E2E test — will be restored after test +PORT=${PROVIDER_PORT} +MODE=development +LOG_LEVEL=WARN +SERVICE_DOMAIN=localhost + +DATABASE_URL=${databaseUrl} + +NETWORK=local +STELLAR_RPC_URL=${opts.rpcUrl} +NETWORK_FEE=1000000000 +CHANNEL_CONTRACT_ID=${opts.channelContractId} +CHANNEL_AUTH_ID=${opts.channelAuthId} +CHANNEL_ASSET_CODE=XLM +CHANNEL_ASSET_CONTRACT_ID=${opts.assetContractId} + +PROVIDER_SK=${opts.providerSecretKey} +OPEX_PUBLIC=${opts.treasuryPublicKey} +OPEX_SECRET=${opts.treasurySecretKey} + +SERVICE_AUTH_SECRET= +SERVICE_FEE=100 +CHALLENGE_TTL=900 +SESSION_TTL=21600 + +MEMPOOL_SLOT_CAPACITY=100 +MEMPOOL_EXPENSIVE_OP_WEIGHT=10 +MEMPOOL_CHEAP_OP_WEIGHT=1 +MEMPOOL_EXECUTOR_INTERVAL_MS=5000 +MEMPOOL_VERIFIER_INTERVAL_MS=10000 +MEMPOOL_TTL_CHECK_INTERVAL_MS=60000 +`; + + await Deno.writeTextFile(envPath, content); + return backupPath; +} + +async function restoreEnv( + providerPath: string, + backupPath: string | null, +): Promise { + const envPath = `${providerPath}/.env`; + if (backupPath) { + try { + const content = await Deno.readTextFile(backupPath); + await Deno.writeTextFile(envPath, content); + await Deno.remove(backupPath); + } catch { /* best effort */ } + } else { + try { + await Deno.remove(envPath); + } catch { /* ignore */ } + } +} + +// ── Helpers ────────────────────────────────────────────────────── + +async function docker(args: string[]): Promise { + return new Deno.Command("docker", { + args, + stdout: "piped", + stderr: "piped", + }).output(); +} + +function decode(buf: Uint8Array): string { + return new TextDecoder().decode(buf); +} + +async function run( + cmd: string, + args: string[], + cwd: string, + label: string, +): Promise { + const result = await new Deno.Command(cmd, { + args, + cwd, + stdout: "piped", + stderr: "piped", + }).output(); + + if (!result.success) { + throw new Error(`${label} failed:\n${decode(result.stderr)}`); + } +} + +async function waitForReady( + url: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fetch( + `${url}/api/v1/stellar/auth?account=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`, + ); + return; + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`Provider not ready after ${timeoutMs}ms`); +} diff --git a/lifecycle/soroban.ts b/lifecycle/soroban.ts new file mode 100644 index 0000000..30d3093 --- /dev/null +++ b/lifecycle/soroban.ts @@ -0,0 +1,83 @@ +import { Keypair, TransactionBuilder, rpc, xdr } from "stellar-sdk"; + +const FEE = "10000000"; // 1 XLM — generous for Soroban operations + +export function createServer( + rpcUrl: string, + allowHttp = true, +): rpc.Server { + return new rpc.Server(rpcUrl, { allowHttp }); +} + +/** + * Build, simulate, assemble, sign, submit, and poll a Soroban transaction. + * Returns the successful transaction response. + */ +export async function submitTx( + server: rpc.Server, + signer: Keypair, + networkPassphrase: string, + operation: xdr.Operation, +): Promise { + const account = await server.getAccount(signer.publicKey()); + + const tx = new TransactionBuilder(account, { + fee: FEE, + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(`Simulation failed: ${JSON.stringify(sim.error)}`); + } + + const prepared = rpc.assembleTransaction(tx, sim).build(); + prepared.sign(signer); + + const sent = await server.sendTransaction(prepared); + if (sent.status === "ERROR") { + throw new Error( + `Transaction send error: ${JSON.stringify(sent.errorResult)}`, + ); + } + + return poll(server, sent.hash); +} + +async function poll( + server: rpc.Server, + hash: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const resp = await server.getTransaction(hash); + if (resp.status === "SUCCESS") { + return resp as rpc.Api.GetSuccessfulTransactionResponse; + } + if (resp.status === "FAILED") { + throw new Error(`Transaction failed: ${hash}`); + } + // NOT_FOUND — keep polling + await new Promise((r) => setTimeout(r, 2000)); + } + + throw new Error(`Transaction ${hash} timed out after ${timeoutMs}ms`); +} + +/** + * Get the current ledger sequence from the RPC. + */ +export async function getLatestLedger(rpcUrl: string): Promise { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestLedger" }), + }); + const data = await res.json(); + return data.result.sequence; +} From c60e56c5cebd9a3c547aa18cfb4eb9cb4388fb26 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 18 Mar 2026 15:46:25 -0300 Subject: [PATCH 2/3] feat(lifecycle): add docker-compose for CI Splits the lifecycle test into setup and test phases for docker-compose: - ci-setup.ts: deploys contracts, registers provider, writes config to shared volume (provider.env + contracts.env) - ci-test.ts: reads config, runs payment flow, removes provider - docker-compose.yml: stellar, db, setup, provider, test-runner services with isolated network and health-check ordering Each compose run gets its own network, avoiding conflicts with the existing e2e/ docker-compose. --- lifecycle/ci-setup.ts | 134 ++++++++++++++++++++++++++++++++ lifecycle/ci-test.ts | 144 +++++++++++++++++++++++++++++++++++ lifecycle/docker-compose.yml | 104 +++++++++++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 lifecycle/ci-setup.ts create mode 100644 lifecycle/ci-test.ts create mode 100644 lifecycle/docker-compose.yml diff --git a/lifecycle/ci-setup.ts b/lifecycle/ci-setup.ts new file mode 100644 index 0000000..6dc1c3d --- /dev/null +++ b/lifecycle/ci-setup.ts @@ -0,0 +1,134 @@ +/** + * CI Setup phase — runs inside docker-compose. + * Deploys contracts, registers provider, writes config for the provider + * and test-runner containers via the shared /config volume. + */ +import { Keypair } from "stellar-sdk"; +import { createServer } from "./soroban.ts"; +import { + deployChannelAuth, + deployPrivacyChannel, + getOrDeployNativeSac, + uploadWasm, +} from "./deploy.ts"; +import { addProvider } from "./admin.ts"; +import { extractEvents, verifyEvent } from "./events.ts"; + +const RPC_URL = Deno.env.get("STELLAR_RPC_URL")!; +const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL")!; +const NETWORK_PASSPHRASE = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? + "Standalone Network ; February 2017"; +const CONFIG_DIR = Deno.env.get("CONFIG_DIR") ?? "/config"; + +async function fundAccount(publicKey: string): Promise { + const res = await fetch(`${FRIENDBOT_URL}?addr=${publicKey}`); + if (!res.ok) { + throw new Error( + `Friendbot failed for ${publicKey}: ${res.status} ${await res.text()}`, + ); + } +} + +async function main() { + console.log("[ci-setup] Starting..."); + const server = createServer(RPC_URL); + + const admin = Keypair.random(); + const provider = Keypair.random(); + const treasury = Keypair.random(); + console.log(` Admin: ${admin.publicKey()}`); + console.log(` Provider: ${provider.publicKey()}`); + console.log(` Treasury: ${treasury.publicKey()}`); + + console.log("[ci-setup] Funding accounts..."); + await fundAccount(admin.publicKey()); + await fundAccount(treasury.publicKey()); + + // Step 1: Deploy Council (Channel Auth) + console.log("[ci-setup] Deploying Channel Auth..."); + const channelAuthWasm = await Deno.readFile("/wasms/channel_auth_contract.wasm"); + const channelAuthHash = await uploadWasm( + server, admin, NETWORK_PASSPHRASE, channelAuthWasm, + ); + const { contractId: channelAuthId, txResponse: authDeployTx } = + await deployChannelAuth(server, admin, NETWORK_PASSPHRASE, channelAuthHash); + + const deployEvents = extractEvents(authDeployTx); + const initResult = verifyEvent(deployEvents, "ContractInitialized", true); + if (initResult.found) console.log(" ContractInitialized event verified"); + + // Step 2: Deploy Channel (Privacy Channel) + console.log("[ci-setup] Deploying SAC + Privacy Channel..."); + const assetContractId = await getOrDeployNativeSac( + server, admin, NETWORK_PASSPHRASE, + ); + + const privacyChannelWasm = await Deno.readFile("/wasms/privacy_channel.wasm"); + const privacyChannelHash = await uploadWasm( + server, admin, NETWORK_PASSPHRASE, privacyChannelWasm, + ); + const channelContractId = await deployPrivacyChannel( + server, admin, NETWORK_PASSPHRASE, + privacyChannelHash, channelAuthId, assetContractId, + ); + + // Step 3: Register provider + console.log("[ci-setup] Registering provider..."); + const addTx = await addProvider( + server, admin, NETWORK_PASSPHRASE, channelAuthId, provider.publicKey(), + ); + const addEvents = extractEvents(addTx); + const addResult = verifyEvent(addEvents, "ProviderAdded", true); + if (addResult.found) console.log(" ProviderAdded event verified"); + + // Write provider.env (read by provider-entrypoint.sh) + const providerEnv = `PORT=3000 +MODE=development +LOG_LEVEL=TRACE +SERVICE_DOMAIN=localhost + +STELLAR_RPC_URL=${RPC_URL} +NETWORK=local +NETWORK_FEE=1000000000 +CHANNEL_CONTRACT_ID=${channelContractId} +CHANNEL_AUTH_ID=${channelAuthId} +CHANNEL_ASSET_CODE=XLM +CHANNEL_ASSET_CONTRACT_ID=${assetContractId} + +PROVIDER_SK=${provider.secret()} +OPEX_PUBLIC=${treasury.publicKey()} +OPEX_SECRET=${treasury.secret()} + +SERVICE_AUTH_SECRET= +SERVICE_FEE=100 +CHALLENGE_TTL=900 +SESSION_TTL=21600 + +MEMPOOL_SLOT_CAPACITY=100 +MEMPOOL_EXPENSIVE_OP_WEIGHT=10 +MEMPOOL_CHEAP_OP_WEIGHT=1 +MEMPOOL_EXECUTOR_INTERVAL_MS=5000 +MEMPOOL_VERIFIER_INTERVAL_MS=10000 +MEMPOOL_TTL_CHECK_INTERVAL_MS=60000 +`; + await Deno.writeTextFile(`${CONFIG_DIR}/provider.env`, providerEnv); + + // Write contracts.env (read by test-runner) + const contractsEnv = `CHANNEL_CONTRACT_ID=${channelContractId} +CHANNEL_AUTH_ID=${channelAuthId} +CHANNEL_ASSET_CONTRACT_ID=${assetContractId} +PROVIDER_PK=${provider.publicKey()} +PROVIDER_SK=${provider.secret()} +TREASURY_PK=${treasury.publicKey()} +ADMIN_SK=${admin.secret()} +`; + await Deno.writeTextFile(`${CONFIG_DIR}/contracts.env`, contractsEnv); + + console.log(`[ci-setup] Config written to ${CONFIG_DIR}/`); + console.log("[ci-setup] Done."); +} + +main().catch((err) => { + console.error("[ci-setup] FAILED:", err); + Deno.exit(1); +}); diff --git a/lifecycle/ci-test.ts b/lifecycle/ci-test.ts new file mode 100644 index 0000000..3c9f8b6 --- /dev/null +++ b/lifecycle/ci-test.ts @@ -0,0 +1,144 @@ +/** + * CI Test phase — runs inside docker-compose after setup + provider are ready. + * Reads config from /config, runs payment flow (steps 4-6), removes provider (step 7). + */ +import { Keypair } from "stellar-sdk"; +import { NetworkConfig, type ContractId } from "@colibri/core"; +import type { StellarNetworkId } from "@moonlight/moonlight-sdk"; +import type { Config } from "../e2e/config.ts"; +import { createServer } from "./soroban.ts"; +import { removeProvider } from "./admin.ts"; +import { extractEvents, verifyEvent } from "./events.ts"; +import { authenticate } from "../e2e/auth.ts"; +import { deposit } from "../e2e/deposit.ts"; +import { prepareReceive } from "../e2e/receive.ts"; +import { send } from "../e2e/send.ts"; +import { withdraw } from "../e2e/withdraw.ts"; + +const RPC_URL = Deno.env.get("STELLAR_RPC_URL")!; +const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL")!; +const PROVIDER_URL = Deno.env.get("PROVIDER_URL")!; +const NETWORK_PASSPHRASE = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? + "Standalone Network ; February 2017"; +const CONFIG_DIR = Deno.env.get("CONFIG_DIR") ?? "/config"; + +const DEPOSIT_AMOUNT = 10; +const SEND_AMOUNT = 5; +const WITHDRAW_AMOUNT = 4; + +function loadEnvFile(path: string): Record { + const env: Record = {}; + const content = Deno.readTextFileSync(path); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); + } + return env; +} + +async function fundAccount(publicKey: string): Promise { + const res = await fetch(`${FRIENDBOT_URL}?addr=${publicKey}`); + if (!res.ok) { + throw new Error( + `Friendbot failed for ${publicKey}: ${res.status} ${await res.text()}`, + ); + } +} + +async function main() { + const startTime = Date.now(); + + console.log("[ci-test] Loading config..."); + const contracts = loadEnvFile(`${CONFIG_DIR}/contracts.env`); + const channelContractId = contracts["CHANNEL_CONTRACT_ID"]; + const channelAuthId = contracts["CHANNEL_AUTH_ID"]; + const assetContractId = contracts["CHANNEL_ASSET_CONTRACT_ID"]; + const providerSecretKey = contracts["PROVIDER_SK"]; + const adminSecretKey = contracts["ADMIN_SK"]; + + console.log(` Channel: ${channelContractId}`); + console.log(` Auth: ${channelAuthId}`); + console.log(` Asset: ${assetContractId}`); + console.log(` Provider: ${PROVIDER_URL}`); + + const horizonUrl = RPC_URL.replace("/soroban/rpc", ""); + const networkConfig = NetworkConfig.CustomNet({ + networkPassphrase: NETWORK_PASSPHRASE, + rpcUrl: RPC_URL, + horizonUrl, + friendbotUrl: FRIENDBOT_URL, + allowHttp: true, + }); + + const e2eConfig: Config = { + networkPassphrase: NETWORK_PASSPHRASE, + rpcUrl: RPC_URL, + horizonUrl, + friendbotUrl: FRIENDBOT_URL, + providerUrl: PROVIDER_URL, + channelContractId: channelContractId as ContractId, + channelAuthId: channelAuthId as ContractId, + channelAssetContractId: assetContractId as ContractId, + networkConfig, + networkId: NETWORK_PASSPHRASE as StellarNetworkId, + providerSecretKey, + }; + + const alice = Keypair.random(); + const bob = Keypair.random(); + console.log(` Alice: ${alice.publicKey()}`); + console.log(` Bob: ${bob.publicKey()}`); + + await fundAccount(alice.publicKey()); + await fundAccount(bob.publicKey()); + console.log(" Users funded"); + + // Step 4: Deposit + console.log(`\n[4/7] Deposit (${DEPOSIT_AMOUNT} XLM)`); + const aliceJwt = await authenticate(alice, e2eConfig); + console.log(" Alice authenticated"); + await deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, e2eConfig); + console.log(" Deposit complete"); + + // Step 5: Send + console.log(`\n[5/7] Send (${SEND_AMOUNT} XLM)`); + const bobJwt = await authenticate(bob, e2eConfig); + console.log(" Bob authenticated"); + const receiverOps = await prepareReceive(bob.secret(), SEND_AMOUNT, e2eConfig); + await send(alice.secret(), receiverOps, SEND_AMOUNT, aliceJwt, e2eConfig); + console.log(" Send complete"); + + // Step 6: Withdraw + console.log(`\n[6/7] Withdraw (${WITHDRAW_AMOUNT} XLM)`); + await withdraw( + bob.secret(), bob.publicKey(), WITHDRAW_AMOUNT, bobJwt, e2eConfig, + ); + console.log(" Withdraw complete"); + + // Step 7: Remove provider + console.log("\n[7/7] Remove Privacy Provider"); + const admin = Keypair.fromSecret(adminSecretKey); + const server = createServer(RPC_URL); + const removeTx = await removeProvider( + server, + admin, + NETWORK_PASSPHRASE, + channelAuthId, + Keypair.fromSecret(providerSecretKey).publicKey(), + ); + const removeEvents = extractEvents(removeTx); + const removeResult = verifyEvent(removeEvents, "ProviderRemoved", true); + if (removeResult.found) console.log(" ProviderRemoved event verified"); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`\n=== Lifecycle E2E passed in ${elapsed}s ===`); +} + +main().catch((err) => { + console.error("\n=== Lifecycle E2E FAILED ==="); + console.error(err); + Deno.exit(1); +}); diff --git a/lifecycle/docker-compose.yml b/lifecycle/docker-compose.yml new file mode 100644 index 0000000..14eb4b5 --- /dev/null +++ b/lifecycle/docker-compose.yml @@ -0,0 +1,104 @@ +services: + stellar: + image: stellar/quickstart:latest + command: --local --limits unlimited + ports: + - "8000:8000" + healthcheck: + test: > + curl -sf http://localhost:8000/soroban/rpc -X POST + -H "Content-Type: application/json" + -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' + | grep -q healthy + interval: 5s + timeout: 5s + retries: 60 + start_period: 10s + + db: + image: postgres:18 + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: devpass + POSTGRES_DB: lifecycle_db + healthcheck: + test: ["CMD-SHELL", "pg_isready -U admin -d lifecycle_db"] + interval: 5s + timeout: 5s + retries: 5 + + setup: + image: denoland/deno:latest + depends_on: + stellar: + condition: service_healthy + volumes: + - ${WASM_DIR:-../e2e}/wasms:/wasms:ro + - .:/lifecycle-src:ro + - ../e2e:/e2e-src:ro + - shared-config:/config + working_dir: /app + environment: + STELLAR_RPC_URL: http://stellar:8000/soroban/rpc + STELLAR_NETWORK_PASSPHRASE: "Standalone Network ; February 2017" + FRIENDBOT_URL: http://stellar:8000/friendbot + CONFIG_DIR: /config + entrypoint: + - sh + - -c + - | + cp /lifecycle-src/*.ts /lifecycle-src/deno.json . && cp /lifecycle-src/deno.lock . 2>/dev/null || true + mkdir -p ../e2e && cp /e2e-src/*.ts /e2e-src/deno.json ../e2e/ && cp /e2e-src/deno.lock ../e2e/ 2>/dev/null || true + deno install + deno run --allow-all ci-setup.ts + + provider: + image: ${PROVIDER_IMAGE:-provider-platform:local} + depends_on: + db: + condition: service_healthy + setup: + condition: service_completed_successfully + volumes: + - shared-config:/config:ro + - ../e2e/provider-entrypoint.sh:/app/entrypoint.sh:ro + entrypoint: ["sh", "/app/entrypoint.sh"] + environment: + DATABASE_URL: postgresql://admin:devpass@db:5432/lifecycle_db + + test-runner: + image: denoland/deno:latest + depends_on: + provider: + condition: service_started + volumes: + - shared-config:/config:ro + - .:/lifecycle-src:ro + - ../e2e:/e2e-src:ro + working_dir: /app + environment: + PROVIDER_URL: http://provider:3000 + STELLAR_RPC_URL: http://stellar:8000/soroban/rpc + STELLAR_NETWORK_PASSPHRASE: "Standalone Network ; February 2017" + FRIENDBOT_URL: http://stellar:8000/friendbot + CONFIG_DIR: /config + entrypoint: + - sh + - -c + - | + cp /lifecycle-src/*.ts /lifecycle-src/deno.json . && cp /lifecycle-src/deno.lock . 2>/dev/null || true + mkdir -p ../e2e && cp /e2e-src/*.ts /e2e-src/deno.json ../e2e/ && cp /e2e-src/deno.lock ../e2e/ 2>/dev/null || true + deno install + echo "Waiting for provider..." + for i in $(seq 1 60); do + if deno eval "try { await fetch('$PROVIDER_URL'); Deno.exit(0) } catch { Deno.exit(1) }" 2>/dev/null; then + echo "Provider is ready." + break + fi + if [ "$i" -eq 60 ]; then echo "Provider not ready after 60s"; exit 1; fi + sleep 2 + done + deno run --allow-all ci-test.ts + +volumes: + shared-config: From 22d1bed88711c9e9817dd44c38c2bb06febe859d Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 18 Mar 2026 15:47:13 -0300 Subject: [PATCH 3/3] ci: add lifecycle E2E reusable workflow Reusable workflow (workflow_call) for the lifecycle E2E test, matching the same input/secret interface as e2e-reusable.yml: - contracts_artifact / contracts_version for WASMs - provider_version / provider_image_override for provider image - E2E_TRIGGER_TOKEN secret Uses docker compose in lifecycle/ directory. Caller repos add a lifecycle job alongside the existing e2e job. --- .github/workflows/lifecycle-reusable.yml | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/lifecycle-reusable.yml diff --git a/.github/workflows/lifecycle-reusable.yml b/.github/workflows/lifecycle-reusable.yml new file mode 100644 index 0000000..0940a7b --- /dev/null +++ b/.github/workflows/lifecycle-reusable.yml @@ -0,0 +1,84 @@ +name: Lifecycle E2E (Reusable) + +on: + workflow_call: + inputs: + contracts_version: + description: "soroban-core release tag (e.g. v0.1.0)" + type: string + default: "latest" + provider_version: + description: "provider-platform image tag (e.g. 0.2.0)" + type: string + default: "latest" + contracts_artifact: + description: "Name of a workflow artifact containing wasms (overrides release download)" + type: string + default: "" + provider_image_override: + description: "Full image ref override for provider-platform (e.g. ghcr.io/org/repo:sha-abc123)" + type: string + default: "" + secrets: + E2E_TRIGGER_TOKEN: + required: true + +env: + REGISTRY: ghcr.io + ORG: moonlight-protocol + +jobs: + lifecycle: + runs-on: ubuntu-latest + permissions: + packages: read + steps: + - uses: actions/checkout@v4 + with: + repository: moonlight-protocol/local-dev + ref: main + token: ${{ secrets.E2E_TRIGGER_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.E2E_TRIGGER_TOKEN }} + + - name: Download contract wasms from artifact + if: inputs.contracts_artifact != '' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.contracts_artifact }} + path: e2e/wasms + + - name: Download contract wasms from release + if: inputs.contracts_artifact == '' + env: + GH_TOKEN: ${{ secrets.E2E_TRIGGER_TOKEN }} + VERSION: ${{ inputs.contracts_version }} + run: | + mkdir -p e2e/wasms + if [ "$VERSION" = "latest" ]; then + gh release download --repo ${{ env.ORG }}/soroban-core -p '*.wasm' -D e2e/wasms/ + else + gh release download "$VERSION" --repo ${{ env.ORG }}/soroban-core -p '*.wasm' -D e2e/wasms/ + fi + ls -la e2e/wasms/ + + - name: Run Lifecycle E2E + env: + PROVIDER_IMAGE: ${{ inputs.provider_image_override != '' && inputs.provider_image_override || format('{0}/{1}/provider-platform:{2}', env.REGISTRY, env.ORG, inputs.provider_version) }} + working-directory: lifecycle + run: | + docker compose up -d + EXIT_CODE=$(docker wait lifecycle-test-runner-1) + echo "--- test-runner logs ---" + docker compose logs test-runner + echo "--- setup logs ---" + docker compose logs setup + echo "--- provider logs ---" + docker compose logs provider + docker compose down + exit $EXIT_CODE