diff --git a/README.md b/README.md index 264fad9..283e6c2 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,28 @@ deno task verify-otel open http://localhost:16686 ``` +### Run testnet E2E + +Runs the same E2E flow against the live testnet provider and verifies traces in Grafana Cloud. + +```bash +cd testnet + +# Run E2E against testnet +OTEL_DENO=true OTEL_SERVICE_NAME=moonlight-e2e \ + OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-ca-east-0.grafana.net/otlp \ + OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ + OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic " \ + deno run --allow-all main.ts + +# Verify traces arrived in Grafana Cloud Tempo +TEMPO_URL=https://tempo-prod-13-prod-ca-east-0.grafana.net/tempo \ + TEMPO_AUTH="Basic " \ + deno run --allow-all verify-otel.ts +``` + +The verify script uses trace-by-ID lookups (deterministic) for all SDK and provider request-path checks, and a targeted TraceQL search for background service spans only. + ## E2E in CI See [e2e/README.md](e2e/README.md) for the Docker compose setup that runs E2E tests in CI without any host dependencies. diff --git a/testnet/.gitignore b/testnet/.gitignore new file mode 100644 index 0000000..6bf937b --- /dev/null +++ b/testnet/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +e2e-trace-ids.json diff --git a/testnet/config.ts b/testnet/config.ts new file mode 100644 index 0000000..3ea847c --- /dev/null +++ b/testnet/config.ts @@ -0,0 +1,49 @@ +import { NetworkConfig, type ContractId } from "@colibri/core"; +import type { StellarNetworkId } from "@moonlight/moonlight-sdk"; + +export interface Config { + networkPassphrase: string; + rpcUrl: string; + horizonUrl: string; + friendbotUrl: string; + providerUrl: string; + channelContractId: ContractId; + channelAuthId: ContractId; + channelAssetContractId: ContractId; + networkConfig: NetworkConfig; + networkId: StellarNetworkId; +} + +export function loadConfig(): Config { + 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"; + const providerUrl = Deno.env.get("PROVIDER_URL") ?? + "https://moonlight-beta-privacy-provider-a.fly.dev"; + + const channelContractId = (Deno.env.get("CHANNEL_CONTRACT_ID") ?? + "CDMZSHMT2AIL2UG7XBOHZKXM6FY3MUP75HAXUUSAHLGRQ2VWPGYKPM5T") as ContractId; + const channelAuthId = (Deno.env.get("CHANNEL_AUTH_ID") ?? + "CAF7DFHTPSYIW5543WBXJODZCDI5WF5SSHBXGMPKFOYPFRDVWFDNBGX7") as ContractId; + const channelAssetContractId = (Deno.env.get("CHANNEL_ASSET_CONTRACT_ID") ?? + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC") as ContractId; + + const networkConfig = NetworkConfig.TestNet({ allowHttp: false }); + + return { + networkPassphrase, + rpcUrl, + horizonUrl, + friendbotUrl, + providerUrl, + channelContractId, + channelAuthId, + channelAssetContractId, + networkConfig, + networkId: networkPassphrase as StellarNetworkId, + }; +} diff --git a/testnet/deno.json b/testnet/deno.json new file mode 100644 index 0000000..0d7289b --- /dev/null +++ b/testnet/deno.json @@ -0,0 +1,13 @@ +{ + "nodeModulesDir": "auto", + "tasks": { + "e2e": "OTEL_DENO=true OTEL_SERVICE_NAME=moonlight-e2e OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf deno run --allow-all main.ts", + "verify-otel": "deno run --allow-all verify-otel.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/testnet/deno.lock b/testnet/deno.lock new file mode 100644 index 0000000..194f86d --- /dev/null +++ b/testnet/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/testnet/main.ts b/testnet/main.ts new file mode 100644 index 0000000..74ea834 --- /dev/null +++ b/testnet/main.ts @@ -0,0 +1,107 @@ +import { Keypair } from "stellar-sdk"; +import { loadConfig } from "./config.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"; +import { sdkTracer, withE2ESpan, writeTraceIds } from "../e2e/tracer.ts"; + +const DEPOSIT_AMOUNT = 10; // XLM +const SEND_AMOUNT = 5; // XLM +const WITHDRAW_AMOUNT = 4; // XLM + +async function fundAccount( + friendbotUrl: string, + publicKey: string, +): Promise { + const res = await fetch(`${friendbotUrl}?addr=${publicKey}`); + if (!res.ok) { + throw new Error( + `Friendbot funding failed for ${publicKey}: ${res.status} ${await res.text()}`, + ); + } +} + +async function main() { + const startTime = Date.now(); + + // Step 1: Load configuration + console.log("\n[1/8] Loading testnet configuration..."); + const config = loadConfig(); + console.log(` Channel: ${config.channelContractId}`); + console.log(` Auth: ${config.channelAuthId}`); + console.log(` Asset: ${config.channelAssetContractId}`); + console.log(` Provider: ${config.providerUrl}`); + console.log(` RPC: ${config.rpcUrl}`); + + // Generate fresh keypairs + const alice = Keypair.random(); + const bob = Keypair.random(); + console.log(` Alice: ${alice.publicKey()}`); + console.log(` Bob: ${bob.publicKey()}`); + + // Step 2: Fund accounts via Friendbot + console.log("\n[2/8] Funding accounts via testnet Friendbot..."); + await withE2ESpan("e2e.fund_accounts", async () => { + await fundAccount(config.friendbotUrl, alice.publicKey()); + console.log(` Alice funded`); + await fundAccount(config.friendbotUrl, bob.publicKey()); + console.log(` Bob funded`); + }); + + // Step 3: Authenticate Alice + console.log("\n[3/8] Authenticating Alice with provider..."); + const aliceJwt = await withE2ESpan("e2e.authenticate_alice", () => + authenticate(alice, config) + ); + console.log(` Alice authenticated`); + + // Step 4: Authenticate Bob + console.log("\n[4/8] Authenticating Bob with provider..."); + const bobJwt = await withE2ESpan("e2e.authenticate_bob", () => + authenticate(bob, config) + ); + console.log(` Bob authenticated`); + + // Step 5: Alice deposits into channel + console.log(`\n[5/8] Alice depositing ${DEPOSIT_AMOUNT} XLM into channel...`); + await withE2ESpan("e2e.deposit", () => + deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, config, sdkTracer) + ); + console.log(` Deposit complete`); + + // Step 6: Bob prepares to receive + console.log(`\n[6/8] Bob preparing to receive ${SEND_AMOUNT} XLM...`); + const receiverOps = await withE2ESpan("e2e.prepare_receive", () => + prepareReceive(bob.secret(), SEND_AMOUNT, config, sdkTracer) + ); + console.log(` Receive prepared (${receiverOps.length} CREATE ops)`); + + // Step 7: Alice sends to Bob + console.log(`\n[7/8] Alice sending ${SEND_AMOUNT} XLM to Bob...`); + await withE2ESpan("e2e.send", () => + send(alice.secret(), receiverOps, SEND_AMOUNT, aliceJwt, config, sdkTracer) + ); + console.log(` Send complete`); + + // Step 8: Bob withdraws to his Stellar address + console.log( + `\n[8/8] Bob withdrawing ${WITHDRAW_AMOUNT} XLM to ${bob.publicKey()}...`, + ); + await withE2ESpan("e2e.withdraw", () => + withdraw(bob.secret(), bob.publicKey(), WITHDRAW_AMOUNT, bobJwt, config, sdkTracer) + ); + console.log(` Withdraw complete`); + + // Write trace IDs for verify-otel to fetch by ID + await writeTraceIds(); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`\n✅ Testnet E2E test passed in ${elapsed}s`); +} + +main().catch((err) => { + console.error(`\n❌ Testnet E2E test failed:`, err); + Deno.exit(1); +}); diff --git a/testnet/verify-otel.ts b/testnet/verify-otel.ts new file mode 100644 index 0000000..5c37a88 --- /dev/null +++ b/testnet/verify-otel.ts @@ -0,0 +1,412 @@ +/** + * OTEL Verification for Testnet (deterministic) + * + * All checks use trace-by-ID lookups from the E2E run — no time-windowed + * searches. This is possible because the provider's trace context middleware + * nests all request-path spans under the SDK's trace IDs. + * + * Background service spans (Executor.*, Verifier.*, Mempool.*) run on timers + * outside of HTTP context, so they are verified with a targeted search. + * + * Prerequisites: + * - E2E test completed with OTEL_DENO=true (writes e2e-trace-ids.json) + * - TEMPO_URL and TEMPO_AUTH env vars set (or defaults to Grafana Cloud) + * + * Usage: + * deno run --allow-all verify-otel.ts + */ + +const TEMPO_URL = Deno.env.get("TEMPO_URL") ?? + "https://tempo-prod-13-prod-ca-east-0.grafana.net/tempo"; +const TEMPO_AUTH = Deno.env.get("TEMPO_AUTH") ?? + "Basic MTUxMzA1MTpnbGNfZXlKdklqb2lNVGN3TVRFeE5DSXNJbTRpT2lKd2NtOTJhV1JsY2kxMGNtRmpaWE10ZDNKcGRHVXRjSEp2ZG1sa1pYSXRkSEpoWTJWekxYZHlhWFJsSWl3aWF5STZJamM0TUc5YVNHNVNUak4wTmxJNU1HaHNTWFl4TlhjM1V5SXNJbTBpT25zaWNpSTZJbkJ5YjJRdFkyRXRaV0Z6ZEMwd0luMTk="; +const TRACE_POLL_TIMEOUT_MS = Number( + Deno.env.get("TRACE_POLL_TIMEOUT_MS") ?? "30000", +); +const SDK_SERVICE = "moonlight-e2e"; +const PROVIDER_SERVICE = "provider-platform"; + +const TRACE_IDS_PATH = (() => { + const localPath = new URL("./e2e-trace-ids.json", import.meta.url).pathname; + try { + Deno.statSync(localPath); + return localPath; + } catch { + return new URL("../e2e/e2e-trace-ids.json", import.meta.url).pathname; + } +})(); + +interface E2ETraceData { + traceIds: string[]; + startTimeUs: number; + endTimeUs: number; +} + +interface TempoTrace { + batches?: ResourceSpan[]; + resourceSpans?: ResourceSpan[]; +} + +interface ResourceSpan { + resource: { + attributes: { key: string; value: { stringValue?: string } }[]; + }; + scopeSpans: { + spans: TempoSpan[]; + }[]; +} + +interface TempoSpan { + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + startTimeUnixNano: string; + endTimeUnixNano: string; + attributes?: { key: string; value: { stringValue?: string; intValue?: string } }[]; + events?: { name: string }[]; + status?: { code: number }; +} + +interface NormalizedSpan { + traceId: string; + spanId: string; + parentSpanId: string; + name: string; + serviceName: string; + hasEvents: boolean; +} + +function getServiceName(rs: ResourceSpan): string { + const attr = rs.resource.attributes.find((a) => a.key === "service.name"); + return attr?.value?.stringValue ?? "unknown"; +} + +function normalizeTempoTrace(data: TempoTrace): NormalizedSpan[] { + const spans: NormalizedSpan[] = []; + const resourceSpans = data.batches ?? data.resourceSpans ?? []; + for (const rs of resourceSpans) { + const serviceName = getServiceName(rs); + for (const ss of rs.scopeSpans ?? []) { + for (const span of ss.spans ?? []) { + spans.push({ + traceId: span.traceId, + spanId: span.spanId, + parentSpanId: span.parentSpanId ?? "", + name: span.name, + serviceName, + hasEvents: (span.events?.length ?? 0) > 0, + }); + } + } + } + return spans; +} + +function loadTraceData(): E2ETraceData { + try { + const raw = Deno.readTextFileSync(TRACE_IDS_PATH); + const data = JSON.parse(raw); + if (!Array.isArray(data.traceIds) || data.traceIds.length === 0) { + throw new Error("Empty trace ID list"); + } + return data; + } catch (err) { + console.error(`\n❌ Could not read trace data from ${TRACE_IDS_PATH}`); + console.error(` Run the E2E test first: deno task e2e`); + console.error(` Error: ${err}`); + Deno.exit(1); + } +} + +async function fetchTraceById( + traceId: string, + maxWaitMs = TRACE_POLL_TIMEOUT_MS, + intervalMs = 3000, +): Promise { + const deadline = Date.now() + maxWaitMs; + + while (Date.now() < deadline) { + const res = await fetch(`${TEMPO_URL}/api/traces/${traceId}`, { + headers: { + Authorization: TEMPO_AUTH, + Accept: "application/json", + }, + }); + + if (res.ok) { + const data: TempoTrace = await res.json(); + const spans = normalizeTempoTrace(data); + if (spans.length > 0) return spans; + } else if (res.status === 404) { + // Trace not ingested yet, retry + } else { + const body = await res.text().catch(() => "(could not read body)"); + console.error( + ` Tempo returned HTTP ${res.status} for trace ${traceId}: ${body}`, + ); + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + return []; +} + +/** + * Search for traces matching a TraceQL query. Returns the number of matching + * traces (not spans). Used only for background service span existence check. + */ +async function searchTraceCount( + traceql: string, + startEpochS: number, + endEpochS: number, + minExpected: number, + maxWaitMs = TRACE_POLL_TIMEOUT_MS, + intervalMs = 3000, +): Promise { + const deadline = Date.now() + maxWaitMs; + let best = 0; + + while (Date.now() < deadline) { + const q = encodeURIComponent(traceql); + const url = + `${TEMPO_URL}/api/search?q=${q}&start=${startEpochS}&end=${endEpochS}&limit=50`; + const res = await fetch(url, { + headers: { Authorization: TEMPO_AUTH }, + }); + + if (res.ok) { + const data = await res.json(); + const count = (data.traces ?? []).length; + best = Math.max(best, count); + if (best >= minExpected) return best; + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + return best; +} + +async function main() { + console.log("\n[OTEL] Verifying OpenTelemetry traces in Grafana Cloud Tempo\n"); + + // 1. Verify Tempo is reachable + console.log("[1/3] Checking Tempo connectivity..."); + console.log(` Tempo URL: ${TEMPO_URL}`); + try { + const res = await fetch(`${TEMPO_URL}/api/echo`, { + headers: { Authorization: TEMPO_AUTH }, + }); + if (!res.ok && res.status !== 404) { + throw new Error(`HTTP ${res.status}`); + } + await res.text(); + console.log(` Tempo reachable`); + } catch (err) { + console.error(`\n❌ Tempo not reachable at ${TEMPO_URL}`); + console.error(` Error: ${err}`); + Deno.exit(1); + } + + // 2. Load trace data and fetch all traces by ID + console.log("\n[2/3] Loading trace data and fetching traces by ID..."); + const traceData = loadTraceData(); + console.log(` Trace IDs: ${traceData.traceIds.length}`); + console.log( + ` Time window: ${new Date(traceData.startTimeUs / 1000).toISOString()} → ${new Date(traceData.endTimeUs / 1000).toISOString()}`, + ); + + const allSpans: NormalizedSpan[] = []; + let tracesFound = 0; + + for (const id of traceData.traceIds) { + const spans = await fetchTraceById(id); + if (spans.length > 0) { + allSpans.push(...spans); + tracesFound++; + } else { + console.error(` ⚠️ Trace ${id} not found in Tempo`); + } + } + console.log( + ` Fetched ${tracesFound}/${traceData.traceIds.length} traces (${allSpans.length} spans)`, + ); + + if (allSpans.length === 0) { + console.error("\n❌ No traces found in Tempo"); + Deno.exit(1); + } + + // Split by service + const sdkSpans = allSpans.filter((s) => s.serviceName === SDK_SERVICE); + const providerSpans = allSpans.filter( + (s) => s.serviceName === PROVIDER_SERVICE, + ); + + // 3. Validate — all checks use the trace-by-ID data + console.log(`\n[3/3] Validating traces...`); + console.log(` SDK spans: ${sdkSpans.length}`); + console.log(` Provider spans: ${providerSpans.length}`); + + let passed = 0; + let failed = 0; + + function assertMin(label: string, actual: number, min: number): void { + if (actual >= min) { + console.log(` ✅ ${label}: ${actual} (>= ${min})`); + passed++; + } else { + console.log(` ❌ ${label}: ${actual} (expected >= ${min})`); + failed++; + } + } + + function byPrefix(spans: NormalizedSpan[], ...prefixes: string[]): number { + return spans.filter((s) => prefixes.some((p) => s.name.startsWith(p))).length; + } + + function byName(spans: NormalizedSpan[], ...names: string[]): number { + return spans.filter((s) => names.includes(s.name)).length; + } + + // ===================================================================== + // Provider-platform checks (all from trace-by-ID) + // ===================================================================== + + console.log("\n Provider-platform:"); + + // Function-level spans (request-path only: P_*, Bundle.*) + assertMin( + "Function-level spans (P_*, Bundle.*)", + byPrefix(providerSpans, "P_", "Bundle."), + 20, + ); + + // Auth challenge create: 2 users × (P_CreateChallenge + DB + Memory) = 6 + assertMin( + "Auth challenge create spans", + byName(providerSpans, "P_CreateChallenge", "P_CreateChallengeDB", "P_CreateChallengeMemory"), + 4, + ); + + // Auth challenge verify: 2 users × (Verify + Compare + JWT) = 6 + assertMin( + "Auth challenge verify spans", + byName(providerSpans, "P_VerifyChallenge", "P_CompareChallenge", "P_GenerateChallengeJWT"), + 4, + ); + + // Bundle processing: 3 bundles (deposit, send, withdraw) + assertMin( + "P_AddOperationsBundle spans", + byName(providerSpans, "P_AddOperationsBundle"), + 3, + ); + + // Bundle.* helper spans: 3 bundles × 3+ helpers each + assertMin( + "Bundle.* helper spans", + byPrefix(providerSpans, "Bundle."), + 9, + ); + + // Spans with events + assertMin( + "Spans with events", + providerSpans.filter((s) => s.hasEvents).length, + 15, + ); + + // HTTP spans (middleware-created request spans) + assertMin( + "HTTP request spans", + byPrefix(providerSpans, "GET ", "POST "), + 5, + ); + + // Background service spans — these run on timers, NOT under E2E trace IDs. + // Use a targeted search for existence only. + const startS = Math.floor(traceData.startTimeUs / 1_000_000); + const endS = Math.ceil(traceData.endTimeUs / 1_000_000) + 120; + const bgCount = await searchTraceCount( + `{resource.service.name="${PROVIDER_SERVICE}" && name=~"Executor\\\\..*|Verifier\\\\..*|Mempool\\\\..*"}`, + startS, endS, 1, + ); + assertMin("Background service traces (Executor/Verifier/Mempool)", bgCount, 1); + + // ===================================================================== + // SDK checks (all from trace-by-ID) + // ===================================================================== + + console.log("\n SDK (moonlight-e2e):"); + + const e2eStepNames = [ + "e2e.fund_accounts", + "e2e.authenticate_alice", + "e2e.authenticate_bob", + "e2e.deposit", + "e2e.prepare_receive", + "e2e.send", + "e2e.withdraw", + ]; + assertMin("E2E step spans (e2e.*)", byName(sdkSpans, ...e2eStepNames), 7); + + for (const name of e2eStepNames) { + if (byName(sdkSpans, name) === 0) { + console.log(` ❌ Missing E2E step span: ${name}`); + failed++; + } + } + + assertMin( + "Auth E2E spans (auth.*)", + byName(sdkSpans, "auth.get_challenge", "auth.sign_challenge", "auth.verify_challenge"), + 6, + ); + + assertMin( + "Bundle E2E spans (bundle.*)", + byName(sdkSpans, "bundle.submit", "bundle.wait"), + 6, + ); + + assertMin("PrivacyChannel spans", byPrefix(sdkSpans, "PrivacyChannel."), 4); + assertMin("UtxoBasedAccount spans", byPrefix(sdkSpans, "UtxoBasedAccount."), 8); + assertMin("SDK spans with events", sdkSpans.filter((s) => s.hasEvents).length, 10); + + // ===================================================================== + // Distributed tracing (from trace-by-ID) + // ===================================================================== + + console.log("\n Distributed tracing:"); + + const providerTraceIds = new Set(providerSpans.map((s) => s.traceId)); + const sdkTraceIds = new Set(sdkSpans.map((s) => s.traceId)); + const sharedTraceIds = [...sdkTraceIds].filter((id) => + providerTraceIds.has(id) + ); + assertMin("Shared trace IDs", sharedTraceIds.length, 5); + + const sdkSpanIds = new Set(sdkSpans.map((s) => s.spanId)); + const providerWithSdkParent = providerSpans.filter((s) => + sdkSpanIds.has(s.parentSpanId) + ); + assertMin("Provider spans with SDK parent", providerWithSdkParent.length, 5); + + // Summary + console.log(`\n Results: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + console.error(`\n❌ OTEL verification failed`); + Deno.exit(1); + } + + console.log(`\n✅ OTEL verification passed — traces visible in Grafana Cloud`); +} + +main().catch((err) => { + console.error(`\n❌ OTEL verification failed:`, err); + Deno.exit(1); +});