From dfe8ff1fcd1586d2028c430514aed8ebb9534d68 Mon Sep 17 00:00:00 2001 From: cc-mac-mini Date: Fri, 1 May 2026 13:42:21 +0800 Subject: [PATCH] Add SDK parity and local evidence gates --- .github/workflows/publish-sdk.yml | 202 +++++++ .gitignore | 3 + README.md | 19 +- README.zh-CN.md | 19 +- docs/agent-map/index.json | 5 +- docs/ops/release-checklist.md | 9 +- docs/ops/sdk-release-runbook.md | 151 +++++ docs/sdk/README.md | 29 +- docs/sdk/README.zh-CN.md | 29 +- docs/sdk/api-parity-matrix.md | 26 +- docs/sdk/capability_inventory.md | 21 +- docs/sdk/dotnet_integration.md | 28 +- docs/sdk/go_integration.md | 30 +- docs/sdk/java_integration.md | 32 +- dotnet/examples/e2e/Program.cs | 11 +- dotnet/src/SynapseNetwork.Sdk/SynapseAuth.cs | 395 +++++++++++++ .../SynapseNetwork.Sdk.csproj | 3 + .../src/SynapseNetwork.Sdk/SynapseProvider.cs | 62 +++ .../SynapseAuthTests.cs | 130 +++++ go/examples/e2e/main.go | 12 +- go/go.mod | 7 + go/go.sum | 8 + go/synapse/auth.go | 452 +++++++++++++++ go/synapse/auth_test.go | 185 +++++++ go/synapse/control_models.go | 267 +++++++++ go/synapse/provider_control.go | 338 +++++++++++ java/examples/pom.xml | 6 + .../synapsenetwork/sdk/examples/E2eSmoke.java | 4 +- .../sdk/examples/ExampleSupport.java | 6 + java/pom.xml | 14 + .../ai/synapsenetwork/sdk/SynapseAuth.java | 523 ++++++++++++++++++ .../synapsenetwork/sdk/SynapseProvider.java | 87 +++ .../synapsenetwork/sdk/SynapseAuthTest.java | 158 ++++++ llms.txt | 17 +- python/examples/e2e.py | 10 +- scripts/ci/source_quality_checks.py | 82 +++ scripts/e2e/sdk_local_evidence.sh | 160 ++++++ scripts/e2e/sdk_local_evidence_report.py | 432 +++++++++++++++ scripts/e2e/sdk_local_screenshots.mjs | 179 ++++++ scripts/e2e/sdk_parity_e2e.sh | 486 ++++++++++++++++ typescript/examples/_shared.ts | 6 + typescript/examples/e2e.ts | 5 +- 42 files changed, 4576 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/publish-sdk.yml create mode 100644 docs/ops/sdk-release-runbook.md create mode 100644 dotnet/src/SynapseNetwork.Sdk/SynapseAuth.cs create mode 100644 dotnet/src/SynapseNetwork.Sdk/SynapseProvider.cs create mode 100644 dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseAuthTests.cs create mode 100644 go/go.sum create mode 100644 go/synapse/auth.go create mode 100644 go/synapse/auth_test.go create mode 100644 go/synapse/control_models.go create mode 100644 go/synapse/provider_control.go create mode 100644 java/src/main/java/ai/synapsenetwork/sdk/SynapseAuth.java create mode 100644 java/src/main/java/ai/synapsenetwork/sdk/SynapseProvider.java create mode 100644 java/src/test/java/ai/synapsenetwork/sdk/SynapseAuthTest.java create mode 100755 scripts/e2e/sdk_local_evidence.sh create mode 100755 scripts/e2e/sdk_local_evidence_report.py create mode 100755 scripts/e2e/sdk_local_screenshots.mjs create mode 100755 scripts/e2e/sdk_parity_e2e.sh diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml new file mode 100644 index 0000000..96316d7 --- /dev/null +++ b/.github/workflows/publish-sdk.yml @@ -0,0 +1,202 @@ +name: Publish SDK Package + +on: + workflow_dispatch: + inputs: + release_train_version: + description: "Human-facing SDK release train version, for example 0.1.0" + required: true + type: string + package: + description: "Package to build or publish" + required: true + type: choice + options: + - python + - typescript + - go + - java + - dotnet + package_version: + description: "Language package version. Can be a patch above the train version." + required: true + type: string + channel: + description: "Registry channel / dist-tag" + required: true + default: preview + type: choice + options: + - preview + - next + - beta + - latest + dry_run: + description: "Build and validate only; do not publish to registries." + required: true + default: true + type: boolean + +jobs: + publish-sdk: + name: ${{ inputs.package }} ${{ inputs.package_version }} + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + if: inputs.package == 'python' + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: python/pyproject.toml + + - name: Set up Node.js + if: inputs.package == 'typescript' + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + cache: npm + cache-dependency-path: typescript/package-lock.json + + - name: Set up Go + if: inputs.package == 'go' + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Set up Java + if: inputs.package == 'java' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + server-id: github + server-username: GITHUB_ACTOR + server-password: GITHUB_TOKEN + + - name: Set up .NET + if: inputs.package == 'dotnet' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Validate versions + run: | + python - <<'PY' + import os + import re + for name in ("release_train_version", "package_version"): + value = os.environ[f"INPUT_{name.upper()}"] + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", value): + raise SystemExit(f"{name} must be SemVer-like, got {value!r}") + PY + env: + INPUT_RELEASE_TRAIN_VERSION: ${{ inputs.release_train_version }} + INPUT_PACKAGE_VERSION: ${{ inputs.package_version }} + + - name: Run SDK PR quality gates + run: bash scripts/ci/pr_checks.sh + + - name: Build Python package + if: inputs.package == 'python' + run: | + python -m pip install --upgrade build twine + python - <<'PY' + from pathlib import Path + path = Path("python/pyproject.toml") + text = path.read_text() + text = text.replace('version = "0.1.0"', f'version = "${{ inputs.package_version }}"') + path.write_text(text) + PY + python -m build python + python -m twine check python/dist/* + + - name: Publish Python package + if: inputs.package == 'python' && inputs.dry_run == false + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python -m twine upload python/dist/* + + - name: Build TypeScript package + if: inputs.package == 'typescript' + working-directory: typescript + run: | + npm ci + npm version "${{ inputs.package_version }}" --no-git-tag-version + npm run build + npm pack + + - name: Publish TypeScript package + if: inputs.package == 'typescript' && inputs.dry_run == false + working-directory: typescript + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public --tag "${{ inputs.channel }}" + + - name: Verify Go module + if: inputs.package == 'go' + working-directory: go + run: | + go test ./... + go list ./... + echo "Go submodule tag for this package is go/v${{ inputs.package_version }}" + + - name: Create Go module tag + if: inputs.package == 'go' && inputs.dry_run == false + run: | + tag="go/v${{ inputs.package_version }}" + git tag "$tag" + git push origin "$tag" + + - name: Build Java package + if: inputs.package == 'java' + working-directory: java + run: | + mvn -B versions:set -DnewVersion="${{ inputs.package_version }}" -DgenerateBackupPoms=false + mvn -B package + + - name: Publish Java package to GitHub Packages + if: inputs.package == 'java' && inputs.dry_run == false + working-directory: java + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: mvn -B deploy -DskipTests + + - name: Build .NET package + if: inputs.package == 'dotnet' + run: | + dotnet restore dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj + dotnet test dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.csproj + dotnet pack dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj \ + -c Release \ + -p:Version="${{ inputs.package_version }}" \ + -o dotnet/artifacts + + - name: Publish .NET package + if: inputs.package == 'dotnet' && inputs.dry_run == false + run: | + dotnet nuget push dotnet/artifacts/*.nupkg \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + + - name: Summary + run: | + echo "### SDK package workflow" >> "$GITHUB_STEP_SUMMARY" + echo "- Train: ${{ inputs.release_train_version }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Package: ${{ inputs.package }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Version: ${{ inputs.package_version }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Channel: ${{ inputs.channel }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Dry run: ${{ inputs.dry_run }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index bffcd88..7dc6a67 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,8 @@ target/ # Local agent state .agents-memory/ +# Local E2E evidence +output/ + # macOS .DS_Store diff --git a/README.md b/README.md index 0e49284..fa5ab02 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Developers should pass staging before any future production migration. Productio | Mode | Use for | SDK method | Required cost parameter | Billing result | |---|---|---|---|---| -| Fixed-price API invoke | Normal API services discovered from the marketplace | Python `invoke()` / TypeScript `invoke()` | Pass latest discovery price as `cost_usdc` / `costUsdc` | Gateway rejects with `PRICE_MISMATCH` if the live price changed | -| Token-metered LLM invoke | LLM services registered with `serviceKind=llm` and `priceModel=token_metered` | Python `invoke_llm()` / TypeScript `invokeLlm()` | Do not pass `cost_usdc` / `costUsdc`; optional cap is `max_cost_usdc` / `maxCostUsdc` | Gateway holds a cap, then charges final provider-reported token usage | +| Fixed-price API invoke | Normal API services discovered from the marketplace | Python/TypeScript `invoke()`, Go `Invoke()`, Java `invoke()`, .NET `InvokeAsync()` | Pass latest discovery price as `cost_usdc` / `costUsdc` / `CostUSDC` / `CostUsdc` | Gateway rejects with `PRICE_MISMATCH` if the live price changed | +| Token-metered LLM invoke | LLM services registered with `serviceKind=llm` and `priceModel=token_metered` | Python `invoke_llm()`, TypeScript `invokeLlm()`, Go `InvokeLLM()`, Java `invokeLlm()`, .NET `InvokeLlmAsync()` | Do not pass fixed-price cost; optional cap is `max_cost_usdc` / `maxCostUsdc` / `MaxCostUSDC` / `MaxCostUsdc` | Gateway holds a cap, then charges final provider-reported token usage | Do not recompute money with floating-point math. Pass discovered prices and spend caps through exactly; prefer string amounts such as `"0.05"` when the SDK method accepts strings. @@ -107,17 +107,17 @@ Provider is an owner-scoped supply-side role, not a separate root account. Keep |---|---|---| | Python | `python/` | Consumer, owner auth, provider publishing | | TypeScript | `typescript/` | Consumer, owner auth, provider publishing | -| Go | `go/` | Consumer runtime preview | -| Java/JVM | `java/` | Consumer runtime preview; Kotlin can call the Java SDK | -| .NET | `dotnet/` | Consumer runtime preview | +| Go | `go/` | Consumer, owner auth, provider publishing | +| Java/JVM | `java/` | Consumer, owner auth, provider publishing; Kotlin can call the Java SDK | +| .NET | `dotnet/` | Consumer, owner auth, provider publishing | -Wave 1 multi-language SDKs focus on agent runtime: search/discover, fixed-price invoke, token-metered LLM invoke, receipt lookup, and gateway health. Owner auth and provider publishing for Go, Java, and .NET are planned after the consumer runtime surface is stable. +All five SDKs expose the same public capability families: agent runtime, owner wallet auth, credential management, balance/deposit helpers, usage/finance reads, and provider lifecycle/withdrawal helpers. The exact naming follows each language's conventions, but the Gateway contract and money rules stay consistent. ## Examples By SDK All runnable examples default to staging and read `SYNAPSE_AGENT_KEY`. -| SDK | Free fixed-price smoke | LLM smoke | Full local E2E | +| SDK | Free fixed-price smoke | LLM smoke | Full E2E | |---|---|---|---| | Python | `PYTHONPATH=python python3 python/examples/free_service_smoke.py` | `PYTHONPATH=python python3 python/examples/llm_smoke.py` | `PYTHONPATH=python python3 python/examples/e2e.py` | | TypeScript | `npm run example:free --prefix typescript` | `npm run example:llm --prefix typescript` | `npm run example:e2e --prefix typescript` | @@ -128,10 +128,13 @@ All runnable examples default to staging and read `SYNAPSE_AGENT_KEY`. To run every SDK through the same real Gateway E2E harness: ```bash +export SYNAPSE_OWNER_PRIVATE_KEY=0x... export SYNAPSE_AGENT_KEY=agt_xxx -bash scripts/e2e/sdk_wave1_local.sh --skip-install +bash scripts/e2e/sdk_parity_e2e.sh --env staging --skip-install ``` +For a private gateway test target, use `--env local` with an explicit `SYNAPSE_GATEWAY_URL`; `local` is not a public SDK environment preset. + ## Agent Quickstart: Python Step 1: get your Agent Key from the Synapse Gateway Dashboard. diff --git a/README.zh-CN.md b/README.zh-CN.md index 707e343..2bafd85 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,8 +61,8 @@ SynapseNetwork 让 Agent 可以发现服务、通过 gateway 调用服务,并 | 模式 | 适用场景 | SDK 方法 | 必传费用参数 | 计费结果 | |---|---|---|---|---| -| Fixed-price API invoke | marketplace 发现的普通 API 服务 | Python `invoke()` / TypeScript `invoke()` | 传最新 discovery price:`cost_usdc` / `costUsdc` | 如果 live price 已变化,Gateway 用 `PRICE_MISMATCH` 拒绝 | -| Token-metered LLM invoke | `serviceKind=llm` 且 `priceModel=token_metered` 的 LLM 服务 | Python `invoke_llm()` / TypeScript `invokeLlm()` | 不传 `cost_usdc` / `costUsdc`;可选上限是 `max_cost_usdc` / `maxCostUsdc` | Gateway 先冻结上限,再按 Provider 返回的 final token usage 扣费 | +| Fixed-price API invoke | marketplace 发现的普通 API 服务 | Python/TypeScript `invoke()`、Go `Invoke()`、Java `invoke()`、.NET `InvokeAsync()` | 传最新 discovery price:`cost_usdc` / `costUsdc` / `CostUSDC` / `CostUsdc` | 如果 live price 已变化,Gateway 用 `PRICE_MISMATCH` 拒绝 | +| Token-metered LLM invoke | `serviceKind=llm` 且 `priceModel=token_metered` 的 LLM 服务 | Python `invoke_llm()`、TypeScript `invokeLlm()`、Go `InvokeLLM()`、Java `invokeLlm()`、.NET `InvokeLlmAsync()` | 不传 fixed-price cost;可选上限是 `max_cost_usdc` / `maxCostUsdc` / `MaxCostUSDC` / `MaxCostUsdc` | Gateway 先冻结上限,再按 Provider 返回的 final token usage 扣费 | 不要用浮点数重新计算金额。调用时传 discovery 得到的价格或预算上限;SDK 方法支持时优先使用字符串金额,例如 `"0.05"`。 @@ -107,17 +107,17 @@ Provider 是 owner scope 下的供给侧角色,不是第二套根账户体系 |---|---|---| | Python | `python/` | Consumer、owner auth、provider publishing | | TypeScript | `typescript/` | Consumer、owner auth、provider publishing | -| Go | `go/` | Consumer runtime preview | -| Java/JVM | `java/` | Consumer runtime preview;Kotlin 可直接调用 Java SDK | -| .NET | `dotnet/` | Consumer runtime preview | +| Go | `go/` | Consumer、owner auth、provider publishing | +| Java/JVM | `java/` | Consumer、owner auth、provider publishing;Kotlin 可直接调用 Java SDK | +| .NET | `dotnet/` | Consumer、owner auth、provider publishing | -Wave 1 多语言 SDK 先聚焦 Agent runtime:search/discover、fixed-price invoke、token-metered LLM invoke、receipt lookup 和 gateway health。Go、Java、.NET 的 owner auth 与 provider publishing 会在 consumer runtime 稳定后进入下一阶段。 +五种 SDK 都覆盖同一组公开能力:Agent runtime、owner wallet auth、credential 管理、balance/deposit helper、usage/finance 读取,以及 provider lifecycle/withdrawal helper。方法命名遵循各语言习惯,但 Gateway contract 和金额规则保持一致。 ## 各 SDK 示例 所有 runnable examples 默认使用 staging,并读取 `SYNAPSE_AGENT_KEY`。 -| SDK | 免费 fixed-price smoke | LLM smoke | 完整本地 E2E | +| SDK | 免费 fixed-price smoke | LLM smoke | 完整 E2E | |---|---|---|---| | Python | `PYTHONPATH=python python3 python/examples/free_service_smoke.py` | `PYTHONPATH=python python3 python/examples/llm_smoke.py` | `PYTHONPATH=python python3 python/examples/e2e.py` | | TypeScript | `npm run example:free --prefix typescript` | `npm run example:llm --prefix typescript` | `npm run example:e2e --prefix typescript` | @@ -128,10 +128,13 @@ Wave 1 多语言 SDK 先聚焦 Agent runtime:search/discover、fixed-price inv 统一跑全部 SDK: ```bash +export SYNAPSE_OWNER_PRIVATE_KEY=0x... export SYNAPSE_AGENT_KEY=agt_xxx -bash scripts/e2e/sdk_wave1_local.sh --skip-install +bash scripts/e2e/sdk_parity_e2e.sh --env staging --skip-install ``` +如需测试私有 gateway,使用 `--env local` 并显式设置 `SYNAPSE_GATEWAY_URL`;`local` 不是公开 SDK environment preset。 + ## Agent 快速接入:Python 步骤 1:从 Synapse Gateway Dashboard 获取 Agent Key。 diff --git a/docs/agent-map/index.json b/docs/agent-map/index.json index ed1aba0..1a23e2a 100644 --- a/docs/agent-map/index.json +++ b/docs/agent-map/index.json @@ -61,7 +61,7 @@ "Canonical runtime flow is discovery/search plus one of two invoke modes: fixed-price invoke with costUsdc/cost_usdc, or token-metered LLM invoke with maxCostUsdc/max_cost_usdc.", "Public examples should use SYNAPSE_AGENT_KEY for agent runtime credentials; SYNAPSE_API_KEY is only a Python legacy fallback.", "Public preview examples target staging on Arbitrum Sepolia with MockUSDC test assets.", - "Go, Java, and .NET are Wave 1 Consumer SDKs only; owner auth and provider lifecycle helpers remain Python/TypeScript until Phase 2.", + "Go, Java, and .NET now expose owner auth, credential, finance, provider lifecycle, and withdrawal helpers alongside the existing runtime client.", "Do not restore old quote-first gateway calls." ] }, @@ -268,6 +268,7 @@ "dotnet/examples/free-service-smoke/Program.cs", "dotnet/examples/llm-smoke/Program.cs", "dotnet/examples/e2e/Program.cs", + "scripts/e2e/sdk_parity_e2e.sh", "scripts/e2e/sdk_wave1_local.sh", "docs/test/consumer-e2e-plan.md", "docs/test/typescript-provider-onboarding-e2e-plan.md", @@ -289,7 +290,7 @@ "bash scripts/ci/go_checks.sh", "bash scripts/ci/java_checks.sh", "bash scripts/ci/dotnet_checks.sh", - "SYNAPSE_AGENT_KEY=agt_xxx bash scripts/e2e/sdk_wave1_local.sh --skip-install" + "SYNAPSE_OWNER_PRIVATE_KEY=0x... SYNAPSE_AGENT_KEY=agt_xxx bash scripts/e2e/sdk_parity_e2e.sh --env staging --skip-install" ], "notes": [ "Provider staging examples require a public HTTPS endpoint reachable by the staging gateway.", diff --git a/docs/ops/release-checklist.md b/docs/ops/release-checklist.md index 167e431..be0a182 100644 --- a/docs/ops/release-checklist.md +++ b/docs/ops/release-checklist.md @@ -5,6 +5,7 @@ Use this checklist for SDK releases and public documentation updates. ## Preflight - [ ] `bash scripts/ci/pr_checks.sh` passes. +- [ ] Read `docs/ops/sdk-release-runbook.md`. - [ ] `.github/workflows/ci.yml` and `.github/workflows/pr-ci.yml` are current. - [ ] 更新 CHANGELOG.md with the release date and developer-visible changes. - [ ] Public examples use `SYNAPSE_AGENT_KEY`. @@ -27,5 +28,9 @@ Do not switch public examples from staging to production until production DNS, g ## Publish -- [ ] Create and push the Git tag. -- [ ] Publish the GitHub Release with install notes and staging/prod status. +- [ ] Initialize the SDK release train in Synapse-Network-Growing `/releases` -> `SDK Packages`. +- [ ] Dry-run each selected package through `.github/workflows/publish-sdk.yml`. +- [ ] Publish each selected package through `.github/workflows/publish-sdk.yml`. +- [ ] For Go, verify the subdirectory module tag uses `go/vX.Y.Z`. +- [ ] Publish the GitHub Release with package URLs, install notes, and public-preview status. +- [ ] Do not describe SDK packages as staging/prod deployments; SDK packages only have registry channels. diff --git a/docs/ops/sdk-release-runbook.md b/docs/ops/sdk-release-runbook.md new file mode 100644 index 0000000..fd0dfa6 --- /dev/null +++ b/docs/ops/sdk-release-runbook.md @@ -0,0 +1,151 @@ +# SDK Package Release Runbook + +- Status: Public Preview +- Last verified against code: 2026-05-01 + +This runbook covers SynapseNetwork SDK package publishing. SDKs are **published** to language registries; they are not deployed to staging or production runtime environments. + +## Release Model + +- `release_train_version` is the human-facing SDK train, for example `0.1.0`. +- `package_version` is the actual language package version. +- New trains initialize all package versions to the train version. +- A single language can hotfix forward, for example train `0.1.0` with Python package `0.1.1`. +- Published package versions are immutable. Do not overwrite or republish the same version; publish a higher patch version instead. +- SDK examples may default to `environment="staging"`, but package release channels are registry channels, not Gateway environments. + +## Package Platforms + +| Language | Package | Registry | Publish notes | +| --- | --- | --- | --- | +| Python | `synapse-client` | PyPI | Optional TestPyPI dry-run before public release. | +| TypeScript | `@synapse-network/sdk` | npm | Use npm dist-tags such as `preview`, `next`, or `latest`. | +| Go | `github.com/cliff-personal/Synapse-Network-Sdk/go` | Go module via GitHub | Because the module is in `/go`, tags must use `go/vX.Y.Z`. | +| Java | `ai.synapsenetwork:synapse-network-sdk` | Maven Central | If Central is not ready, publish preview artifacts to GitHub Packages Maven. | +| .NET | `SynapseNetwork.Sdk` | NuGet.org | Use NuGet package versions and never overwrite an existing version. | +| All | GitHub Release | GitHub | One release page per train with links to all language packages. | + +## Preflight + +Run the full SDK quality gate: + +```bash +bash scripts/ci/pr_checks.sh +``` + +Build/package checks: + +```bash +python -m build python +python -m twine check python/dist/* + +cd typescript +npm ci +npm run build +npm pack + +cd ../go +go test ./... +go list ./... + +cd ../java +mvn -B package + +cd ../dotnet +dotnet test tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.csproj +dotnet pack src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj -c Release +``` + +## GitHub Actions Publishing + +Use `.github/workflows/publish-sdk.yml` for controlled package publishing. + +Inputs: + +- `release_train_version` +- `package` +- `package_version` +- `channel` +- `dry_run` + +Dry-run first: + +```bash +gh workflow run publish-sdk.yml \ + --ref main \ + -f release_train_version=0.1.0 \ + -f package=python \ + -f package_version=0.1.0 \ + -f channel=preview \ + -f dry_run=true +``` + +Publish only after dry-run succeeds: + +```bash +gh workflow run publish-sdk.yml \ + --ref main \ + -f release_train_version=0.1.0 \ + -f package=python \ + -f package_version=0.1.0 \ + -f channel=preview \ + -f dry_run=false +``` + +## Registry Secrets + +Registry credentials must stay in GitHub Actions secrets. Do not store them in Synapse-Network-Growing or any repo file. + +- `PYPI_API_TOKEN` +- `NPM_TOKEN` +- `NUGET_API_KEY` +- Maven Central or GitHub Packages credentials +- GPG signing secrets if Maven Central requires signing + +## Go Tag Rule + +The Go module lives in a subdirectory: + +```text +go/go.mod -> module github.com/cliff-personal/Synapse-Network-Sdk/go +``` + +Therefore Go package publishing uses subdirectory tags: + +```bash +git tag go/v0.1.0 +git push origin go/v0.1.0 +``` + +Do not rely on a root `v0.1.0` tag for the Go module. + +## Growing Release Center + +Use `http://localhost:9700/releases` -> `SDK Packages`. + +Recommended flow: + +1. Click `Initialize Release Train`. +2. Enter train version, channel, release notes, and selected packages. +3. Run `Dry Run` for each package. +4. Publish packages after dry-runs pass. +5. Sync statuses until every package is `published` or explicitly `failed`. +6. Create or update the GitHub Release with package URLs. + +## Post-Publish Verification + +Verify install and one minimal staging invocation per language where practical: + +- Install package from the public registry. +- Search for a free or smoke service. +- Invoke with an Agent Key. +- Fetch receipt. +- Confirm docs link back to the published version. + +## Rollback Policy + +SDK registries generally do not support safe rollback by overwriting a version. If a published package is bad: + +1. Mark the package release failed or superseded in the release center notes. +2. Publish a higher patch version. +3. Update GitHub Release notes and docs with the fixed version. diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 0c7eeeb..13b90a2 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -19,7 +19,7 @@ This directory is the SDK-side source of truth for capabilities, integration gui 9. [Go Integration Guide](./go_integration.md) 10. [Java/JVM Integration Guide](./java_integration.md) 11. [.NET Integration Guide](./dotnet_integration.md) -12. [Wave 1 Local E2E](#wave-1-local-e2e) +12. [SDK Parity E2E](#sdk-parity-e2e) 13. [Python Staging Development](../ops/SDK_Python_Staging_Development.md) 14. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md) 15. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md) @@ -36,9 +36,9 @@ The SDK currently has three explicit public surfaces: Provider remains an owner-scoped supply-side role. `SynapseProvider` improves discoverability but does not introduce a second provider root identity. -Wave 1 multi-language packages add Go, Java/JVM, and .NET consumer runtime SDKs. These packages intentionally start with `SynapseClient` only: discovery/search, fixed-price invoke, token-metered LLM invoke, receipt lookup, and gateway health. Python and TypeScript remain the reference SDKs for owner auth and provider publishing until those control-plane surfaces are added to the new languages. +Go, Java/JVM, and .NET now expose the same public capability families as Python and TypeScript: `SynapseClient` agent runtime, `SynapseAuth` owner wallet auth, credential management, balance/deposit helpers, usage/finance reads, and `SynapseProvider` provider publishing/withdrawal helpers. -Owner/provider helper returns are typed SDK objects. Do not document or add public `SynapseAuth` / `SynapseProvider` methods that return raw Python `dict` or TypeScript `Record`; add a named result model/interface instead. +Owner/provider helper returns are typed SDK objects. Do not document or add public `SynapseAuth` / `SynapseProvider` methods that return raw Python `dict`, TypeScript `Record`, Go `map[string]any`, Java `JsonNode`/`Map`, or .NET `JsonElement`/`Dictionary` as the top-level result; add a named result model/interface/struct/record instead. Python quote-first methods `create_quote()`, `create_invocation()`, and `invoke_service()` are deprecated. They no longer call old endpoints and instead tell users to use discovery/search + `invoke(..., cost_usdc=...)` for fixed-price APIs. @@ -48,19 +48,30 @@ Consumer docs should present two invocation modes: | Mode | SDK method | Cost input | |---|---|---| -| Fixed-price API | Python `invoke()` / TypeScript `invoke()` | latest discovery price as `cost_usdc` / `costUsdc` | -| Token-metered LLM | Python `invoke_llm()` / TypeScript `invokeLlm()` | optional cap as `max_cost_usdc` / `maxCostUsdc`; never send `cost_usdc` / `costUsdc` | +| Fixed-price API | Python/TypeScript `invoke()`, Go `Invoke()`, Java `invoke()`, .NET `InvokeAsync()` | latest discovery price as string money | +| Token-metered LLM | Python `invoke_llm()`, TypeScript `invokeLlm()`, Go `InvokeLLM()`, Java `invokeLlm()`, .NET `InvokeLlmAsync()` | optional cap as string money; never send fixed-price cost | -## Wave 1 Local E2E +## SDK Parity E2E -The real Gateway E2E entrypoint for the new Go, Java/JVM, and .NET SDKs is: +The real Gateway E2E entrypoint for all five SDKs is: ```bash +export SYNAPSE_OWNER_PRIVATE_KEY='0x...' export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key' -bash scripts/e2e/sdk_wave1_local.sh +bash scripts/e2e/sdk_parity_e2e.sh --env staging ``` -The script verifies health, discovery, fixed-price invoke, receipt lookup, token-metered LLM invoke, local validation failures, and invalid credential handling for each selected SDK. It may install missing local toolchains; .NET is pinned to SDK 8.0 under `$HOME/.synapse-network-sdk-e2e/dotnet` if needed. +The script first verifies owner login, credential issue, balance, usage logs, and provider registration guide through each selected SDK's `SynapseAuth` / `SynapseProvider` surface. It then runs the shared runtime E2E for health, discovery, fixed-price invoke, receipt lookup, token-metered LLM invoke, validation failures, and invalid credential handling. + +For a private test gateway, use: + +```bash +export SYNAPSE_OWNER_PRIVATE_KEY='0x...' +export SYNAPSE_GATEWAY_URL='https://your-private-gateway.example.com' +bash scripts/e2e/sdk_parity_e2e.sh --env local +``` + +This does not reintroduce a public local environment preset. The local target is only an explicit URL override for test automation. The script may install missing local toolchains; .NET is pinned to SDK 8.0 under `$HOME/.synapse-network-sdk-e2e/dotnet` if needed. By default the fixed-price path only auto-selects a free fixed-price API service. If staging has no such service, set `SYNAPSE_E2E_FIXED_SERVICE_ID`, `SYNAPSE_E2E_FIXED_COST_USDC`, and `SYNAPSE_E2E_FIXED_PAYLOAD_JSON` explicitly. diff --git a/docs/sdk/README.zh-CN.md b/docs/sdk/README.zh-CN.md index 982231f..9b86ff6 100644 --- a/docs/sdk/README.zh-CN.md +++ b/docs/sdk/README.zh-CN.md @@ -19,7 +19,7 @@ 9. [Go Integration Guide](./go_integration.md) 10. [Java/JVM Integration Guide](./java_integration.md) 11. [.NET Integration Guide](./dotnet_integration.md) -12. [Wave 1 本地 E2E](#wave-1-本地-e2e) +12. [SDK Parity E2E](#sdk-parity-e2e) 13. [Python Staging Development](../ops/SDK_Python_Staging_Development.md) 14. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md) 15. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md) @@ -36,9 +36,9 @@ SDK 当前有三个明确的公开入口: Provider 仍然是 owner scope 下的供给侧角色。`SynapseProvider` 只是让 provider 接入更容易发现,不引入第二套 provider root 身份。 -Wave 1 多语言包新增 Go、Java/JVM 和 .NET consumer runtime SDK。这些包第一阶段只提供 `SynapseClient`:discovery/search、fixed-price invoke、token-metered LLM invoke、receipt lookup 和 gateway health。Python 与 TypeScript 仍是 owner auth 和 provider publishing 的 reference SDK,等新语言 consumer runtime 稳定后再补控制面能力。 +Go、Java/JVM 和 .NET 现在与 Python、TypeScript 覆盖同一组公开能力:`SynapseClient` agent runtime、`SynapseAuth` owner wallet auth、credential 管理、balance/deposit helper、usage/finance 读取,以及 `SynapseProvider` provider publishing/withdrawal helper。 -Owner/provider helper 的返回值必须是命名 SDK 对象。不要新增或记录返回 raw Python `dict` / TypeScript `Record` 的公开 `SynapseAuth` / `SynapseProvider` 方法;应先新增命名 result model/interface。 +Owner/provider helper 的返回值必须是命名 SDK 对象。不要新增或记录返回 raw Python `dict`、TypeScript `Record`、Go `map[string]any`、Java `JsonNode`/`Map` 或 .NET `JsonElement`/`Dictionary` 作为顶层结果的公开 `SynapseAuth` / `SynapseProvider` 方法;应先新增命名 result model/interface/struct/record。 Python 旧的 quote-first 方法 `create_quote()`、`create_invocation()`、`invoke_service()` 已经废弃。它们不会再访问旧 endpoint,而是直接提示普通 fixed-price API 改用 discovery/search + `invoke(..., cost_usdc=...)`。 @@ -48,19 +48,30 @@ Consumer 文档应统一呈现两种调用模式: | 模式 | SDK 方法 | 费用输入 | |---|---|---| -| Fixed-price API | Python `invoke()` / TypeScript `invoke()` | 最新 discovery price:`cost_usdc` / `costUsdc` | -| Token-metered LLM | Python `invoke_llm()` / TypeScript `invokeLlm()` | 可选上限:`max_cost_usdc` / `maxCostUsdc`;不要发送 `cost_usdc` / `costUsdc` | +| Fixed-price API | Python/TypeScript `invoke()`、Go `Invoke()`、Java `invoke()`、.NET `InvokeAsync()` | 最新 discovery price,使用字符串金额 | +| Token-metered LLM | Python `invoke_llm()`、TypeScript `invokeLlm()`、Go `InvokeLLM()`、Java `invokeLlm()`、.NET `InvokeLlmAsync()` | 可选上限,使用字符串金额;不要发送 fixed-price cost | -## Wave 1 本地 E2E +## SDK Parity E2E -新增 Go、Java/JVM 和 .NET SDK 的真实 Gateway E2E 入口是: +五种 SDK 共用的真实 Gateway E2E 入口是: ```bash +export SYNAPSE_OWNER_PRIVATE_KEY='0x...' export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key' -bash scripts/e2e/sdk_wave1_local.sh +bash scripts/e2e/sdk_parity_e2e.sh --env staging ``` -脚本会对每个选中的 SDK 验证 health、discovery、fixed-price invoke、receipt lookup、token-metered LLM invoke、本地参数校验失败,以及 invalid credential 负向路径。缺少本地工具链时可自动安装;.NET 会按项目 baseline 安装 SDK 8.0 到 `$HOME/.synapse-network-sdk-e2e/dotnet`。 +脚本会先通过每个选中的 SDK 验证 owner login、credential issue、balance、usage logs 和 provider registration guide,再运行共享 runtime E2E:health、discovery、fixed-price invoke、receipt lookup、token-metered LLM invoke、参数校验失败,以及 invalid credential 负向路径。 + +私有 gateway 测试目标使用: + +```bash +export SYNAPSE_OWNER_PRIVATE_KEY='0x...' +export SYNAPSE_GATEWAY_URL='https://your-private-gateway.example.com' +bash scripts/e2e/sdk_parity_e2e.sh --env local +``` + +这不会恢复公开的 local environment preset;local 只是测试自动化里的显式 URL override。缺少本地工具链时可自动安装;.NET 会按项目 baseline 安装 SDK 8.0 到 `$HOME/.synapse-network-sdk-e2e/dotnet`。 默认 fixed-price 路径只会自动选择免费的 fixed-price API 服务。如果 staging 没有这类服务,需要显式设置 `SYNAPSE_E2E_FIXED_SERVICE_ID`、`SYNAPSE_E2E_FIXED_COST_USDC` 和 `SYNAPSE_E2E_FIXED_PAYLOAD_JSON`。 diff --git a/docs/sdk/api-parity-matrix.md b/docs/sdk/api-parity-matrix.md index 3c1f608..1acd726 100644 --- a/docs/sdk/api-parity-matrix.md +++ b/docs/sdk/api-parity-matrix.md @@ -16,22 +16,22 @@ Current public environment: staging on Arbitrum Sepolia with MockUSDC test asset ## Owner Control Plane -| Capability | Python SDK | TypeScript SDK | Gateway route | -|---|---|---|---| -| Wallet auth | `SynapseAuth.from_private_key()`, `get_token()` | `SynapseAuth.fromWallet()`, `getToken()` | `/api/v1/auth/challenge`, `/api/v1/auth/verify` | -| Agent credentials | `issue_credential()`, `list_credentials()`, `revoke_credential()`, `update_credential_quota()` | `issueCredential()`, `listCredentials()`, `revokeCredential()`, `updateCredentialQuota()` | `/api/v1/credentials/agent/*` | -| Balance and deposit | `get_balance()`, `register_deposit_intent()`, `confirm_deposit()` | `getBalance()`, `registerDepositIntent()`, `confirmDeposit()` | `/api/v1/balance*` | -| Usage and limits | `get_usage_logs()`, `set_spending_limit()` | `getUsageLogs()`, `setSpendingLimit()` | `/api/v1/usage/logs`, `/api/v1/balance/spending-limit` | +| Capability | Python SDK | TypeScript SDK | Go SDK | Java SDK | .NET SDK | Gateway route | +|---|---|---|---|---|---|---| +| Wallet auth | `SynapseAuth.from_private_key()`, `get_token()` | `SynapseAuth.fromWallet()`, `getToken()` | `NewAuthFromPrivateKey()`, `GetToken()` | `SynapseAuth.fromPrivateKey()`, `getToken()` | `SynapseAuth.FromPrivateKey()`, `GetTokenAsync()` | `/api/v1/auth/challenge`, `/api/v1/auth/verify` | +| Agent credentials | `issue_credential()`, `list_credentials()`, `revoke_credential()`, `update_credential_quota()` | `issueCredential()`, `listCredentials()`, `revokeCredential()`, `updateCredentialQuota()` | `IssueCredential()`, `ListCredentials()`, `RevokeCredential()`, `UpdateCredentialQuota()` | `issueCredential()`, `listCredentials()`, `revokeCredential()`, `updateCredentialQuota()` | `IssueCredentialAsync()`, `ListCredentialsAsync()`, `RevokeCredentialAsync()`, `UpdateCredentialQuotaAsync()` | `/api/v1/credentials/agent/*` | +| Balance and deposit | `get_balance()`, `register_deposit_intent()`, `confirm_deposit()` | `getBalance()`, `registerDepositIntent()`, `confirmDeposit()` | `GetBalance()`, `RegisterDepositIntent()`, `ConfirmDeposit()` | `getBalance()`, `registerDepositIntent()`, `confirmDeposit()` | `GetBalanceAsync()`, `RegisterDepositIntentAsync()`, `ConfirmDepositAsync()` | `/api/v1/balance*` | +| Usage and limits | `get_usage_logs()`, `set_spending_limit()` | `getUsageLogs()`, `setSpendingLimit()` | `GetUsageLogs()`, `SetSpendingLimit()` | `getUsageLogs()`, `setSpendingLimit()` | `GetUsageLogsAsync()`, `SetSpendingLimitAsync()` | `/api/v1/usage/logs`, `/api/v1/balance/spending-limit` | ## Provider Publishing -| Capability | Python SDK | TypeScript SDK | Gateway route | -|---|---|---|---| -| Provider facade | `auth.provider()` / `SynapseProvider` | `auth.provider()` / `SynapseProvider` | Owner JWT scoped | -| Registration guide | `get_registration_guide()` | `getRegistrationGuide()` | `GET /api/v1/services/registration-guide` | -| Service lifecycle | `register_service()`, `list_services()`, `update_service()`, `delete_service()`, `ping_service()` | `registerService()`, `listServices()`, `updateService()`, `deleteService()`, `pingService()` | `/api/v1/services*` | -| Health and earnings | `get_service_status()`, `get_service_health_history()`, `get_earnings_summary()` | `getServiceStatus()`, `getServiceHealthHistory()`, `getEarningsSummary()` | `/api/v1/services/*`, `/api/v1/providers/earnings/summary` | -| Provider withdrawals | `get_withdrawal_capability()`, `create_withdrawal_intent()`, `list_withdrawals()` | `getWithdrawalCapability()`, `createWithdrawalIntent()`, `listWithdrawals()` | `/api/v1/providers/withdrawals*` | +| Capability | Python SDK | TypeScript SDK | Go SDK | Java SDK | .NET SDK | Gateway route | +|---|---|---|---|---|---|---| +| Provider facade | `auth.provider()` / `SynapseProvider` | `auth.provider()` / `SynapseProvider` | `auth.Provider()` / `Provider` | `auth.provider()` / `SynapseProvider` | `auth.Provider()` / `SynapseProvider` | Owner JWT scoped | +| Registration guide | `get_registration_guide()` | `getRegistrationGuide()` | `GetRegistrationGuide()` | `getRegistrationGuide()` | `GetRegistrationGuideAsync()` | `GET /api/v1/services/registration-guide` | +| Service lifecycle | `register_service()`, `list_services()`, `update_service()`, `delete_service()`, `ping_service()` | `registerService()`, `listServices()`, `updateService()`, `deleteService()`, `pingService()` | `RegisterProviderService()`, `ListProviderServices()`, `UpdateProviderService()`, `DeleteProviderService()`, `PingProviderService()` | `registerProviderService()`, `listProviderServices()`, `updateProviderService()`, `deleteProviderService()`, `pingProviderService()` | `RegisterProviderServiceAsync()`, `ListProviderServicesAsync()`, `UpdateProviderServiceAsync()`, `DeleteProviderServiceAsync()`, `PingProviderServiceAsync()` | `/api/v1/services*` | +| Health and earnings | `get_service_status()`, `get_service_health_history()`, `get_earnings_summary()` | `getServiceStatus()`, `getServiceHealthHistory()`, `getEarningsSummary()` | `GetProviderServiceStatus()`, `GetProviderServiceHealthHistory()`, `GetProviderEarningsSummary()` | `getProviderServiceStatus()`, `getProviderServiceHealthHistory()`, `getProviderEarningsSummary()` | `GetProviderServiceStatusAsync()`, `GetProviderServiceHealthHistoryAsync()`, `GetProviderEarningsSummaryAsync()` | `/api/v1/services/*`, `/api/v1/providers/earnings/summary` | +| Provider withdrawals | `get_withdrawal_capability()`, `create_withdrawal_intent()`, `list_withdrawals()` | `getWithdrawalCapability()`, `createWithdrawalIntent()`, `listWithdrawals()` | `GetProviderWithdrawalCapability()`, `CreateProviderWithdrawalIntent()`, `ListProviderWithdrawals()` | `getProviderWithdrawalCapability()`, `createProviderWithdrawalIntent()`, `listProviderWithdrawals()` | `GetProviderWithdrawalCapabilityAsync()`, `CreateProviderWithdrawalIntentAsync()`, `ListProviderWithdrawalsAsync()` | `/api/v1/providers/withdrawals*` | ## Documentation Rules diff --git a/docs/sdk/capability_inventory.md b/docs/sdk/capability_inventory.md index 57d1141..5a8970c 100644 --- a/docs/sdk/capability_inventory.md +++ b/docs/sdk/capability_inventory.md @@ -16,7 +16,7 @@ Agent runtime examples use `SYNAPSE_AGENT_KEY=agt_xxx`. Python keeps `SYNAPSE_AP The old quote-first flow is not a current SDK main path. Python keeps deprecated compatibility methods that raise a clear error instead of calling removed endpoints. -Public `SynapseAuth` and `SynapseProvider` owner/provider helpers return named SDK objects. Python uses `SDKModel` result classes such as `CredentialRevokeResult`, `UsageLogList`, `ProviderRegistrationGuide`, and `ProviderWithdrawalIntentResult`; TypeScript exports matching interfaces. Raw `dict` / `Record` is allowed for internal HTTP payloads, schemas, patch inputs, and dynamic runtime payload fields, but not as the top-level public Auth/Provider return contract. +Public `SynapseAuth` and `SynapseProvider` owner/provider helpers return named SDK objects. Python uses `SDKModel` result classes, TypeScript exports interfaces, Go exports structs, Java exports records/classes, and .NET exports records. Raw `dict` / `Record` / `map[string]any` / `JsonNode` / `JsonElement` is allowed for internal HTTP payloads, schemas, patch inputs, and dynamic runtime payload fields, but not as the top-level public Auth/Provider return contract. ## Python Consumer @@ -137,7 +137,7 @@ Provider publishing is available through `auth.provider()` and existing `Synapse 17. create provider withdrawal intent 18. list provider withdrawals -## Go Consumer +## Go SDK Supported: @@ -148,8 +148,12 @@ Supported: 5. invocation receipt lookup 6. gateway health check 7. typed auth, budget, price mismatch, discovery, and invoke errors +8. owner auth through `NewAuthFromPrivateKey()` and JWT cache +9. credential issue/list/status/revoke/rotate/delete/quota/audit/ensure helpers +10. balance, deposit intent/confirm, voucher, usage, finance audit, and risk helpers +11. provider facade, secrets, registration guide, parse curl, service lifecycle, health, earnings, and withdrawals -## Java/JVM Consumer +## Java/JVM SDK Supported: @@ -160,8 +164,12 @@ Supported: 5. invocation receipt lookup 6. gateway health check 7. typed auth, budget, price mismatch, and invoke exceptions +8. owner auth through `SynapseAuth.fromPrivateKey()` and JWT cache +9. credential issue/list/status/revoke/rotate/delete/quota/audit/ensure helpers +10. balance, deposit intent/confirm, voucher, usage, finance audit, and risk helpers +11. provider facade, secrets, registration guide, parse curl, service lifecycle, health, earnings, and withdrawals -## .NET Consumer +## .NET SDK Supported: @@ -172,6 +180,10 @@ Supported: 5. invocation receipt lookup 6. gateway health check 7. typed auth, budget, price mismatch, and invoke exceptions +8. owner auth through `SynapseAuth.FromPrivateKey()` and JWT cache +9. credential issue/list/status/revoke/rotate/delete/quota/audit/ensure helpers +10. balance, deposit intent/confirm, voucher, usage, finance audit, and risk helpers +11. provider facade, secrets, registration guide, parse curl, service lifecycle, health, earnings, and withdrawals ## Gateway Capabilities Not Yet Wrapped @@ -183,7 +195,6 @@ The SDK does not currently wrap the full gateway management surface. Known uncov 4. events 5. on-chain transaction signing helpers 6. provider withdrawal completion helper -7. owner/provider control-plane helpers in Go, Java, and .NET ## Product Boundary diff --git a/docs/sdk/dotnet_integration.md b/docs/sdk/dotnet_integration.md index 2f0d93f..e022afe 100644 --- a/docs/sdk/dotnet_integration.md +++ b/docs/sdk/dotnet_integration.md @@ -1,6 +1,6 @@ # .NET SDK Integration Guide -The .NET SDK is a Wave 1 consumer runtime SDK targeting `net8.0`. +The .NET SDK targets `net8.0`. It supports the full public Synapse SDK surface: `SynapseClient` agent runtime, `SynapseAuth` owner wallet auth, credential and finance helpers, and `SynapseProvider` publishing/withdrawal helpers. ## Install @@ -47,6 +47,31 @@ var result = await client.InvokeLlmAsync( Do not pass fixed-price `CostUsdc` to LLM services. Use `MaxCostUsdc` as an optional cap or omit it to let the Gateway compute the hold. +## Owner Auth and Provider Control + +Use owner auth only in backend or operator tooling. Agent runtime code should keep using `SynapseClient` with `SYNAPSE_AGENT_KEY`. + +```csharp +var auth = SynapseAuth.FromPrivateKey( + Environment.GetEnvironmentVariable("SYNAPSE_OWNER_PRIVATE_KEY")!, + new SynapseAuthOptions { Environment = "staging" }); + +var token = await auth.GetTokenAsync(); +var credential = await auth.IssueCredentialAsync(new CredentialOptions +{ + Name = "agent-runtime", + MaxCalls = 100, + Rpm = 60, + ExpiresInSec = 3600, +}); +var balance = await auth.GetBalanceAsync(); +var guide = await auth.Provider().GetRegistrationGuideAsync(); + +Console.WriteLine($"{token.Length} {credential.Token} {guide.Steps?.Count ?? 0}"); +``` + +Public owner/provider methods return named .NET records. Do not expose `JsonElement` or `Dictionary` as a top-level public result; reserve them for request payloads, schemas, patches, and dynamic nested fields. + ## Verification ```bash @@ -55,4 +80,5 @@ dotnet test dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.cspro SYNAPSE_AGENT_KEY=agt_xxx dotnet run --project dotnet/examples/free-service-smoke/free-service-smoke.csproj SYNAPSE_AGENT_KEY=agt_xxx dotnet run --project dotnet/examples/llm-smoke/llm-smoke.csproj SYNAPSE_AGENT_KEY=agt_xxx dotnet run --project dotnet/examples/e2e/e2e.csproj +SYNAPSE_OWNER_PRIVATE_KEY=0x... bash scripts/e2e/sdk_parity_e2e.sh --languages dotnet --env staging ``` diff --git a/docs/sdk/go_integration.md b/docs/sdk/go_integration.md index abccb1b..28871e0 100644 --- a/docs/sdk/go_integration.md +++ b/docs/sdk/go_integration.md @@ -1,6 +1,6 @@ # Go SDK Integration Guide -The Go SDK is a Wave 1 consumer runtime SDK. It supports service discovery, fixed-price invoke, token-metered LLM invoke, invocation receipt lookup, and gateway health checks. +The Go SDK supports the full public Synapse SDK surface: `SynapseClient` agent runtime, `Auth` owner wallet auth, credential and finance helpers, and `Provider` publishing/withdrawal helpers. ## Install @@ -64,6 +64,33 @@ result, err := client.InvokeLLM( Do not pass fixed-price `CostUSDC` to LLM services. Use `MaxCostUSDC` as an optional cap or omit it to let the Gateway compute the hold. +## Owner Auth and Provider Control + +Use owner auth only in backend or operator tooling. Agent runtime code should keep using `SynapseClient` with `SYNAPSE_AGENT_KEY`. + +```go +auth, err := synapse.NewAuthFromPrivateKey(os.Getenv("SYNAPSE_OWNER_PRIVATE_KEY"), synapse.AuthOptions{ + Environment: "staging", +}) +if err != nil { + panic(err) +} + +token, err := auth.GetToken(context.Background()) +credential, err := auth.IssueCredential(context.Background(), synapse.CredentialOptions{ + Name: "agent-runtime", + MaxCalls: 100, + RPM: 60, + ExpiresInSec: 3600, +}) +balance, err := auth.GetBalance(context.Background()) +guide, err := auth.Provider().GetRegistrationGuide(context.Background()) + +fmt.Println(token != "", credential.Token, balance.OwnerBalance, len(guide.Steps)) +``` + +Public owner/provider methods return named Go structs. Do not expose `map[string]any` as a top-level public result; reserve maps for request payloads, schemas, patches, and dynamic nested fields. + ## Verification ```bash @@ -71,4 +98,5 @@ bash scripts/ci/go_checks.sh SYNAPSE_AGENT_KEY=agt_xxx go -C go run ./examples/free_service_smoke SYNAPSE_AGENT_KEY=agt_xxx go -C go run ./examples/llm_smoke SYNAPSE_AGENT_KEY=agt_xxx go -C go run ./examples/e2e +SYNAPSE_OWNER_PRIVATE_KEY=0x... bash scripts/e2e/sdk_parity_e2e.sh --languages go --env staging ``` diff --git a/docs/sdk/java_integration.md b/docs/sdk/java_integration.md index cd618df..cc5a7d9 100644 --- a/docs/sdk/java_integration.md +++ b/docs/sdk/java_integration.md @@ -1,6 +1,6 @@ # Java/JVM SDK Integration Guide -The Java SDK is a Wave 1 consumer runtime SDK targeting Java 17 and newer. Kotlin and other JVM languages can call the Java SDK directly. +The Java SDK targets Java 17 and newer. Kotlin and other JVM languages can call it directly. It supports the full public Synapse SDK surface: `SynapseClient` agent runtime, `SynapseAuth` owner wallet auth, credential and finance helpers, and `SynapseProvider` publishing/withdrawal helpers. ## Install @@ -47,6 +47,35 @@ var result = client.invokeLlm( Do not pass fixed-price `costUsdc` to LLM services. Use `maxCostUsdc` as an optional cap or omit it to let the Gateway compute the hold. +## Owner Auth and Provider Control + +Use owner auth only in backend or operator tooling. Agent runtime code should keep using `SynapseClient` with `SYNAPSE_AGENT_KEY`. + +```java +SynapseAuth.Options authOptions = new SynapseAuth.Options(); +authOptions.environment = "staging"; + +SynapseAuth auth = SynapseAuth.fromPrivateKey( + System.getenv("SYNAPSE_OWNER_PRIVATE_KEY"), + authOptions); + +String token = auth.getToken(); + +SynapseAuth.CredentialOptions credentialOptions = new SynapseAuth.CredentialOptions(); +credentialOptions.name = "agent-runtime"; +credentialOptions.maxCalls = 100; +credentialOptions.rpm = 60; +credentialOptions.expiresInSec = 3600; + +var credential = auth.issueCredential(credentialOptions); +var balance = auth.getBalance(); +var guide = auth.provider().getRegistrationGuide(); + +System.out.println(token + " " + credential.token() + " " + guide.steps().size()); +``` + +Public owner/provider methods return named Java records/classes. Do not expose `JsonNode` or `Map` as a top-level public result; reserve them for request payloads, schemas, patches, and dynamic nested fields. + ## Verification ```bash @@ -56,4 +85,5 @@ mvn -q -f java/examples/pom.xml compile SYNAPSE_AGENT_KEY=agt_xxx mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.FreeServiceSmoke SYNAPSE_AGENT_KEY=agt_xxx mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.LlmSmoke SYNAPSE_AGENT_KEY=agt_xxx mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.E2eSmoke +SYNAPSE_OWNER_PRIVATE_KEY=0x... bash scripts/e2e/sdk_parity_e2e.sh --languages java --env staging ``` diff --git a/dotnet/examples/e2e/Program.cs b/dotnet/examples/e2e/Program.cs index a6a33c0..ccd9fce 100644 --- a/dotnet/examples/e2e/Program.cs +++ b/dotnet/examples/e2e/Program.cs @@ -25,7 +25,7 @@ new InvokeOptions { CostUsdc = fixedTarget.CostUsdc, - IdempotencyKey = "dotnet-e2e-fixed-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + IdempotencyKey = IdempotencyKey("fixed"), }, cancellationToken); var fixedReceipt = await AwaitReceipt(client, fixedResult.InvocationId, cancellationToken); @@ -47,7 +47,7 @@ new LlmInvokeOptions { MaxCostUsdc = maxCost, - IdempotencyKey = "dotnet-e2e-llm-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + IdempotencyKey = IdempotencyKey("llm"), }, cancellationToken); var llmReceipt = await AwaitReceipt(client, llmResult.InvocationId, cancellationToken); @@ -250,6 +250,13 @@ static bool EnvBool(string name) return value is "1" or "true" or "TRUE" or "yes" or "YES" or "y" or "Y"; } +static string IdempotencyKey(string scenario) +{ + var runId = Environment.GetEnvironmentVariable("E2E_RUN_ID")?.Trim(); + var prefix = string.IsNullOrWhiteSpace(runId) ? "dotnet-e2e" : $"{runId}-dotnet-e2e"; + return $"{prefix}-{scenario}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)}"; +} + static string FirstNonBlank(params string?[] values) { foreach (var value in values) diff --git a/dotnet/src/SynapseNetwork.Sdk/SynapseAuth.cs b/dotnet/src/SynapseNetwork.Sdk/SynapseAuth.cs new file mode 100644 index 0000000..4c37c6b --- /dev/null +++ b/dotnet/src/SynapseNetwork.Sdk/SynapseAuth.cs @@ -0,0 +1,395 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Nethereum.Signer; + +namespace SynapseNetwork.Sdk; + +public sealed class SynapseAuth +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly string _walletAddress; + private readonly string _gatewayUrl; + private readonly HttpClient _httpClient; + private readonly Func _signer; + private string? _token; + private DateTimeOffset _tokenExpiresAt; + + public SynapseAuth(SynapseAuthOptions options) + { + _walletAddress = Require(options.WalletAddress, nameof(options.WalletAddress)).ToLowerInvariant(); + _gatewayUrl = SynapseClient.ResolveGatewayUrl(options.Environment, options.GatewayUrl); + _httpClient = options.HttpClient ?? new HttpClient { Timeout = options.Timeout ?? TimeSpan.FromSeconds(30) }; + _signer = options.Signer ?? throw new ArgumentException("Signer is required"); + } + + public static SynapseAuth FromPrivateKey(string privateKey, SynapseAuthOptions? options = null) + { + var key = new EthECKey(privateKey); + options ??= new SynapseAuthOptions(); + options.WalletAddress = key.GetPublicAddress(); + options.Signer = message => new EthereumMessageSigner().EncodeUTF8AndSign(message, key); + return new SynapseAuth(options); + } + + public SynapseProvider Provider() => new(this); + + public async Task AuthenticateAsync(bool forceRefresh = false, CancellationToken cancellationToken = default) + { + if (!forceRefresh && !string.IsNullOrWhiteSpace(_token) && DateTimeOffset.UtcNow < _tokenExpiresAt.AddSeconds(-30)) + { + return _token; + } + var challenge = await GetAsync("/api/v1/auth/challenge?address=" + Uri.EscapeDataString(_walletAddress), cancellationToken).ConfigureAwait(false); + if (challenge.Success != true || string.IsNullOrWhiteSpace(challenge.Challenge)) + { + throw new AuthenticationException("AUTH_CHALLENGE_FAILED", "Challenge request did not return a usable challenge."); + } + var token = await RequestAsync( + HttpMethod.Post, + "/api/v1/auth/verify", + new Dictionary { ["wallet_address"] = _walletAddress, ["message"] = challenge.Challenge, ["signature"] = _signer(challenge.Challenge) }, + null, + cancellationToken).ConfigureAwait(false); + if (token.Success != true || string.IsNullOrWhiteSpace(token.AccessToken)) + { + throw new AuthenticationException("AUTH_VERIFY_FAILED", "Auth verify did not return an access token."); + } + _token = token.AccessToken; + _tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(0, token.ExpiresIn)); + return _token; + } + + public Task GetTokenAsync(CancellationToken cancellationToken = default) => AuthenticateAsync(false, cancellationToken); + + public async Task LogoutAsync(CancellationToken cancellationToken = default) + { + var result = await OwnerRequestAsync(HttpMethod.Post, "/api/v1/auth/logout", null, cancellationToken).ConfigureAwait(false); + _token = null; + _tokenExpiresAt = default; + return result; + } + + public Task GetOwnerProfileAsync(CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, "/api/v1/auth/me", null, cancellationToken); + + public async Task IssueCredentialAsync(CredentialOptions? options = null, CancellationToken cancellationToken = default) + { + var raw = await OwnerRequestAsync(HttpMethod.Post, "/api/v1/credentials/agent/issue", CredentialBody(options), cancellationToken).ConfigureAwait(false); + var credential = raw.TryGetProperty("credential", out var node) ? node.Deserialize(JsonOptions) ?? new AgentCredential() : new AgentCredential(); + var token = FirstText(GetString(raw, "token"), credential.Token, GetString(raw, "credential_token")); + var id = FirstText(GetString(raw, "credential_id"), GetString(raw, "id"), credential.Id, credential.CredentialId); + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(id)) + { + throw new AuthenticationException("CREDENTIAL_PAYLOAD_MISSING", raw.ToString()); + } + return new IssueCredentialResult(credential with { Id = id, CredentialId = id, Token = token }, token); + } + + public Task> ListCredentialsAsync(CancellationToken cancellationToken = default) + => CredentialListAsync("/api/v1/credentials/agent/list", cancellationToken); + + public Task> ListActiveCredentialsAsync(CancellationToken cancellationToken = default) + => CredentialListAsync("/api/v1/credentials/agent/list?active_only=true", cancellationToken); + + public Task GetCredentialStatusAsync(string credentialId, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, "/api/v1/credentials/agent/" + EscapeRequired(credentialId, "credentialId") + "/status", null, cancellationToken); + + public Task CheckCredentialStatusAsync(string credentialId, CancellationToken cancellationToken = default) + => GetCredentialStatusAsync(credentialId, cancellationToken); + + public Task RevokeCredentialAsync(string credentialId, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/credentials/agent/" + EscapeRequired(credentialId, "credentialId") + "/revoke", null, cancellationToken); + + public Task RotateCredentialAsync(string credentialId, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/credentials/agent/" + EscapeRequired(credentialId, "credentialId") + "/rotate", null, cancellationToken); + + public Task DeleteCredentialAsync(string credentialId, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Delete, "/api/v1/credentials/agent/" + EscapeRequired(credentialId, "credentialId"), null, cancellationToken); + + public Task UpdateCredentialQuotaAsync(string credentialId, CredentialQuotaOptions? options = null, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Patch, "/api/v1/credentials/agent/" + EscapeRequired(credentialId, "credentialId") + "/quota", QuotaBody(options), cancellationToken); + + public Task GetCredentialAuditLogsAsync(int limit = 100, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, QueryPath("/api/v1/credentials/agent/audit-logs", ("limit", Limit(limit))), null, cancellationToken); + + public async Task EnsureCredentialAsync(string name, CredentialOptions? options = null, CancellationToken cancellationToken = default) + { + foreach (var credential in await ListActiveCredentialsAsync(cancellationToken).ConfigureAwait(false)) + { + if (credential.Name == name) + { + if (!string.IsNullOrWhiteSpace(credential.Token)) return credential.Token; + var rotated = await RotateCredentialAsync(FirstText(credential.CredentialId, credential.Id), cancellationToken).ConfigureAwait(false); + return FirstText(rotated.Token, rotated.Credential?.Token); + } + } + options ??= new CredentialOptions(); + options.Name = name; + return (await IssueCredentialAsync(options, cancellationToken).ConfigureAwait(false)).Token; + } + + public async Task GetBalanceAsync(CancellationToken cancellationToken = default) + { + var raw = await OwnerRequestAsync(HttpMethod.Get, "/api/v1/balance", null, cancellationToken).ConfigureAwait(false); + var node = raw.TryGetProperty("balance", out var balance) ? balance : raw; + return node.Deserialize(JsonOptions) ?? new BalanceSummary(); + } + + public Task RegisterDepositIntentAsync(string txHash, string amountUsdc, string? idempotencyKey = null, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/balance/deposit/intent", new Dictionary { ["txHash"] = txHash, ["amountUsdc"] = amountUsdc }, cancellationToken, Idempotency(idempotencyKey)); + + public Task ConfirmDepositAsync(string intentId, string eventKey, int confirmations = 1, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/balance/deposit/intents/" + EscapeRequired(intentId, "intentId") + "/confirm", new Dictionary { ["eventKey"] = eventKey, ["confirmations"] = confirmations }, cancellationToken); + + public Task SetSpendingLimitAsync(string? spendingLimitUsdc, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Put, "/api/v1/balance/spending-limit", spendingLimitUsdc == null ? new Dictionary { ["allowUnlimited"] = true } : new Dictionary { ["spendingLimitUsdc"] = spendingLimitUsdc, ["allowUnlimited"] = false }, cancellationToken); + + public Task RedeemVoucherAsync(string voucherCode, string? idempotencyKey = null, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/balance/vouchers/redeem", new Dictionary { ["voucherCode"] = Require(voucherCode, "voucherCode") }, cancellationToken, Idempotency(idempotencyKey)); + + public Task GetUsageLogsAsync(int limit = 100, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, QueryPath("/api/v1/usage/logs", ("limit", Limit(limit))), null, cancellationToken); + + public Task GetFinanceAuditLogsAsync(int limit = 100, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, QueryPath("/api/v1/finance/audit-logs", ("limit", Limit(limit))), null, cancellationToken); + + public Task GetRiskOverviewAsync(CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, "/api/v1/finance/risk-overview", null, cancellationToken); + + public Task IssueProviderSecretAsync(CredentialOptions? options = null, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/secrets/provider/issue", CredentialBody(options), cancellationToken); + + public async Task> ListProviderSecretsAsync(CancellationToken cancellationToken = default) + => ReadList(await OwnerRequestAsync(HttpMethod.Get, "/api/v1/secrets/provider/list", null, cancellationToken).ConfigureAwait(false), "secrets"); + + public Task DeleteProviderSecretAsync(string secretId, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Delete, "/api/v1/secrets/provider/" + EscapeRequired(secretId, "secretId"), null, cancellationToken); + + public Task RegisterProviderServiceAsync(RegisterProviderServiceOptions options, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/services", ProviderServiceBody(options), cancellationToken); + + public async Task> ListProviderServicesAsync(CancellationToken cancellationToken = default) + => ReadList(await OwnerRequestAsync(HttpMethod.Get, "/api/v1/services", null, cancellationToken).ConfigureAwait(false), "services"); + + public Task GetRegistrationGuideAsync(CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, "/api/v1/services/registration-guide", null, cancellationToken); + + public Task ParseCurlToServiceManifestAsync(string curlCommand, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/services/parse-curl", new Dictionary { ["curlCommand"] = Require(curlCommand, "curlCommand") }, cancellationToken); + + public Task UpdateProviderServiceAsync(string serviceRecordId, IReadOnlyDictionary patch, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Put, "/api/v1/services/" + EscapeRequired(serviceRecordId, "serviceRecordId"), patch, cancellationToken); + + public Task DeleteProviderServiceAsync(string serviceRecordId, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Delete, "/api/v1/services/" + EscapeRequired(serviceRecordId, "serviceRecordId"), null, cancellationToken); + + public Task PingProviderServiceAsync(string serviceRecordId, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Post, "/api/v1/services/" + EscapeRequired(serviceRecordId, "serviceRecordId") + "/ping", null, cancellationToken); + + public Task GetProviderServiceHealthHistoryAsync(string serviceRecordId, int limit = 100, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, QueryPath("/api/v1/services/" + EscapeRequired(serviceRecordId, "serviceRecordId") + "/health/history", ("limitPerTarget", Limit(limit))), null, cancellationToken); + + public Task GetProviderEarningsSummaryAsync(CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, "/api/v1/providers/earnings/summary", null, cancellationToken); + + public Task GetProviderWithdrawalCapabilityAsync(CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, "/api/v1/providers/withdrawals/capability", null, cancellationToken); + + public Task CreateProviderWithdrawalIntentAsync(string amountUsdc, string? idempotencyKey = null, string? destinationAddress = null, CancellationToken cancellationToken = default) + { + var body = new Dictionary { ["amountUsdc"] = Require(amountUsdc, "amountUsdc") }; + if (!string.IsNullOrWhiteSpace(destinationAddress)) body["destinationAddress"] = destinationAddress; + return OwnerRequestAsync(HttpMethod.Post, "/api/v1/providers/withdrawals/intent", body, cancellationToken, Idempotency(idempotencyKey)); + } + + public Task ListProviderWithdrawalsAsync(int limit = 100, CancellationToken cancellationToken = default) + => OwnerRequestAsync(HttpMethod.Get, QueryPath("/api/v1/providers/withdrawals", ("limit", Limit(limit))), null, cancellationToken); + + public async Task GetProviderServiceAsync(string serviceId, CancellationToken cancellationToken = default) + { + foreach (var service in await ListProviderServicesAsync(cancellationToken).ConfigureAwait(false)) + { + if (service.ServiceId == serviceId) return service; + } + throw new AuthenticationException("SERVICE_NOT_FOUND", "Provider service not found: " + serviceId); + } + + public async Task GetProviderServiceStatusAsync(string serviceId, CancellationToken cancellationToken = default) + { + var service = await GetProviderServiceAsync(serviceId, cancellationToken).ConfigureAwait(false); + return new ProviderServiceStatus(service.ServiceId ?? service.Id ?? serviceId, service.Status ?? "unknown", service.RuntimeAvailable ?? false, service.Health); + } + + private async Task> CredentialListAsync(string path, CancellationToken cancellationToken) + => ReadList(await OwnerRequestAsync(HttpMethod.Get, path, null, cancellationToken).ConfigureAwait(false), "credentials"); + + private async Task GetAsync(string path, CancellationToken cancellationToken) + => await RequestAsync(HttpMethod.Get, path, null, null, cancellationToken).ConfigureAwait(false); + + private async Task OwnerRequestAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken, IReadOnlyDictionary? extraHeaders = null) + { + var headers = new Dictionary(extraHeaders ?? new Dictionary()) + { + ["Authorization"] = "Bearer " + await GetTokenAsync(cancellationToken).ConfigureAwait(false), + }; + return await RequestAsync(method, path, body, headers, cancellationToken).ConfigureAwait(false); + } + + private async Task RequestAsync(HttpMethod method, string path, object? body, IReadOnlyDictionary? headers, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, _gatewayUrl + path); + request.Headers.Add("Accept", "application/json"); + foreach (var header in headers ?? new Dictionary()) request.Headers.Add(header.Key, header.Value); + if (body != null) request.Content = JsonContent.Create(body, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) throw MapError(response.StatusCode, text); + return JsonSerializer.Deserialize(string.IsNullOrWhiteSpace(text) ? "{}" : text, JsonOptions) + ?? throw new SynapseException("EMPTY_RESPONSE", "Gateway returned an empty response."); + } + + private static Exception MapError(HttpStatusCode statusCode, string text) + { + using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text); + var detail = document.RootElement.TryGetProperty("detail", out var node) ? node : document.RootElement; + var code = detail.TryGetProperty("code", out var codeNode) ? codeNode.GetString() ?? "" : ""; + var message = detail.TryGetProperty("message", out var msgNode) ? msgNode.GetString() ?? text : text; + return statusCode switch + { + HttpStatusCode.Unauthorized => new AuthenticationException(code, message), + HttpStatusCode.PaymentRequired => new BudgetException(code, message), + _ => new InvokeException(code, message), + }; + } + + private Dictionary ProviderServiceBody(RegisterProviderServiceOptions options) + { + var kind = FirstText(options.ServiceKind, "api"); + var priceModel = FirstText(options.PriceModel, kind == "llm" ? "token_metered" : "fixed"); + var serviceId = FirstText(options.ServiceId, DefaultServiceId(options.ServiceName)); + return new Dictionary + { + ["serviceId"] = serviceId, + ["agentToolName"] = serviceId, + ["serviceName"] = Require(options.ServiceName, "serviceName"), + ["serviceKind"] = kind, + ["priceModel"] = priceModel, + ["role"] = "Provider", + ["status"] = FirstText(options.Status, "active"), + ["isActive"] = options.IsActive ?? true, + ["pricing"] = ProviderPricing(options, priceModel), + ["summary"] = Require(options.DescriptionForModel, "descriptionForModel"), + ["tags"] = options.Tags ?? Array.Empty(), + ["auth"] = new Dictionary { ["type"] = "gateway_signed" }, + ["invoke"] = new Dictionary { ["method"] = FirstText(options.EndpointMethod, "POST"), ["targets"] = new[] { new Dictionary { ["url"] = Require(options.EndpointUrl, "endpointUrl") } }, ["timeoutMs"] = options.RequestTimeoutMs ?? 15000 }, + ["healthCheck"] = new Dictionary { ["path"] = FirstText(options.HealthPath, "/health"), ["method"] = FirstText(options.HealthMethod, "GET"), ["timeoutMs"] = options.HealthTimeoutMs ?? 3000, ["successCodes"] = new[] { 200 } }, + ["providerProfile"] = new Dictionary { ["displayName"] = FirstText(options.ProviderDisplayName, options.ServiceName) }, + ["payoutAccount"] = new Dictionary { ["payoutAddress"] = FirstText(options.PayoutAddress, _walletAddress), ["chainId"] = options.ChainId ?? 31337, ["settlementCurrency"] = FirstText(options.SettlementCurrency, "USDC") }, + ["governance"] = new Dictionary { ["termsAccepted"] = true, ["riskAcknowledged"] = true }, + }; + } + + private static Dictionary ProviderPricing(RegisterProviderServiceOptions options, string priceModel) + => priceModel == "token_metered" + ? new Dictionary { ["priceModel"] = "token_metered", ["inputPricePer1MTokensUsdc"] = Require(options.InputPricePer1MTokensUsdc, "inputPricePer1MTokensUsdc"), ["outputPricePer1MTokensUsdc"] = Require(options.OutputPricePer1MTokensUsdc, "outputPricePer1MTokensUsdc"), ["currency"] = "USDC" } + : new Dictionary { ["amount"] = Require(options.BasePriceUsdc, "basePriceUsdc"), ["currency"] = "USDC" }; + + private static Dictionary CredentialBody(CredentialOptions? options) + { + var body = new Dictionary(); + if (options == null) return body; + Put(body, "name", options.Name); + Put(body, "maxCalls", options.MaxCalls); + Put(body, "creditLimit", options.CreditLimit); + Put(body, "resetInterval", options.ResetInterval); + Put(body, "rpm", options.Rpm); + Put(body, "expiresInSec", options.ExpiresInSec); + return body; + } + + private static Dictionary QuotaBody(CredentialQuotaOptions? options) + { + var body = new Dictionary(); + if (options == null) return body; + Put(body, "maxCalls", options.MaxCalls); + Put(body, "rpm", options.Rpm); + Put(body, "creditLimit", options.CreditLimit); + Put(body, "resetInterval", options.ResetInterval); + Put(body, "expiresAt", options.ExpiresAt); + return body; + } + + private static IReadOnlyDictionary Idempotency(string? value) + => new Dictionary { ["X-Idempotency-Key"] = string.IsNullOrWhiteSpace(value) ? "dotnet-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() : value }; + + private static IReadOnlyList ReadList(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var array) || array.ValueKind != JsonValueKind.Array) return Array.Empty(); + return array.EnumerateArray().Select(item => item.Deserialize(JsonOptions)!).Where(item => item != null).ToArray(); + } + + private static string QueryPath(string path, params (string Key, object Value)[] pairs) + => path + "?" + string.Join("&", pairs.Select(item => Uri.EscapeDataString(item.Key) + "=" + Uri.EscapeDataString(Convert.ToString(item.Value, System.Globalization.CultureInfo.InvariantCulture) ?? ""))); + + private static string EscapeRequired(string value, string name) => Uri.EscapeDataString(Require(value, name)); + private static int Limit(int limit) => limit <= 0 ? 100 : limit; + private static string Require(string? value, string name) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"{name} is required") : value.Trim(); + private static string FirstText(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? ""; + private static string DefaultServiceId(string name) => new string(Require(name, "serviceName").ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray()).Trim('_'); + private static string GetString(JsonElement root, string property) => root.TryGetProperty(property, out var node) ? node.GetString() ?? "" : ""; + private static void Put(Dictionary body, string key, object? value) + { + if (value != null && !string.IsNullOrWhiteSpace(Convert.ToString(value)) && Convert.ToString(value) != "0") body[key] = value; + } +} + +public sealed record SynapseAuthOptions +{ + public string? WalletAddress { get; set; } + public Func? Signer { get; set; } + public string? Environment { get; set; } + public string? GatewayUrl { get; set; } + public TimeSpan? Timeout { get; set; } + public HttpClient? HttpClient { get; set; } +} + +public sealed record CredentialOptions { public string? Name { get; set; } public int? MaxCalls { get; set; } public string? CreditLimit { get; set; } public string? ResetInterval { get; set; } public int? Rpm { get; set; } public int? ExpiresInSec { get; set; } } +public sealed record CredentialQuotaOptions { public int? MaxCalls { get; set; } public int? Rpm { get; set; } public string? CreditLimit { get; set; } public string? ResetInterval { get; set; } public string? ExpiresAt { get; set; } } +public sealed record RegisterProviderServiceOptions { public string? ServiceName { get; set; } public string? EndpointUrl { get; set; } public string? BasePriceUsdc { get; set; } public string? DescriptionForModel { get; set; } public string? ServiceKind { get; set; } public string? PriceModel { get; set; } public string? InputPricePer1MTokensUsdc { get; set; } public string? OutputPricePer1MTokensUsdc { get; set; } public string? ServiceId { get; set; } public string? ProviderDisplayName { get; set; } public string? PayoutAddress { get; set; } public int? ChainId { get; set; } public string? SettlementCurrency { get; set; } public IReadOnlyList? Tags { get; set; } public string? Status { get; set; } public bool? IsActive { get; set; } public string? EndpointMethod { get; set; } public string? HealthPath { get; set; } public string? HealthMethod { get; set; } public int? HealthTimeoutMs { get; set; } public int? RequestTimeoutMs { get; set; } } +public sealed record ChallengeResponse(bool? Success, string? Challenge, string? Domain); +public sealed record TokenResponse(bool? Success, [property: JsonPropertyName("access_token")] string? AccessToken, [property: JsonPropertyName("token_type")] string? TokenType, [property: JsonPropertyName("expires_in")] long ExpiresIn); +public sealed record AuthLogoutResult(string? Status, bool? Success); +public sealed record OwnerProfile(string? OwnerAddress, string? WalletAddress, JsonElement? Profile); +public sealed record AgentCredential(string? Id = null, [property: JsonPropertyName("credential_id")] string? CredentialId = null, string? Token = null, string? Name = null, string? Status = null); +public sealed record IssueCredentialResult(AgentCredential Credential, string Token); +public sealed record CredentialStatusResult(string? Status, string? CredentialId, bool? Valid, string? CredentialStatus); +public sealed record CredentialRevokeResult(string? Status, string? CredentialId, AgentCredential? Credential); +public sealed record CredentialRotateResult(string? Status, string? CredentialId, string? Token, AgentCredential? Credential); +public sealed record CredentialDeleteResult(string? Status, string? CredentialId); +public sealed record CredentialQuotaUpdateResult(string? Status, string? CredentialId, AgentCredential? Credential); +public sealed record CredentialAuditLogList(IReadOnlyList? Logs); +public sealed record BalanceSummary(JsonElement? OwnerBalance = null, JsonElement? ConsumerAvailableBalance = null, JsonElement? ProviderReceivable = null, JsonElement? PlatformFeeAccrued = null); +public sealed record DepositIntentResult(string? Status, [property: JsonPropertyName("tx_hash")] string? TxHash, JsonElement? Intent); +public sealed record DepositConfirmResult(string? Status, JsonElement? Intent); +public sealed record VoucherRedeemResult(string? Status, string? VoucherCode); +public sealed record UsageLogList(IReadOnlyList? Logs); +public sealed record FinanceAuditLogList(IReadOnlyList? Logs); +public sealed record RiskOverview(JsonElement? Risk); +public sealed record ProviderSecret(string? Id, string? Name, string? OwnerAddress, string? SecretKey, string? MaskedKey, string? Status); +public sealed record IssueProviderSecretResult(string? Status, ProviderSecret Secret); +public sealed record ProviderSecretDeleteResult(string? Status, string? SecretId); +public sealed record ProviderServiceRecord(string? ServiceId, string? Id, string? ServiceName, string? Status, bool? RuntimeAvailable, JsonElement? Health); +public sealed record RegisterProviderServiceResult(string? Status, string? ServiceId, ProviderServiceRecord? Service); +public sealed record ProviderServiceStatus(string ServiceId, string LifecycleStatus, bool RuntimeAvailable, JsonElement? Health); +public sealed record ProviderRegistrationGuide(IReadOnlyList? Steps, JsonElement? Requirements); +public sealed record ServiceManifestDraft(JsonElement? Data, JsonElement? Manifest); +public sealed record ProviderServiceUpdateResult(string? Status, ProviderServiceRecord? Service); +public sealed record ProviderServiceDeleteResult(string? Status, string? ServiceId); +public sealed record ProviderServicePingResult(string? Status, JsonElement? Health); +public sealed record ProviderServiceHealthHistory(IReadOnlyList? History); +public sealed record ProviderEarningsSummary(JsonElement? Total); +public sealed record ProviderWithdrawalCapability(bool? Available); +public sealed record ProviderWithdrawalIntentResult(string? Status, string? IntentId, JsonElement? AmountUsdc, JsonElement? Intent); +public sealed record ProviderWithdrawalList(IReadOnlyList? Withdrawals); diff --git a/dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj b/dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj index f2cf826..0bcc413 100644 --- a/dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj +++ b/dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj @@ -9,4 +9,7 @@ Synapse Network Team Official .NET SDK for SynapseNetwork agent runtime APIs. + + + diff --git a/dotnet/src/SynapseNetwork.Sdk/SynapseProvider.cs b/dotnet/src/SynapseNetwork.Sdk/SynapseProvider.cs new file mode 100644 index 0000000..3d127e7 --- /dev/null +++ b/dotnet/src/SynapseNetwork.Sdk/SynapseProvider.cs @@ -0,0 +1,62 @@ +namespace SynapseNetwork.Sdk; + +public sealed class SynapseProvider(SynapseAuth auth) +{ + public Task IssueSecretAsync(CredentialOptions? options = null, CancellationToken cancellationToken = default) + => auth.IssueProviderSecretAsync(options, cancellationToken); + + public Task> ListSecretsAsync(CancellationToken cancellationToken = default) + => auth.ListProviderSecretsAsync(cancellationToken); + + public Task DeleteSecretAsync(string secretId, CancellationToken cancellationToken = default) + => auth.DeleteProviderSecretAsync(secretId, cancellationToken); + + public Task GetRegistrationGuideAsync(CancellationToken cancellationToken = default) + => auth.GetRegistrationGuideAsync(cancellationToken); + + public Task ParseCurlToServiceManifestAsync(string curlCommand, CancellationToken cancellationToken = default) + => auth.ParseCurlToServiceManifestAsync(curlCommand, cancellationToken); + + public Task RegisterServiceAsync(RegisterProviderServiceOptions options, CancellationToken cancellationToken = default) + => auth.RegisterProviderServiceAsync(options, cancellationToken); + + public Task RegisterLlmServiceAsync(RegisterProviderServiceOptions options, CancellationToken cancellationToken = default) + { + options.ServiceKind = "llm"; + options.PriceModel = "token_metered"; + return auth.RegisterProviderServiceAsync(options, cancellationToken); + } + + public Task> ListServicesAsync(CancellationToken cancellationToken = default) + => auth.ListProviderServicesAsync(cancellationToken); + + public Task GetServiceAsync(string serviceId, CancellationToken cancellationToken = default) + => auth.GetProviderServiceAsync(serviceId, cancellationToken); + + public Task GetServiceStatusAsync(string serviceId, CancellationToken cancellationToken = default) + => auth.GetProviderServiceStatusAsync(serviceId, cancellationToken); + + public Task UpdateServiceAsync(string serviceRecordId, IReadOnlyDictionary patch, CancellationToken cancellationToken = default) + => auth.UpdateProviderServiceAsync(serviceRecordId, patch, cancellationToken); + + public Task DeleteServiceAsync(string serviceRecordId, CancellationToken cancellationToken = default) + => auth.DeleteProviderServiceAsync(serviceRecordId, cancellationToken); + + public Task PingServiceAsync(string serviceRecordId, CancellationToken cancellationToken = default) + => auth.PingProviderServiceAsync(serviceRecordId, cancellationToken); + + public Task GetServiceHealthHistoryAsync(string serviceRecordId, int limit = 100, CancellationToken cancellationToken = default) + => auth.GetProviderServiceHealthHistoryAsync(serviceRecordId, limit, cancellationToken); + + public Task GetEarningsSummaryAsync(CancellationToken cancellationToken = default) + => auth.GetProviderEarningsSummaryAsync(cancellationToken); + + public Task GetWithdrawalCapabilityAsync(CancellationToken cancellationToken = default) + => auth.GetProviderWithdrawalCapabilityAsync(cancellationToken); + + public Task CreateWithdrawalIntentAsync(string amountUsdc, string? idempotencyKey = null, string? destinationAddress = null, CancellationToken cancellationToken = default) + => auth.CreateProviderWithdrawalIntentAsync(amountUsdc, idempotencyKey, destinationAddress, cancellationToken); + + public Task ListWithdrawalsAsync(int limit = 100, CancellationToken cancellationToken = default) + => auth.ListProviderWithdrawalsAsync(limit, cancellationToken); +} diff --git a/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseAuthTests.cs b/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseAuthTests.cs new file mode 100644 index 0000000..037c9df --- /dev/null +++ b/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseAuthTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Nethereum.Signer; +using SynapseNetwork.Sdk; +using Xunit; + +namespace SynapseNetwork.Sdk.Tests; + +public sealed class SynapseAuthTests +{ + private const string PrivateKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + [Fact] + public async Task FromPrivateKeySignsChallengeAndCachesToken() + { + var handler = new AuthFixtureHandler(); + var auth = SynapseAuth.FromPrivateKey(PrivateKey, new SynapseAuthOptions + { + GatewayUrl = "https://gateway.test", + HttpClient = new HttpClient(handler), + }); + + Assert.Equal("jwt_owner", await auth.GetTokenAsync()); + Assert.Equal("jwt_owner", await auth.GetTokenAsync()); + Assert.Equal(1, handler.ChallengeCalls); + Assert.Equal(1, handler.VerifyCalls); + Assert.Equal("0xowner", (await auth.GetOwnerProfileAsync()).OwnerAddress); + } + + [Fact] + public async Task CredentialFinanceAndProviderRoutesReturnNamedObjects() + { + var handler = new AuthFixtureHandler(); + var auth = SynapseAuth.FromPrivateKey(PrivateKey, new SynapseAuthOptions + { + GatewayUrl = "https://gateway.test", + HttpClient = new HttpClient(handler), + }); + + var issued = await auth.IssueCredentialAsync(new CredentialOptions { Name = "agent" }); + Assert.Equal("agt_1", issued.Token); + Assert.Equal("cred_1", issued.Credential.Id); + Assert.Single(await auth.ListCredentialsAsync()); + Assert.Equal("success", (await auth.UpdateCredentialQuotaAsync("cred_1", new CredentialQuotaOptions { CreditLimit = "1.00" })).Status); + Assert.Equal("1.00", (await auth.GetBalanceAsync()).OwnerBalance?.GetString()); + Assert.Single((await auth.GetUsageLogsAsync(5)).Logs ?? Array.Empty()); + + var provider = auth.Provider(); + Assert.Single((await provider.GetRegistrationGuideAsync()).Steps ?? Array.Empty()); + var service = await provider.RegisterServiceAsync(new RegisterProviderServiceOptions + { + ServiceName = "Weather", + EndpointUrl = "https://provider.example.com/invoke", + BasePriceUsdc = "0.01", + DescriptionForModel = "Weather", + }); + Assert.Equal("svc_weather", service.ServiceId); + Assert.True((await provider.GetServiceStatusAsync("svc_weather")).RuntimeAvailable); + Assert.Equal("success", (await provider.UpdateServiceAsync("rec_1", new Dictionary { ["status"] = "active" })).Status); + Assert.Equal("wd_1", (await provider.CreateWithdrawalIntentAsync("0.10", "idem", "0xabc")).IntentId); + + Assert.Contains("POST /api/v1/credentials/agent/issue", handler.Seen); + Assert.Contains("GET /api/v1/balance", handler.Seen); + Assert.Contains("POST /api/v1/services", handler.Seen); + Assert.Contains("POST /api/v1/providers/withdrawals/intent", handler.Seen); + } + + private sealed class AuthFixtureHandler : HttpMessageHandler + { + public List Seen { get; } = []; + public int ChallengeCalls { get; private set; } + public int VerifyCalls { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? ""; + Seen.Add(request.Method.Method + " " + path); + if (path.StartsWith("/api/v1/auth/", StringComparison.Ordinal)) + { + return await Auth(request, path, cancellationToken); + } + Assert.Equal("Bearer jwt_owner", request.Headers.GetValues("Authorization").Single()); + return path switch + { + "/api/v1/credentials/agent/issue" => Json("""{"credential":{"id":"cred_1","token":"agt_1","status":"active"},"token":"agt_1"}"""), + "/api/v1/credentials/agent/list" => Json("""{"credentials":[{"id":"cred_1","name":"agent","status":"active"}]}"""), + "/api/v1/credentials/agent/cred_1/quota" => Json("""{"status":"success","credentialId":"cred_1"}"""), + "/api/v1/balance" => Json("""{"balance":{"ownerBalance":"1.00"}}"""), + "/api/v1/usage/logs" => Json("""{"logs":[{"id":"usage_1"}]}"""), + "/api/v1/services/registration-guide" => Json("""{"steps":["register"]}"""), + "/api/v1/services" when request.Method == HttpMethod.Post => Json("""{"status":"success","serviceId":"svc_weather","service":{"serviceId":"svc_weather"}}"""), + "/api/v1/services" => Json("""{"services":[{"serviceId":"svc_weather","status":"active","runtimeAvailable":true}]}"""), + "/api/v1/services/rec_1" => Json("""{"status":"success"}"""), + "/api/v1/providers/withdrawals/intent" => Json("""{"status":"success","intentId":"wd_1"}"""), + _ => Json("""{"status":"success"}"""), + }; + } + + private async Task Auth(HttpRequestMessage request, string path, CancellationToken cancellationToken) + { + if (path == "/api/v1/auth/challenge") + { + ChallengeCalls++; + Assert.Contains("address=0xfcad0b19bb29d4674531d6f115237e16afce377c", request.RequestUri?.Query); + return Json("""{"success":true,"challenge":"sign me"}"""); + } + if (path == "/api/v1/auth/me") + { + Assert.Equal("Bearer jwt_owner", request.Headers.GetValues("Authorization").Single()); + return Json("""{"ownerAddress":"0xowner"}"""); + } + VerifyCalls++; + var body = request.Content == null ? "" : await request.Content.ReadAsStringAsync(cancellationToken); + Assert.True(ValidSignature(body)); + return Json("""{"success":true,"access_token":"jwt_owner","expires_in":3600}"""); + } + + private static bool ValidSignature(string body) + { + using var doc = JsonDocument.Parse(body); + var signature = doc.RootElement.GetProperty("signature").GetString(); + var signer = new EthereumMessageSigner(); + return signer.EncodeUTF8AndEcRecover("sign me", signature) == "0xFCAd0B19bB29D4674531d6f115237E16AfCE377c"; + } + + private static HttpResponseMessage Json(string body) + => new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + } +} diff --git a/go/examples/e2e/main.go b/go/examples/e2e/main.go index cbbb224..c631635 100644 --- a/go/examples/e2e/main.go +++ b/go/examples/e2e/main.go @@ -52,7 +52,7 @@ func main() { ctx, fixedServiceID, fixedPayload, - synapse.InvokeOptions{CostUSDC: fixedCost, IdempotencyKey: "go-e2e-fixed-" + time.Now().Format("20060102150405")}, + synapse.InvokeOptions{CostUSDC: fixedCost, IdempotencyKey: idempotencyKey("go", "fixed")}, ) must(err) fixedReceipt := awaitReceipt(ctx, client, fixedResult.InvocationID) @@ -77,7 +77,7 @@ func main() { ctx, llmServiceID, llmPayload, - synapse.LLMInvokeOptions{MaxCostUSDC: maxCost, IdempotencyKey: "go-e2e-llm-" + time.Now().Format("20060102150405")}, + synapse.LLMInvokeOptions{MaxCostUSDC: maxCost, IdempotencyKey: idempotencyKey("go", "llm")}, ) must(err) llmReceipt := awaitReceipt(ctx, client, llmResult.InvocationID) @@ -267,6 +267,14 @@ func envInt(name string, fallback int) int { return value } +func idempotencyKey(language, scenario string) string { + prefix := language + "-e2e" + if runID := strings.TrimSpace(os.Getenv("E2E_RUN_ID")); runID != "" { + prefix = runID + "-" + prefix + } + return prefix + "-" + scenario + "-" + time.Now().Format("20060102150405000000000") +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/go/go.mod b/go/go.mod index 0a56bf0..71509ee 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,3 +1,10 @@ module github.com/cliff-personal/Synapse-Network-Sdk/go go 1.22 + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 + golang.org/x/crypto v0.22.0 +) + +require golang.org/x/sys v0.19.0 // indirect diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..d3b51bd --- /dev/null +++ b/go/go.sum @@ -0,0 +1,8 @@ +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/go/synapse/auth.go b/go/synapse/auth.go new file mode 100644 index 0000000..9f6330c --- /dev/null +++ b/go/synapse/auth.go @@ -0,0 +1,452 @@ +package synapse + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" + secpEcdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "golang.org/x/crypto/sha3" +) + +type SignerFunc func(message string) (string, error) + +type Auth struct { + walletAddress string + gatewayURL string + timeout time.Duration + httpClient *http.Client + signer SignerFunc + token string + tokenExpiresAt time.Time +} + +type AuthOptions struct { + WalletAddress string + Signer SignerFunc + Environment string + GatewayURL string + Timeout time.Duration + HTTPClient *http.Client +} + +func NewAuth(opts AuthOptions) (*Auth, error) { + address := strings.ToLower(strings.TrimSpace(opts.WalletAddress)) + if address == "" { + return nil, errors.New("walletAddress is required") + } + if opts.Signer == nil { + return nil, errors.New("signer is required") + } + gatewayURL, err := ResolveGatewayURL(opts.Environment, opts.GatewayURL) + if err != nil { + return nil, err + } + timeout := opts.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: timeout} + } + return &Auth{walletAddress: address, gatewayURL: gatewayURL, timeout: timeout, httpClient: client, signer: opts.Signer}, nil +} + +func NewAuthFromPrivateKey(privateKey string, opts AuthOptions) (*Auth, error) { + raw, err := decodeHex(privateKey) + if err != nil { + return nil, err + } + key := secp256k1.PrivKeyFromBytes(raw) + opts.WalletAddress = ethereumAddress(key.PubKey().SerializeUncompressed()) + opts.Signer = privateKeySigner(key) + return NewAuth(opts) +} + +func privateKeySigner(key *secp256k1.PrivateKey) SignerFunc { + return func(message string) (string, error) { + signature := secpEcdsa.SignCompact(key, ethereumMessageHash(message), false) + // SignCompact returns header || R || S. Ethereum expects R || S || V. + return "0x" + encodeHex(append(signature[1:], signature[0])), nil + } +} + +func ethereumMessageHash(message string) []byte { + prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message)) + hash := sha3.NewLegacyKeccak256() + _, _ = hash.Write([]byte(prefix)) + _, _ = hash.Write([]byte(message)) + return hash.Sum(nil) +} + +func ethereumAddress(uncompressedPubkey []byte) string { + hash := sha3.NewLegacyKeccak256() + _, _ = hash.Write(uncompressedPubkey[1:]) + sum := hash.Sum(nil) + return "0x" + encodeHex(sum[len(sum)-20:]) +} + +func decodeHex(value string) ([]byte, error) { + decoded, err := hex.DecodeString(strings.TrimPrefix(strings.TrimSpace(value), "0x")) + if err != nil { + return nil, err + } + return decoded, nil +} + +func encodeHex(value []byte) string { + return hex.EncodeToString(value) +} + +func (a *Auth) Provider() *Provider { + return &Provider{auth: a} +} + +func (a *Auth) Authenticate(ctx context.Context, forceRefresh ...bool) (string, error) { + refresh := len(forceRefresh) > 0 && forceRefresh[0] + if !refresh && a.token != "" && time.Now().Before(a.tokenExpiresAt.Add(-30*time.Second)) { + return a.token, nil + } + var challenge ChallengeResponse + if err := a.authGet(ctx, "/api/v1/auth/challenge?address="+url.QueryEscape(a.walletAddress), "", &challenge); err != nil { + return "", err + } + if !challenge.Success || strings.TrimSpace(challenge.Challenge) == "" { + return "", AuthenticationError{APIError{Message: "challenge request did not return a usable challenge"}} + } + signature, err := a.signer(challenge.Challenge) + if err != nil { + return "", err + } + var token TokenResponse + err = a.authRequest(ctx, http.MethodPost, "/api/v1/auth/verify", "", map[string]any{ + "wallet_address": a.walletAddress, + "message": challenge.Challenge, + "signature": signature, + }, &token) + if err != nil { + return "", err + } + if !token.Success || strings.TrimSpace(token.AccessToken) == "" { + return "", AuthenticationError{APIError{Message: "auth verify did not return an access token"}} + } + a.token = token.AccessToken + a.tokenExpiresAt = time.Now().Add(time.Duration(maxInt(token.ExpiresIn, 0)) * time.Second) + return a.token, nil +} + +func (a *Auth) GetToken(ctx context.Context) (string, error) { + return a.Authenticate(ctx) +} + +func (a *Auth) Logout(ctx context.Context) (*AuthLogoutResult, error) { + var result AuthLogoutResult + if err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/auth/logout", nil, &result); err != nil { + return nil, err + } + a.token = "" + a.tokenExpiresAt = time.Time{} + return &result, nil +} + +func (a *Auth) GetOwnerProfile(ctx context.Context) (*OwnerProfile, error) { + var result OwnerProfile + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/auth/me", nil, &result) + return &result, err +} + +func (a *Auth) IssueCredential(ctx context.Context, opts CredentialOptions) (*IssueCredentialResult, error) { + var raw map[string]any + if err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/credentials/agent/issue", credentialBody(opts), &raw); err != nil { + return nil, err + } + result := issuedCredentialResult(raw, opts.Name) + if result.Token == "" || result.Credential.ID == "" { + return nil, AuthenticationError{APIError{Message: fmt.Sprintf("credential payload missing: %v", raw)}} + } + return &result, nil +} + +func (a *Auth) ListCredentials(ctx context.Context) ([]AgentCredential, error) { + return a.listCredentials(ctx, "/api/v1/credentials/agent/list") +} + +func (a *Auth) ListActiveCredentials(ctx context.Context) ([]AgentCredential, error) { + return a.listCredentials(ctx, "/api/v1/credentials/agent/list?active_only=true") +} + +func (a *Auth) GetCredentialStatus(ctx context.Context, credentialID string) (*CredentialStatusResult, error) { + var result CredentialStatusResult + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/credentials/agent/"+escapeRequired(credentialID, "credentialID")+"/status", nil, &result) + return &result, err +} + +func (a *Auth) CheckCredentialStatus(ctx context.Context, credentialID string) (*CredentialStatusResult, error) { + return a.GetCredentialStatus(ctx, credentialID) +} + +func (a *Auth) RevokeCredential(ctx context.Context, credentialID string) (*CredentialRevokeResult, error) { + var result CredentialRevokeResult + err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/credentials/agent/"+escapeRequired(credentialID, "credentialID")+"/revoke", nil, &result) + return &result, err +} + +func (a *Auth) RotateCredential(ctx context.Context, credentialID string) (*CredentialRotateResult, error) { + var result CredentialRotateResult + err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/credentials/agent/"+escapeRequired(credentialID, "credentialID")+"/rotate", nil, &result) + return &result, err +} + +func (a *Auth) DeleteCredential(ctx context.Context, credentialID string) (*CredentialDeleteResult, error) { + var result CredentialDeleteResult + err := a.ownerRequest(ctx, http.MethodDelete, "/api/v1/credentials/agent/"+escapeRequired(credentialID, "credentialID"), nil, &result) + return &result, err +} + +func (a *Auth) UpdateCredentialQuota(ctx context.Context, credentialID string, opts CredentialQuotaOptions) (*CredentialQuotaUpdateResult, error) { + var result CredentialQuotaUpdateResult + err := a.ownerRequest(ctx, http.MethodPatch, "/api/v1/credentials/agent/"+escapeRequired(credentialID, "credentialID")+"/quota", quotaBody(opts), &result) + return &result, err +} + +func (a *Auth) GetCredentialAuditLogs(ctx context.Context, limit int) (*CredentialAuditLogList, error) { + var result CredentialAuditLogList + err := a.ownerRequest(ctx, http.MethodGet, queryPath("/api/v1/credentials/agent/audit-logs", map[string]any{"limit": defaultInt(limit, 100)}), nil, &result) + return &result, err +} + +func (a *Auth) EnsureCredential(ctx context.Context, name string, opts CredentialOptions) (string, error) { + for _, credential := range mustSlice(a.ListActiveCredentials(ctx)) { + if strings.TrimSpace(credential.Name) == strings.TrimSpace(name) { + if credential.Token != "" { + return credential.Token, nil + } + rotated, err := a.RotateCredential(ctx, firstNonEmpty(credential.CredentialID, credential.ID)) + if err != nil { + return "", err + } + return firstNonEmpty(rotated.Token, rotated.Credential.Token), nil + } + } + opts.Name = name + issued, err := a.IssueCredential(ctx, opts) + if err != nil { + return "", err + } + return issued.Token, nil +} + +func (a *Auth) GetBalance(ctx context.Context) (*BalanceSummary, error) { + var raw struct { + Balance *BalanceSummary `json:"balance"` + } + if err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/balance", nil, &raw); err != nil { + return nil, err + } + if raw.Balance == nil { + return &BalanceSummary{}, nil + } + return raw.Balance, nil +} + +func (a *Auth) RegisterDepositIntent(ctx context.Context, txHash, amountUSDC, idempotencyKey string) (*DepositIntentResult, error) { + var result DepositIntentResult + err := a.ownerRequestWithIdempotency(ctx, http.MethodPost, "/api/v1/balance/deposit/intent", map[string]any{"txHash": txHash, "amountUsdc": amountUSDC}, idempotencyKey, &result) + return &result, err +} + +func (a *Auth) ConfirmDeposit(ctx context.Context, intentID, eventKey string, confirmations int) (*DepositConfirmResult, error) { + var result DepositConfirmResult + err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/balance/deposit/intents/"+escapeRequired(intentID, "intentID")+"/confirm", map[string]any{"eventKey": eventKey, "confirmations": defaultInt(confirmations, 1)}, &result) + return &result, err +} + +func (a *Auth) SetSpendingLimit(ctx context.Context, spendingLimitUSDC *string) error { + body := map[string]any{"allowUnlimited": true} + if spendingLimitUSDC != nil { + body = map[string]any{"spendingLimitUsdc": *spendingLimitUSDC, "allowUnlimited": false} + } + var ignored map[string]any + return a.ownerRequest(ctx, http.MethodPut, "/api/v1/balance/spending-limit", body, &ignored) +} + +func (a *Auth) RedeemVoucher(ctx context.Context, voucherCode, idempotencyKey string) (*VoucherRedeemResult, error) { + var result VoucherRedeemResult + err := a.ownerRequestWithIdempotency(ctx, http.MethodPost, "/api/v1/balance/vouchers/redeem", map[string]any{"voucherCode": requireValue(voucherCode, "voucherCode")}, idempotencyKey, &result) + return &result, err +} + +func (a *Auth) GetUsageLogs(ctx context.Context, limit int) (*UsageLogList, error) { + var result UsageLogList + err := a.ownerRequest(ctx, http.MethodGet, queryPath("/api/v1/usage/logs", map[string]any{"limit": defaultInt(limit, 100)}), nil, &result) + return &result, err +} + +func (a *Auth) GetFinanceAuditLogs(ctx context.Context, limit int) (*FinanceAuditLogList, error) { + var result FinanceAuditLogList + err := a.ownerRequest(ctx, http.MethodGet, queryPath("/api/v1/finance/audit-logs", map[string]any{"limit": defaultInt(limit, 100)}), nil, &result) + return &result, err +} + +func (a *Auth) GetRiskOverview(ctx context.Context) (*RiskOverview, error) { + var result RiskOverview + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/finance/risk-overview", nil, &result) + return &result, err +} + +func (a *Auth) authGet(ctx context.Context, path, bearer string, target any) error { + return a.authRequest(ctx, http.MethodGet, path, bearer, nil, target) +} + +func (a *Auth) ownerRequest(ctx context.Context, method, path string, body map[string]any, target any) error { + token, err := a.GetToken(ctx) + if err != nil { + return err + } + return a.authRequest(ctx, method, path, "Bearer "+token, body, target) +} + +func (a *Auth) ownerRequestWithIdempotency(ctx context.Context, method, path string, body map[string]any, idem string, target any) error { + token, err := a.GetToken(ctx) + if err != nil { + return err + } + return a.authRequestWithHeaders(ctx, method, path, map[string]string{"Authorization": "Bearer " + token, "X-Idempotency-Key": defaultString(idem, "go-"+fmt.Sprint(time.Now().UnixNano()))}, body, target) +} + +func (a *Auth) authRequest(ctx context.Context, method, path, bearer string, body map[string]any, target any) error { + headers := map[string]string{} + if bearer != "" { + headers["Authorization"] = bearer + } + return a.authRequestWithHeaders(ctx, method, path, headers, body, target) +} + +func (a *Auth) authRequestWithHeaders(ctx context.Context, method, path string, headers map[string]string, body map[string]any, target any) error { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, a.gatewayURL+path, reader) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := a.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return parseAPIError(resp.StatusCode, raw) + } + if len(raw) == 0 { + return nil + } + return json.Unmarshal(raw, target) +} + +func (a *Auth) listCredentials(ctx context.Context, path string) ([]AgentCredential, error) { + var raw struct { + Credentials []AgentCredential `json:"credentials"` + } + err := a.ownerRequest(ctx, http.MethodGet, path, nil, &raw) + return raw.Credentials, err +} + +type Provider struct{ auth *Auth } + +func (p *Provider) IssueSecret(ctx context.Context, opts CredentialOptions) (*IssueProviderSecretResult, error) { + return p.auth.IssueProviderSecret(ctx, opts) +} + +func (p *Provider) ListSecrets(ctx context.Context) ([]ProviderSecret, error) { + return p.auth.ListProviderSecrets(ctx) +} + +func (p *Provider) DeleteSecret(ctx context.Context, secretID string) (*ProviderSecretDeleteResult, error) { + return p.auth.DeleteProviderSecret(ctx, secretID) +} + +func (p *Provider) GetRegistrationGuide(ctx context.Context) (*ProviderRegistrationGuide, error) { + return p.auth.GetRegistrationGuide(ctx) +} + +func (p *Provider) ParseCurlToServiceManifest(ctx context.Context, curlCommand string) (*ServiceManifestDraft, error) { + return p.auth.ParseCurlToServiceManifest(ctx, curlCommand) +} + +func (p *Provider) RegisterService(ctx context.Context, opts RegisterProviderServiceOptions) (*RegisterProviderServiceResult, error) { + return p.auth.RegisterProviderService(ctx, opts) +} + +func (p *Provider) RegisterLLMService(ctx context.Context, opts RegisterProviderServiceOptions) (*RegisterProviderServiceResult, error) { + opts.ServiceKind = "llm" + opts.PriceModel = "token_metered" + return p.auth.RegisterProviderService(ctx, opts) +} + +func (p *Provider) ListServices(ctx context.Context) ([]ProviderServiceRecord, error) { + return p.auth.ListProviderServices(ctx) +} + +func (p *Provider) GetService(ctx context.Context, serviceID string) (*ProviderServiceRecord, error) { + return p.auth.GetProviderService(ctx, serviceID) +} + +func (p *Provider) GetServiceStatus(ctx context.Context, serviceID string) (*ProviderServiceStatus, error) { + return p.auth.GetProviderServiceStatus(ctx, serviceID) +} + +func (p *Provider) UpdateService(ctx context.Context, serviceRecordID string, patch map[string]any) (*ProviderServiceUpdateResult, error) { + return p.auth.UpdateProviderService(ctx, serviceRecordID, patch) +} + +func (p *Provider) DeleteService(ctx context.Context, serviceRecordID string) (*ProviderServiceDeleteResult, error) { + return p.auth.DeleteProviderService(ctx, serviceRecordID) +} + +func (p *Provider) PingService(ctx context.Context, serviceRecordID string) (*ProviderServicePingResult, error) { + return p.auth.PingProviderService(ctx, serviceRecordID) +} + +func (p *Provider) GetServiceHealthHistory(ctx context.Context, serviceRecordID string, limit int) (*ProviderServiceHealthHistory, error) { + return p.auth.GetProviderServiceHealthHistory(ctx, serviceRecordID, limit) +} + +func (p *Provider) GetEarningsSummary(ctx context.Context) (*ProviderEarningsSummary, error) { + return p.auth.GetProviderEarningsSummary(ctx) +} + +func (p *Provider) GetWithdrawalCapability(ctx context.Context) (*ProviderWithdrawalCapability, error) { + return p.auth.GetProviderWithdrawalCapability(ctx) +} + +func (p *Provider) CreateWithdrawalIntent(ctx context.Context, amountUSDC, idempotencyKey, destinationAddress string) (*ProviderWithdrawalIntentResult, error) { + return p.auth.CreateProviderWithdrawalIntent(ctx, amountUSDC, idempotencyKey, destinationAddress) +} + +func (p *Provider) ListWithdrawals(ctx context.Context, limit int) (*ProviderWithdrawalList, error) { + return p.auth.ListProviderWithdrawals(ctx, limit) +} diff --git a/go/synapse/auth_test.go b/go/synapse/auth_test.go new file mode 100644 index 0000000..8a68f4b --- /dev/null +++ b/go/synapse/auth_test.go @@ -0,0 +1,185 @@ +package synapse + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + secpEcdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" +) + +const testPrivateKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func TestAuthFromPrivateKeySignsChallengeAndCachesToken(t *testing.T) { + challengeCalls := 0 + verifyCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/auth/challenge": + challengeCalls++ + if r.URL.Query().Get("address") != "0xfcad0b19bb29d4674531d6f115237e16afce377c" { + t.Fatalf("unexpected address query: %s", r.URL.RawQuery) + } + writeJSON(t, w, map[string]any{"success": true, "challenge": "sign me", "domain": "synapse"}) + case "/api/v1/auth/verify": + verifyCalls++ + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !validTestSignature(body["signature"], body["message"]) { + t.Fatalf("signature did not recover expected address: %#v", body) + } + writeJSON(t, w, map[string]any{"success": true, "access_token": "jwt_owner", "token_type": "bearer", "expires_in": 3600}) + case "/api/v1/auth/me": + if r.Header.Get("Authorization") != "Bearer jwt_owner" { + t.Fatalf("missing bearer token") + } + writeJSON(t, w, map[string]any{"ownerAddress": "0xowner"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + auth, err := NewAuthFromPrivateKey(testPrivateKey, AuthOptions{GatewayURL: server.URL}) + if err != nil { + t.Fatal(err) + } + if token, err := auth.GetToken(context.Background()); err != nil || token != "jwt_owner" { + t.Fatalf("unexpected token %q err=%v", token, err) + } + if token, err := auth.GetToken(context.Background()); err != nil || token != "jwt_owner" { + t.Fatalf("unexpected cached token %q err=%v", token, err) + } + if challengeCalls != 1 || verifyCalls != 1 { + t.Fatalf("token was not cached challenge=%d verify=%d", challengeCalls, verifyCalls) + } + profile, err := auth.GetOwnerProfile(context.Background()) + if err != nil || profile.OwnerAddress != "0xowner" { + t.Fatalf("unexpected profile %#v err=%v", profile, err) + } +} + +func TestAuthCredentialFinanceAndProviderRoutes(t *testing.T) { + var seen []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Method+" "+r.URL.Path) + if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") { + authFixture(t, w, r) + return + } + if r.Header.Get("Authorization") != "Bearer jwt_owner" { + t.Fatalf("missing Authorization for %s", r.URL.Path) + } + switch r.URL.Path { + case "/api/v1/credentials/agent/issue": + writeJSON(t, w, map[string]any{"credential": map[string]any{"id": "cred_1", "token": "agt_1", "status": "active"}, "token": "agt_1"}) + case "/api/v1/credentials/agent/list": + writeJSON(t, w, map[string]any{"credentials": []any{map[string]any{"id": "cred_1", "name": "agent", "status": "active"}}}) + case "/api/v1/credentials/agent/cred_1/quota": + writeJSON(t, w, map[string]any{"status": "success", "credentialId": "cred_1"}) + case "/api/v1/credentials/agent/audit-logs": + writeJSON(t, w, map[string]any{"logs": []any{map[string]any{"action": "issue"}}}) + case "/api/v1/balance": + writeJSON(t, w, map[string]any{"balance": map[string]any{"ownerBalance": "1.00"}}) + case "/api/v1/usage/logs": + writeJSON(t, w, map[string]any{"logs": []any{map[string]any{"id": "usage_1"}}}) + case "/api/v1/services/registration-guide": + writeJSON(t, w, map[string]any{"steps": []any{"register"}}) + case "/api/v1/services": + if r.Method == http.MethodPost { + writeJSON(t, w, map[string]any{"status": "success", "serviceId": "svc_weather", "service": map[string]any{"serviceId": "svc_weather"}}) + } else { + writeJSON(t, w, map[string]any{"services": []any{map[string]any{"serviceId": "svc_weather", "status": "active", "runtimeAvailable": true}}}) + } + case "/api/v1/services/rec_1": + writeJSON(t, w, map[string]any{"status": "success"}) + case "/api/v1/providers/withdrawals/intent": + writeJSON(t, w, map[string]any{"status": "success", "intentId": "wd_1"}) + default: + writeJSON(t, w, map[string]any{"status": "success"}) + } + })) + defer server.Close() + auth, err := NewAuthFromPrivateKey(testPrivateKey, AuthOptions{GatewayURL: server.URL}) + if err != nil { + t.Fatal(err) + } + issued, err := auth.IssueCredential(context.Background(), CredentialOptions{Name: "agent"}) + if err != nil || issued.Token != "agt_1" { + t.Fatalf("unexpected issued %#v err=%v", issued, err) + } + if _, err = auth.ListCredentials(context.Background()); err != nil { + t.Fatal(err) + } + if _, err = auth.UpdateCredentialQuota(context.Background(), "cred_1", CredentialQuotaOptions{CreditLimit: "1.00"}); err != nil { + t.Fatal(err) + } + if _, err = auth.GetCredentialAuditLogs(context.Background(), 10); err != nil { + t.Fatal(err) + } + if balance, err := auth.GetBalance(context.Background()); err != nil || balance.OwnerBalance != "1.00" { + t.Fatalf("unexpected balance %#v err=%v", balance, err) + } + if _, err = auth.GetUsageLogs(context.Background(), 5); err != nil { + t.Fatal(err) + } + provider := auth.Provider() + if _, err = provider.GetRegistrationGuide(context.Background()); err != nil { + t.Fatal(err) + } + if _, err = provider.RegisterService(context.Background(), RegisterProviderServiceOptions{ServiceName: "Weather", EndpointURL: "https://provider.example.com/invoke", BasePriceUSDC: "0.01", DescriptionForModel: "Weather"}); err != nil { + t.Fatal(err) + } + if status, err := provider.GetServiceStatus(context.Background(), "svc_weather"); err != nil || !status.RuntimeAvailable { + t.Fatalf("unexpected status %#v err=%v", status, err) + } + if _, err = provider.UpdateService(context.Background(), "rec_1", map[string]any{"status": "active"}); err != nil { + t.Fatal(err) + } + if _, err = provider.CreateWithdrawalIntent(context.Background(), "0.10", "idem", "0xabc"); err != nil { + t.Fatal(err) + } + assertSeen(t, seen, "POST /api/v1/credentials/agent/issue", "GET /api/v1/balance", "POST /api/v1/services", "POST /api/v1/providers/withdrawals/intent") +} + +func authFixture(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + if r.URL.Path == "/api/v1/auth/challenge" { + writeJSON(t, w, map[string]any{"success": true, "challenge": "sign me"}) + return + } + writeJSON(t, w, map[string]any{"success": true, "access_token": "jwt_owner", "expires_in": 3600}) +} + +func validTestSignature(signatureHex, message string) bool { + raw, err := decodeHex(signatureHex) + if err != nil || len(raw) != 65 { + return false + } + compact := append([]byte{raw[64]}, raw[:64]...) + pub, _, err := secpEcdsa.RecoverCompact(compact, ethereumMessageHash(message)) + return err == nil && ethereumAddress(pub.SerializeUncompressed()) == "0xfcad0b19bb29d4674531d6f115237e16afce377c" +} + +func writeJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Fatal(err) + } +} + +func assertSeen(t *testing.T, seen []string, expected ...string) { + t.Helper() + joined := strings.Join(seen, "\n") + for _, item := range expected { + if !strings.Contains(joined, item) { + t.Fatalf("missing route %s in:\n%s", item, joined) + } + } +} diff --git a/go/synapse/control_models.go b/go/synapse/control_models.go new file mode 100644 index 0000000..3e913e1 --- /dev/null +++ b/go/synapse/control_models.go @@ -0,0 +1,267 @@ +package synapse + +type ChallengeResponse struct { + Success bool `json:"success,omitempty"` + Challenge string `json:"challenge,omitempty"` + Domain string `json:"domain,omitempty"` +} + +type TokenResponse struct { + Success bool `json:"success,omitempty"` + AccessToken string `json:"access_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` +} + +type AuthLogoutResult struct { + Status string `json:"status,omitempty"` + Success bool `json:"success,omitempty"` +} + +type OwnerProfile struct { + OwnerAddress string `json:"ownerAddress,omitempty"` + WalletAddress string `json:"walletAddress,omitempty"` + Profile map[string]any `json:"profile,omitempty"` +} + +type CredentialOptions struct { + Name string + MaxCalls int + CreditLimit string + ResetInterval string + RPM int + ExpiresInSec int + Expiration int +} + +type CredentialQuotaOptions struct { + MaxCalls int + RPM int + CreditLimit string + ResetInterval string + ExpiresAt string + Expiration int +} + +type AgentCredential struct { + ID string `json:"id,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + Token string `json:"token,omitempty"` + Name string `json:"name,omitempty"` + MaxCalls int `json:"maxCalls,omitempty"` + RPM int `json:"rpm,omitempty"` + CreditLimit string `json:"creditLimit,omitempty"` + ResetInterval string `json:"resetInterval,omitempty"` + ExpiresAt any `json:"expiresAt,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt any `json:"createdAt,omitempty"` +} + +type IssueCredentialResult struct { + Credential AgentCredential `json:"credential"` + Token string `json:"token,omitempty"` +} + +type CredentialStatusResult struct { + Status string `json:"status,omitempty"` + CredentialID string `json:"credentialId,omitempty"` + Valid bool `json:"valid,omitempty"` + CredentialStatus string `json:"credentialStatus,omitempty"` + IsExpired bool `json:"isExpired,omitempty"` + CallsExhausted bool `json:"callsExhausted,omitempty"` +} + +type CredentialRevokeResult struct { + Status string `json:"status,omitempty"` + CredentialID string `json:"credentialId,omitempty"` + Credential AgentCredential `json:"credential,omitempty"` +} + +type CredentialRotateResult struct { + Status string `json:"status,omitempty"` + CredentialID string `json:"credentialId,omitempty"` + Token string `json:"token,omitempty"` + Credential AgentCredential `json:"credential,omitempty"` +} + +type CredentialDeleteResult struct { + Status string `json:"status,omitempty"` + CredentialID string `json:"credentialId,omitempty"` +} + +type CredentialQuotaUpdateResult struct { + Status string `json:"status,omitempty"` + CredentialID string `json:"credentialId,omitempty"` + Credential AgentCredential `json:"credential,omitempty"` +} + +type CredentialAuditLogList struct { + Logs []map[string]any `json:"logs,omitempty"` +} + +type BalanceSummary struct { + OwnerBalance any `json:"ownerBalance,omitempty"` + ConsumerAvailableBalance any `json:"consumerAvailableBalance,omitempty"` + ProviderReceivable any `json:"providerReceivable,omitempty"` + PlatformFeeAccrued any `json:"platformFeeAccrued,omitempty"` +} + +type DepositIntentResult struct { + Status string `json:"status,omitempty"` + TxHash string `json:"tx_hash,omitempty"` + Intent DepositIntentRecord `json:"intent,omitempty"` +} + +type DepositConfirmResult struct { + Status string `json:"status,omitempty"` + Intent DepositIntentRecord `json:"intent,omitempty"` +} + +type DepositIntentRecord struct { + ID string `json:"id,omitempty"` + IntentID string `json:"intentId,omitempty"` + DepositIntentID string `json:"depositIntentId,omitempty"` + EventKey string `json:"eventKey,omitempty"` + TxHash string `json:"txHash,omitempty"` +} + +type VoucherRedeemResult struct { + Status string `json:"status,omitempty"` + VoucherCode string `json:"voucherCode,omitempty"` +} + +type UsageLogList struct { + Logs []map[string]any `json:"logs,omitempty"` +} + +type FinanceAuditLogList struct { + Logs []map[string]any `json:"logs,omitempty"` +} + +type RiskOverview struct { + Risk map[string]any `json:"risk,omitempty"` +} + +type ProviderSecret struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + OwnerAddress string `json:"ownerAddress,omitempty"` + SecretKey string `json:"secretKey,omitempty"` + MaskedKey string `json:"maskedKey,omitempty"` + Status string `json:"status,omitempty"` + RPM int `json:"rpm,omitempty"` + CreditLimit string `json:"creditLimit,omitempty"` + ResetInterval string `json:"resetInterval,omitempty"` + Expiration int `json:"expiration,omitempty"` +} + +type IssueProviderSecretResult struct { + Status string `json:"status,omitempty"` + Secret ProviderSecret `json:"secret"` +} + +type ProviderSecretDeleteResult struct { + Status string `json:"status,omitempty"` + SecretID string `json:"secretId,omitempty"` +} + +type RegisterProviderServiceOptions struct { + ServiceName string + EndpointURL string + BasePriceUSDC string + DescriptionForModel string + ServiceKind string + PriceModel string + InputPricePer1MTokensUSDC string + OutputPricePer1MTokensUSDC string + DefaultMaxOutputTokens int + HoldBufferMultiplier string + MaxAutoHoldUSDC string + ServiceID string + ProviderDisplayName string + PayoutAddress string + ChainID int + SettlementCurrency string + Tags []string + Status string + IsActive *bool + InputSchema map[string]any + OutputSchema map[string]any + EndpointMethod string + HealthPath string + HealthMethod string + HealthTimeoutMS int + RequestTimeoutMS int + GovernanceNote string +} + +type RegisterProviderServiceResult struct { + Status string `json:"status,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + Service ProviderServiceRecord `json:"service"` +} + +type ProviderServiceRecord struct { + ServiceRecord + OwnerAddress string `json:"ownerAddress,omitempty"` + IsActive bool `json:"isActive,omitempty"` + Auth map[string]any `json:"auth,omitempty"` + RuntimeAvailable bool `json:"runtimeAvailable,omitempty"` + Health map[string]any `json:"health,omitempty"` + Invoke map[string]any `json:"invoke,omitempty"` +} + +type ProviderServiceStatus struct { + ServiceID string `json:"serviceId,omitempty"` + LifecycleStatus string `json:"lifecycleStatus,omitempty"` + RuntimeAvailable bool `json:"runtimeAvailable,omitempty"` + Health map[string]any `json:"health,omitempty"` +} + +type ProviderRegistrationGuide struct { + Steps []any `json:"steps,omitempty"` + Requirements map[string]any `json:"requirements,omitempty"` +} + +type ServiceManifestDraft struct { + Data map[string]any `json:"data,omitempty"` + Manifest map[string]any `json:"manifest,omitempty"` +} + +type ProviderServiceUpdateResult struct { + Status string `json:"status,omitempty"` + Service ProviderServiceRecord `json:"service,omitempty"` +} + +type ProviderServiceDeleteResult struct { + Status string `json:"status,omitempty"` + ServiceID string `json:"serviceId,omitempty"` +} + +type ProviderServicePingResult struct { + Status string `json:"status,omitempty"` + Health map[string]any `json:"health,omitempty"` +} + +type ProviderServiceHealthHistory struct { + History []map[string]any `json:"history,omitempty"` +} + +type ProviderEarningsSummary struct { + Total any `json:"total,omitempty"` +} + +type ProviderWithdrawalCapability struct { + Available bool `json:"available,omitempty"` +} + +type ProviderWithdrawalIntentResult struct { + Status string `json:"status,omitempty"` + IntentID string `json:"intentId,omitempty"` + AmountUSDC any `json:"amountUsdc,omitempty"` + Intent map[string]any `json:"intent,omitempty"` +} + +type ProviderWithdrawalList struct { + Withdrawals []map[string]any `json:"withdrawals,omitempty"` +} diff --git a/go/synapse/provider_control.go b/go/synapse/provider_control.go new file mode 100644 index 0000000..60ddd30 --- /dev/null +++ b/go/synapse/provider_control.go @@ -0,0 +1,338 @@ +package synapse + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +func (a *Auth) IssueProviderSecret(ctx context.Context, opts CredentialOptions) (*IssueProviderSecretResult, error) { + var result IssueProviderSecretResult + err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/secrets/provider/issue", credentialBody(opts), &result) + if err == nil && result.Secret.ID == "" { + err = AuthenticationError{APIError{Message: "provider secret payload missing"}} + } + return &result, err +} + +func (a *Auth) ListProviderSecrets(ctx context.Context) ([]ProviderSecret, error) { + var raw struct { + Secrets []ProviderSecret `json:"secrets"` + } + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/secrets/provider/list", nil, &raw) + return raw.Secrets, err +} + +func (a *Auth) DeleteProviderSecret(ctx context.Context, secretID string) (*ProviderSecretDeleteResult, error) { + var result ProviderSecretDeleteResult + err := a.ownerRequest(ctx, http.MethodDelete, "/api/v1/secrets/provider/"+escapeRequired(secretID, "secretID"), nil, &result) + return &result, err +} + +func (a *Auth) RegisterProviderService(ctx context.Context, opts RegisterProviderServiceOptions) (*RegisterProviderServiceResult, error) { + body, err := providerServiceBody(a.walletAddress, opts) + if err != nil { + return nil, err + } + var raw RegisterProviderServiceResult + if err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/services", body, &raw); err != nil { + return nil, err + } + if raw.ServiceID == "" { + raw.ServiceID = firstNonEmpty(raw.Service.ServiceID, body["serviceId"].(string)) + } + if raw.Service.ServiceID == "" { + raw.Service.ServiceID = raw.ServiceID + } + return &raw, nil +} + +func (a *Auth) RegisterLLMService(ctx context.Context, opts RegisterProviderServiceOptions) (*RegisterProviderServiceResult, error) { + opts.ServiceKind = "llm" + opts.PriceModel = "token_metered" + return a.RegisterProviderService(ctx, opts) +} + +func (a *Auth) ListProviderServices(ctx context.Context) ([]ProviderServiceRecord, error) { + var raw struct { + Services []ProviderServiceRecord `json:"services"` + } + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/services", nil, &raw) + return raw.Services, err +} + +func (a *Auth) GetRegistrationGuide(ctx context.Context) (*ProviderRegistrationGuide, error) { + var result ProviderRegistrationGuide + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/services/registration-guide", nil, &result) + return &result, err +} + +func (a *Auth) ParseCurlToServiceManifest(ctx context.Context, curlCommand string) (*ServiceManifestDraft, error) { + var result ServiceManifestDraft + err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/services/parse-curl", map[string]any{"curlCommand": requireValue(curlCommand, "curlCommand")}, &result) + return &result, err +} + +func (a *Auth) UpdateProviderService(ctx context.Context, serviceRecordID string, patch map[string]any) (*ProviderServiceUpdateResult, error) { + var result ProviderServiceUpdateResult + err := a.ownerRequest(ctx, http.MethodPut, "/api/v1/services/"+escapeRequired(serviceRecordID, "serviceRecordID"), patch, &result) + return &result, err +} + +func (a *Auth) DeleteProviderService(ctx context.Context, serviceRecordID string) (*ProviderServiceDeleteResult, error) { + var result ProviderServiceDeleteResult + err := a.ownerRequest(ctx, http.MethodDelete, "/api/v1/services/"+escapeRequired(serviceRecordID, "serviceRecordID"), nil, &result) + return &result, err +} + +func (a *Auth) PingProviderService(ctx context.Context, serviceRecordID string) (*ProviderServicePingResult, error) { + var result ProviderServicePingResult + err := a.ownerRequest(ctx, http.MethodPost, "/api/v1/services/"+escapeRequired(serviceRecordID, "serviceRecordID")+"/ping", nil, &result) + return &result, err +} + +func (a *Auth) GetProviderServiceHealthHistory(ctx context.Context, serviceRecordID string, limit int) (*ProviderServiceHealthHistory, error) { + var result ProviderServiceHealthHistory + path := queryPath("/api/v1/services/"+escapeRequired(serviceRecordID, "serviceRecordID")+"/health/history", map[string]any{"limitPerTarget": defaultInt(limit, 100)}) + err := a.ownerRequest(ctx, http.MethodGet, path, nil, &result) + return &result, err +} + +func (a *Auth) GetProviderEarningsSummary(ctx context.Context) (*ProviderEarningsSummary, error) { + var result ProviderEarningsSummary + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/providers/earnings/summary", nil, &result) + return &result, err +} + +func (a *Auth) GetProviderWithdrawalCapability(ctx context.Context) (*ProviderWithdrawalCapability, error) { + var result ProviderWithdrawalCapability + err := a.ownerRequest(ctx, http.MethodGet, "/api/v1/providers/withdrawals/capability", nil, &result) + return &result, err +} + +func (a *Auth) CreateProviderWithdrawalIntent(ctx context.Context, amountUSDC, idempotencyKey, destinationAddress string) (*ProviderWithdrawalIntentResult, error) { + body := map[string]any{"amountUsdc": requireValue(amountUSDC, "amountUSDC")} + if strings.TrimSpace(destinationAddress) != "" { + body["destinationAddress"] = strings.TrimSpace(destinationAddress) + } + var result ProviderWithdrawalIntentResult + err := a.ownerRequestWithIdempotency(ctx, http.MethodPost, "/api/v1/providers/withdrawals/intent", body, idempotencyKey, &result) + return &result, err +} + +func (a *Auth) ListProviderWithdrawals(ctx context.Context, limit int) (*ProviderWithdrawalList, error) { + var result ProviderWithdrawalList + err := a.ownerRequest(ctx, http.MethodGet, queryPath("/api/v1/providers/withdrawals", map[string]any{"limit": defaultInt(limit, 100)}), nil, &result) + return &result, err +} + +func (a *Auth) GetProviderService(ctx context.Context, serviceID string) (*ProviderServiceRecord, error) { + resolved := requireValue(serviceID, "serviceID") + services, err := a.ListProviderServices(ctx) + if err != nil { + return nil, err + } + for _, service := range services { + if service.ServiceID == resolved { + return &service, nil + } + } + return nil, AuthenticationError{APIError{Message: "provider service not found: " + resolved}} +} + +func (a *Auth) GetProviderServiceStatus(ctx context.Context, serviceID string) (*ProviderServiceStatus, error) { + service, err := a.GetProviderService(ctx, serviceID) + if err != nil { + return nil, err + } + return &ProviderServiceStatus{ + ServiceID: firstNonEmpty(service.ServiceID, service.ID), + LifecycleStatus: defaultString(service.Status, "unknown"), + RuntimeAvailable: service.RuntimeAvailable, + Health: service.Health, + }, nil +} + +func providerServiceBody(walletAddress string, opts RegisterProviderServiceOptions) (map[string]any, error) { + name := requireValue(opts.ServiceName, "serviceName") + endpoint := requireValue(opts.EndpointURL, "endpointURL") + summary := requireValue(opts.DescriptionForModel, "descriptionForModel") + serviceID := defaultString(opts.ServiceID, defaultServiceID(name)) + kind := defaultString(opts.ServiceKind, "api") + priceModel := defaultString(opts.PriceModel, map[bool]string{true: "token_metered", false: "fixed"}[kind == "llm"]) + pricing, err := providerPricing(opts, priceModel) + if err != nil { + return nil, err + } + active := true + if opts.IsActive != nil { + active = *opts.IsActive + } + return map[string]any{ + "serviceId": serviceID, + "agentToolName": serviceID, + "serviceName": name, + "serviceKind": kind, + "priceModel": priceModel, + "role": "Provider", + "status": defaultString(opts.Status, "active"), + "isActive": active, + "pricing": pricing, + "summary": summary, + "tags": opts.Tags, + "auth": map[string]any{"type": "gateway_signed"}, + "invoke": providerInvoke(endpoint, opts), + "healthCheck": providerHealth(opts), + "providerProfile": map[string]any{"displayName": defaultString(opts.ProviderDisplayName, name)}, + "payoutAccount": map[string]any{ + "payoutAddress": defaultString(opts.PayoutAddress, walletAddress), + "chainId": defaultInt(opts.ChainID, 31337), + "settlementCurrency": defaultString(opts.SettlementCurrency, "USDC"), + }, + "governance": map[string]any{"termsAccepted": true, "riskAcknowledged": true, "note": opts.GovernanceNote}, + }, nil +} + +func providerPricing(opts RegisterProviderServiceOptions, priceModel string) (map[string]any, error) { + if priceModel == "token_metered" { + if strings.TrimSpace(opts.InputPricePer1MTokensUSDC) == "" || strings.TrimSpace(opts.OutputPricePer1MTokensUSDC) == "" { + return nil, errors.New("input and output token prices are required") + } + pricing := map[string]any{"priceModel": "token_metered", "inputPricePer1MTokensUsdc": opts.InputPricePer1MTokensUSDC, "outputPricePer1MTokensUsdc": opts.OutputPricePer1MTokensUSDC, "currency": "USDC"} + if opts.DefaultMaxOutputTokens != 0 { + pricing["defaultMaxOutputTokens"] = opts.DefaultMaxOutputTokens + } + if opts.HoldBufferMultiplier != "" { + pricing["holdBufferMultiplier"] = opts.HoldBufferMultiplier + } + if opts.MaxAutoHoldUSDC != "" { + pricing["maxAutoHoldUsdc"] = opts.MaxAutoHoldUSDC + } + return pricing, nil + } + if strings.TrimSpace(opts.BasePriceUSDC) == "" { + return nil, errors.New("basePriceUSDC is required") + } + return map[string]any{"amount": opts.BasePriceUSDC, "currency": "USDC"}, nil +} + +func providerInvoke(endpoint string, opts RegisterProviderServiceOptions) map[string]any { + input := opts.InputSchema + if input == nil { + input = map[string]any{"type": "object", "properties": map[string]any{}, "required": []any{}} + } + output := opts.OutputSchema + if output == nil { + output = map[string]any{"type": "object", "properties": map[string]any{}} + } + return map[string]any{"method": defaultString(opts.EndpointMethod, "POST"), "targets": []map[string]any{{"url": endpoint}}, "timeoutMs": defaultInt(opts.RequestTimeoutMS, 15000), "request": map[string]any{"body": input}, "response": map[string]any{"body": output}} +} + +func providerHealth(opts RegisterProviderServiceOptions) map[string]any { + return map[string]any{"path": defaultString(opts.HealthPath, "/health"), "method": defaultString(opts.HealthMethod, "GET"), "timeoutMs": defaultInt(opts.HealthTimeoutMS, 3000), "successCodes": []int{200}, "healthyThreshold": 1, "unhealthyThreshold": 3} +} + +func defaultServiceID(name string) string { + cleaned := strings.Trim(strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + return r + } + if r >= 'A' && r <= 'Z' { + return r + 32 + } + return '_' + }, strings.TrimSpace(name)), "_") + if cleaned == "" { + return fmt.Sprintf("service_%d", time.Now().UnixNano()) + } + return cleaned +} + +func credentialBody(opts CredentialOptions) map[string]any { + body := map[string]any{} + setIf(body, "name", opts.Name) + setIf(body, "maxCalls", opts.MaxCalls) + setIf(body, "creditLimit", opts.CreditLimit) + setIf(body, "resetInterval", opts.ResetInterval) + setIf(body, "rpm", opts.RPM) + setIf(body, "expiresInSec", opts.ExpiresInSec) + setIf(body, "expiration", opts.Expiration) + return body +} + +func quotaBody(opts CredentialQuotaOptions) map[string]any { + body := map[string]any{} + setIf(body, "maxCalls", opts.MaxCalls) + setIf(body, "rpm", opts.RPM) + setIf(body, "creditLimit", opts.CreditLimit) + setIf(body, "resetInterval", opts.ResetInterval) + setIf(body, "expiresAt", opts.ExpiresAt) + setIf(body, "expiration", opts.Expiration) + return body +} + +func issuedCredentialResult(raw map[string]any, fallbackName string) IssueCredentialResult { + credentialPayload, _ := raw["credential"].(map[string]any) + credential := AgentCredential{} + rawCredential, _ := json.Marshal(credentialPayload) + _ = json.Unmarshal(rawCredential, &credential) + token := firstNonEmpty(stringValue(raw["token"]), credential.Token, stringValue(raw["credential_token"])) + id := firstNonEmpty(stringValue(raw["credential_id"]), stringValue(raw["id"]), credential.ID, credential.CredentialID) + credential.ID = firstNonEmpty(credential.ID, id) + credential.CredentialID = firstNonEmpty(credential.CredentialID, id) + credential.Token = firstNonEmpty(credential.Token, token) + credential.Name = firstNonEmpty(credential.Name, fallbackName) + return IssueCredentialResult{Credential: credential, Token: token} +} + +func setIf(body map[string]any, key string, value any) { + if stringValue(value) != "" && stringValue(value) != "0" { + body[key] = value + } +} + +func escapeRequired(value, name string) string { + return url.PathEscape(requireValue(value, name)) +} + +func requireValue(value, name string) string { + resolved := strings.TrimSpace(value) + if resolved == "" { + panic(name + " is required") + } + return resolved +} + +func queryPath(path string, params map[string]any) string { + values := url.Values{} + for key, value := range params { + if stringValue(value) != "" { + values.Set(key, stringValue(value)) + } + } + if len(values) == 0 { + return path + } + return path + "?" + values.Encode() +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mustSlice[T any](values []T, err error) []T { + if err != nil { + return nil + } + return values +} diff --git a/java/examples/pom.xml b/java/examples/pom.xml index 2255328..c82bcc9 100644 --- a/java/examples/pom.xml +++ b/java/examples/pom.xml @@ -12,6 +12,7 @@ 17 UTF-8 2.18.3 + 4.12.3 @@ -20,6 +21,11 @@ jackson-databind ${jackson.version} + + org.web3j + crypto + ${web3j.version} + diff --git a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java index 0e80d7b..dd4ec6f 100644 --- a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java +++ b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java @@ -22,7 +22,7 @@ public static void main(String[] args) { ExampleSupport.FixedTarget fixed = ExampleSupport.fixedTarget(client); SynapseClient.InvokeOptions fixedOptions = new SynapseClient.InvokeOptions(); fixedOptions.costUsdc = fixed.costUsdc(); - fixedOptions.idempotencyKey = "java-e2e-fixed-" + System.currentTimeMillis(); + fixedOptions.idempotencyKey = ExampleSupport.idempotencyKey("fixed"); SynapseClient.InvocationResult fixedResult = client.invoke(fixed.serviceId(), fixed.payload(), fixedOptions); SynapseClient.InvocationResult fixedReceipt = ExampleSupport.awaitReceipt(client, fixedResult.invocationId()); ExampleSupport.emit( @@ -41,7 +41,7 @@ public static void main(String[] args) { String maxCost = ExampleSupport.envDefault("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000"); SynapseClient.LlmInvokeOptions llmOptions = new SynapseClient.LlmInvokeOptions(); llmOptions.maxCostUsdc = maxCost; - llmOptions.idempotencyKey = "java-e2e-llm-" + System.currentTimeMillis(); + llmOptions.idempotencyKey = ExampleSupport.idempotencyKey("llm"); SynapseClient.InvocationResult llmResult = client.invokeLlm( llmServiceId, diff --git a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java index dc04f91..99cbf4c 100644 --- a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java +++ b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java @@ -148,6 +148,12 @@ static boolean envBool(String name) { || "y".equalsIgnoreCase(value.trim())); } + static String idempotencyKey(String scenario) { + String runId = System.getenv("E2E_RUN_ID"); + String prefix = runId == null || runId.isBlank() ? "java-e2e" : runId.trim() + "-java-e2e"; + return prefix + "-" + scenario + "-" + System.currentTimeMillis(); + } + static String firstNonBlank(String... values) { for (String value : values) { if (value != null && !value.isBlank()) { diff --git a/java/pom.xml b/java/pom.xml index f57d103..0f99373 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -14,6 +14,7 @@ UTF-8 5.11.4 2.18.3 + 4.12.3 @@ -22,6 +23,11 @@ jackson-databind ${jackson.version} + + org.web3j + crypto + ${web3j.version} + org.junit.jupiter junit-jupiter @@ -30,6 +36,14 @@ + + + github + GitHub Packages + https://maven.pkg.github.com/cliff-personal/Synapse-Network-Sdk + + + diff --git a/java/src/main/java/ai/synapsenetwork/sdk/SynapseAuth.java b/java/src/main/java/ai/synapsenetwork/sdk/SynapseAuth.java new file mode 100644 index 0000000..ee82213 --- /dev/null +++ b/java/src/main/java/ai/synapsenetwork/sdk/SynapseAuth.java @@ -0,0 +1,523 @@ +package ai.synapsenetwork.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.web3j.crypto.Credentials; +import org.web3j.crypto.Sign; +import org.web3j.utils.Numeric; + +public final class SynapseAuth { + private static final ObjectMapper MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private final String walletAddress; + private final String gatewayUrl; + private final Duration timeout; + private final HttpClient httpClient; + private final Signer signer; + private String token; + private Instant tokenExpiresAt = Instant.EPOCH; + + public SynapseAuth(Options options) { + this.walletAddress = requireNonBlank(options.walletAddress, "walletAddress").toLowerCase(); + this.gatewayUrl = SynapseClient.resolveGatewayUrl(options.environment, options.gatewayUrl); + this.timeout = options.timeout == null ? Duration.ofSeconds(30) : options.timeout; + this.httpClient = options.httpClient == null ? HttpClient.newHttpClient() : options.httpClient; + this.signer = Objects.requireNonNull(options.signer, "signer is required"); + } + + public static SynapseAuth fromPrivateKey(String privateKey, Options options) { + Credentials credentials = Credentials.create(privateKey); + Options resolved = options == null ? new Options() : options; + resolved.walletAddress = credentials.getAddress(); + resolved.signer = message -> { + Sign.SignatureData signature = Sign.signPrefixedMessage(message.getBytes(StandardCharsets.UTF_8), credentials.getEcKeyPair()); + return "0x" + Numeric.toHexStringNoPrefix(signature.getR()) + Numeric.toHexStringNoPrefix(signature.getS()) + Numeric.toHexStringNoPrefix(signature.getV()); + }; + return new SynapseAuth(resolved); + } + + public SynapseProvider provider() { + return new SynapseProvider(this); + } + + public String authenticate() { + if (token != null && Instant.now().isBefore(tokenExpiresAt.minusSeconds(30))) { + return token; + } + ChallengeResponse challenge = get("/api/v1/auth/challenge?address=" + encode(walletAddress), ChallengeResponse.class, null); + if (!challenge.success() || challenge.challenge() == null || challenge.challenge().isBlank()) { + throw new SynapseClient.AuthenticationException("AUTH_CHALLENGE_FAILED", "challenge request did not return a usable challenge"); + } + TokenResponse response = + request( + "POST", + "/api/v1/auth/verify", + Map.of( + "wallet_address", walletAddress, + "message", challenge.challenge(), + "signature", signer.sign(challenge.challenge())), + TokenResponse.class, + null, + null); + if (!response.success() || response.accessToken() == null || response.accessToken().isBlank()) { + throw new SynapseClient.AuthenticationException("AUTH_VERIFY_FAILED", "auth verify did not return an access token"); + } + token = response.accessToken(); + tokenExpiresAt = Instant.now().plusSeconds(Math.max(0, response.expiresIn())); + return token; + } + + public String getToken() { + return authenticate(); + } + + public AuthLogoutResult logout() { + AuthLogoutResult result = ownerRequest("POST", "/api/v1/auth/logout", null, AuthLogoutResult.class); + token = null; + tokenExpiresAt = Instant.EPOCH; + return result; + } + + public OwnerProfile getOwnerProfile() { + return ownerRequest("GET", "/api/v1/auth/me", null, OwnerProfile.class); + } + + public IssueCredentialResult issueCredential(CredentialOptions options) { + JsonNode raw = ownerRequest("POST", "/api/v1/credentials/agent/issue", credentialBody(options), JsonNode.class); + AgentCredential credential = MAPPER.convertValue(raw.path("credential"), AgentCredential.class); + String tokenValue = firstText(raw.path("token").asText(""), credential.token(), raw.path("credential_token").asText("")); + String id = firstText(raw.path("credential_id").asText(""), raw.path("id").asText(""), credential.id(), credential.credentialId()); + if (tokenValue.isBlank() || id.isBlank()) { + throw new SynapseClient.AuthenticationException("CREDENTIAL_PAYLOAD_MISSING", raw.toString()); + } + return new IssueCredentialResult(new AgentCredential(id, id, tokenValue, firstText(credential.name(), options == null ? "" : options.name), "active"), tokenValue); + } + + public List listCredentials() { + return credentialList("/api/v1/credentials/agent/list"); + } + + public List listActiveCredentials() { + return credentialList("/api/v1/credentials/agent/list?active_only=true"); + } + + public CredentialStatusResult getCredentialStatus(String credentialId) { + return ownerRequest("GET", "/api/v1/credentials/agent/" + encodeRequired(credentialId, "credentialId") + "/status", null, CredentialStatusResult.class); + } + + public CredentialStatusResult checkCredentialStatus(String credentialId) { + return getCredentialStatus(credentialId); + } + + public CredentialRevokeResult revokeCredential(String credentialId) { + return ownerRequest("POST", "/api/v1/credentials/agent/" + encodeRequired(credentialId, "credentialId") + "/revoke", null, CredentialRevokeResult.class); + } + + public CredentialRotateResult rotateCredential(String credentialId) { + return ownerRequest("POST", "/api/v1/credentials/agent/" + encodeRequired(credentialId, "credentialId") + "/rotate", null, CredentialRotateResult.class); + } + + public CredentialDeleteResult deleteCredential(String credentialId) { + return ownerRequest("DELETE", "/api/v1/credentials/agent/" + encodeRequired(credentialId, "credentialId"), null, CredentialDeleteResult.class); + } + + public CredentialQuotaUpdateResult updateCredentialQuota(String credentialId, CredentialQuotaOptions options) { + return ownerRequest("PATCH", "/api/v1/credentials/agent/" + encodeRequired(credentialId, "credentialId") + "/quota", quotaBody(options), CredentialQuotaUpdateResult.class); + } + + public CredentialAuditLogList getCredentialAuditLogs(int limit) { + return ownerRequest("GET", withQuery("/api/v1/credentials/agent/audit-logs", Map.of("limit", limitOrDefault(limit))), null, CredentialAuditLogList.class); + } + + public String ensureCredential(String name, CredentialOptions options) { + for (AgentCredential credential : listActiveCredentials()) { + if (Objects.equals(credential.name(), name)) { + if (credential.token() != null && !credential.token().isBlank()) return credential.token(); + return rotateCredential(firstText(credential.credentialId(), credential.id())).token(); + } + } + CredentialOptions resolved = options == null ? new CredentialOptions() : options; + resolved.name = name; + return issueCredential(resolved).token(); + } + + public BalanceSummary getBalance() { + JsonNode raw = ownerRequest("GET", "/api/v1/balance", null, JsonNode.class); + JsonNode balance = raw.has("balance") ? raw.path("balance") : raw; + return MAPPER.convertValue(balance, BalanceSummary.class); + } + + public DepositIntentResult registerDepositIntent(String txHash, String amountUsdc, String idempotencyKey) { + return ownerRequestWithHeaders("POST", "/api/v1/balance/deposit/intent", Map.of("txHash", txHash, "amountUsdc", amountUsdc), DepositIntentResult.class, idempotency(idempotencyKey)); + } + + public DepositConfirmResult confirmDeposit(String intentId, String eventKey, int confirmations) { + return ownerRequest("POST", "/api/v1/balance/deposit/intents/" + encodeRequired(intentId, "intentId") + "/confirm", Map.of("eventKey", eventKey, "confirmations", confirmations == 0 ? 1 : confirmations), DepositConfirmResult.class); + } + + public void setSpendingLimit(String spendingLimitUsdc) { + Map body = spendingLimitUsdc == null ? Map.of("allowUnlimited", true) : Map.of("spendingLimitUsdc", spendingLimitUsdc, "allowUnlimited", false); + ownerRequest("PUT", "/api/v1/balance/spending-limit", body, JsonNode.class); + } + + public VoucherRedeemResult redeemVoucher(String voucherCode, String idempotencyKey) { + return ownerRequestWithHeaders("POST", "/api/v1/balance/vouchers/redeem", Map.of("voucherCode", requireNonBlank(voucherCode, "voucherCode")), VoucherRedeemResult.class, idempotency(idempotencyKey)); + } + + public UsageLogList getUsageLogs(int limit) { + return ownerRequest("GET", withQuery("/api/v1/usage/logs", Map.of("limit", limitOrDefault(limit))), null, UsageLogList.class); + } + + public FinanceAuditLogList getFinanceAuditLogs(int limit) { + return ownerRequest("GET", withQuery("/api/v1/finance/audit-logs", Map.of("limit", limitOrDefault(limit))), null, FinanceAuditLogList.class); + } + + public RiskOverview getRiskOverview() { + return ownerRequest("GET", "/api/v1/finance/risk-overview", null, RiskOverview.class); + } + + public IssueProviderSecretResult issueProviderSecret(CredentialOptions options) { + return ownerRequest("POST", "/api/v1/secrets/provider/issue", credentialBody(options), IssueProviderSecretResult.class); + } + + public List listProviderSecrets() { + JsonNode raw = ownerRequest("GET", "/api/v1/secrets/provider/list", null, JsonNode.class); + return listOf(raw.path("secrets"), ProviderSecret.class); + } + + public ProviderSecretDeleteResult deleteProviderSecret(String secretId) { + return ownerRequest("DELETE", "/api/v1/secrets/provider/" + encodeRequired(secretId, "secretId"), null, ProviderSecretDeleteResult.class); + } + + public RegisterProviderServiceResult registerProviderService(RegisterProviderServiceOptions options) { + return ownerRequest("POST", "/api/v1/services", providerServiceBody(options), RegisterProviderServiceResult.class); + } + + public List listProviderServices() { + JsonNode raw = ownerRequest("GET", "/api/v1/services", null, JsonNode.class); + return listOf(raw.path("services"), ProviderServiceRecord.class); + } + + public ProviderRegistrationGuide getRegistrationGuide() { + return ownerRequest("GET", "/api/v1/services/registration-guide", null, ProviderRegistrationGuide.class); + } + + public ServiceManifestDraft parseCurlToServiceManifest(String curlCommand) { + return ownerRequest("POST", "/api/v1/services/parse-curl", Map.of("curlCommand", requireNonBlank(curlCommand, "curlCommand")), ServiceManifestDraft.class); + } + + public ProviderServiceUpdateResult updateProviderService(String serviceRecordId, Map patch) { + return ownerRequest("PUT", "/api/v1/services/" + encodeRequired(serviceRecordId, "serviceRecordId"), patch == null ? Map.of() : patch, ProviderServiceUpdateResult.class); + } + + public ProviderServiceDeleteResult deleteProviderService(String serviceRecordId) { + return ownerRequest("DELETE", "/api/v1/services/" + encodeRequired(serviceRecordId, "serviceRecordId"), null, ProviderServiceDeleteResult.class); + } + + public ProviderServicePingResult pingProviderService(String serviceRecordId) { + return ownerRequest("POST", "/api/v1/services/" + encodeRequired(serviceRecordId, "serviceRecordId") + "/ping", null, ProviderServicePingResult.class); + } + + public ProviderServiceHealthHistory getProviderServiceHealthHistory(String serviceRecordId, int limit) { + return ownerRequest("GET", withQuery("/api/v1/services/" + encodeRequired(serviceRecordId, "serviceRecordId") + "/health/history", Map.of("limitPerTarget", limitOrDefault(limit))), null, ProviderServiceHealthHistory.class); + } + + public ProviderEarningsSummary getProviderEarningsSummary() { + return ownerRequest("GET", "/api/v1/providers/earnings/summary", null, ProviderEarningsSummary.class); + } + + public ProviderWithdrawalCapability getProviderWithdrawalCapability() { + return ownerRequest("GET", "/api/v1/providers/withdrawals/capability", null, ProviderWithdrawalCapability.class); + } + + public ProviderWithdrawalIntentResult createProviderWithdrawalIntent(String amountUsdc, String idempotencyKey, String destinationAddress) { + Map body = new LinkedHashMap<>(); + body.put("amountUsdc", requireNonBlank(amountUsdc, "amountUsdc")); + if (destinationAddress != null && !destinationAddress.isBlank()) body.put("destinationAddress", destinationAddress); + return ownerRequestWithHeaders("POST", "/api/v1/providers/withdrawals/intent", body, ProviderWithdrawalIntentResult.class, idempotency(idempotencyKey)); + } + + public ProviderWithdrawalList listProviderWithdrawals(int limit) { + return ownerRequest("GET", withQuery("/api/v1/providers/withdrawals", Map.of("limit", limitOrDefault(limit))), null, ProviderWithdrawalList.class); + } + + public ProviderServiceRecord getProviderService(String serviceId) { + return listProviderServices().stream() + .filter(service -> Objects.equals(service.serviceId(), serviceId)) + .findFirst() + .orElseThrow(() -> new SynapseClient.AuthenticationException("SERVICE_NOT_FOUND", "provider service not found: " + serviceId)); + } + + public ProviderServiceStatus getProviderServiceStatus(String serviceId) { + ProviderServiceRecord service = getProviderService(serviceId); + return new ProviderServiceStatus(service.serviceId(), service.status() == null ? "unknown" : service.status(), service.runtimeAvailable(), service.health()); + } + + private T ownerRequest(String method, String path, Object body, Class responseType) { + return ownerRequestWithHeaders(method, path, body, responseType, Map.of()); + } + + private T ownerRequestWithHeaders(String method, String path, Object body, Class responseType, Map extraHeaders) { + Map headers = new LinkedHashMap<>(extraHeaders); + headers.put("Authorization", "Bearer " + getToken()); + return request(method, path, body, responseType, headers, null); + } + + private T get(String path, Class responseType, Map headers) { + return request("GET", path, null, responseType, headers == null ? Map.of() : headers, null); + } + + private T request(String method, String path, Object body, Class responseType, Map headers, String requestId) { + try { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(gatewayUrl + path)).timeout(timeout).header("Content-Type", "application/json"); + if (headers == null) headers = Map.of(); + headers.forEach(builder::header); + if (requestId != null && !requestId.isBlank()) builder.header("X-Request-Id", requestId); + if ("GET".equals(method)) builder.GET(); + else builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body))); + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) throw mapError(response.statusCode(), response.body()); + return MAPPER.readValue(response.body().isBlank() ? "{}" : response.body(), responseType); + } catch (IOException ex) { + throw new SynapseClient.SynapseException("HTTP_PARSE_FAILED", ex.getMessage(), ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new SynapseClient.SynapseException("HTTP_INTERRUPTED", ex.getMessage(), ex); + } + } + + private RuntimeException mapError(int status, String body) { + try { + JsonNode detail = MAPPER.readTree(body).path("detail"); + String code = detail.path("code").asText(""); + String message = detail.path("message").asText(body); + if (status == 401) return new SynapseClient.AuthenticationException(code, message); + if (status == 402) return new SynapseClient.BudgetException(code, message); + return new SynapseClient.InvokeException(code, message); + } catch (IOException ex) { + return new SynapseClient.InvokeException("HTTP_ERROR", body); + } + } + + private List credentialList(String path) { + JsonNode raw = ownerRequest("GET", path, null, JsonNode.class); + return listOf(raw.path("credentials"), AgentCredential.class); + } + + private static Map credentialBody(CredentialOptions options) { + Map body = new LinkedHashMap<>(); + if (options == null) return body; + put(body, "name", options.name); + put(body, "maxCalls", options.maxCalls); + put(body, "creditLimit", options.creditLimit); + put(body, "resetInterval", options.resetInterval); + put(body, "rpm", options.rpm); + put(body, "expiresInSec", options.expiresInSec); + return body; + } + + private static Map quotaBody(CredentialQuotaOptions options) { + Map body = new LinkedHashMap<>(); + if (options == null) return body; + put(body, "maxCalls", options.maxCalls); + put(body, "rpm", options.rpm); + put(body, "creditLimit", options.creditLimit); + put(body, "resetInterval", options.resetInterval); + put(body, "expiresAt", options.expiresAt); + return body; + } + + private Map providerServiceBody(RegisterProviderServiceOptions options) { + String name = requireNonBlank(options.serviceName, "serviceName"); + String endpoint = requireNonBlank(options.endpointUrl, "endpointUrl"); + String serviceKind = firstText(options.serviceKind, "api"); + String priceModel = firstText(options.priceModel, "llm".equals(serviceKind) ? "token_metered" : "fixed"); + Map body = new LinkedHashMap<>(); + String serviceId = firstText(options.serviceId, defaultServiceId(name)); + body.put("serviceId", serviceId); + body.put("agentToolName", serviceId); + body.put("serviceName", name); + body.put("serviceKind", serviceKind); + body.put("priceModel", priceModel); + body.put("role", "Provider"); + body.put("status", firstText(options.status, "active")); + body.put("isActive", options.isActive == null || options.isActive); + body.put("pricing", providerPricing(options, priceModel)); + body.put("summary", requireNonBlank(options.descriptionForModel, "descriptionForModel")); + body.put("tags", options.tags == null ? List.of() : options.tags); + body.put("auth", Map.of("type", "gateway_signed")); + body.put("invoke", Map.of("method", firstText(options.endpointMethod, "POST"), "targets", List.of(Map.of("url", endpoint)), "timeoutMs", options.requestTimeoutMs == 0 ? 15000 : options.requestTimeoutMs)); + body.put("healthCheck", Map.of("path", firstText(options.healthPath, "/health"), "method", firstText(options.healthMethod, "GET"), "timeoutMs", options.healthTimeoutMs == 0 ? 3000 : options.healthTimeoutMs, "successCodes", List.of(200))); + body.put("providerProfile", Map.of("displayName", firstText(options.providerDisplayName, name))); + body.put("payoutAccount", Map.of("payoutAddress", firstText(options.payoutAddress, walletAddress), "chainId", options.chainId == null || options.chainId == 0 ? 31337 : options.chainId, "settlementCurrency", firstText(options.settlementCurrency, "USDC"))); + body.put("governance", Map.of("termsAccepted", true, "riskAcknowledged", true)); + return body; + } + + private static Map providerPricing(RegisterProviderServiceOptions options, String priceModel) { + if ("token_metered".equals(priceModel)) { + return Map.of("priceModel", "token_metered", "inputPricePer1MTokensUsdc", requireNonBlank(options.inputPricePer1MTokensUsdc, "inputPricePer1MTokensUsdc"), "outputPricePer1MTokensUsdc", requireNonBlank(options.outputPricePer1MTokensUsdc, "outputPricePer1MTokensUsdc"), "currency", "USDC"); + } + return Map.of("amount", requireNonBlank(options.basePriceUsdc, "basePriceUsdc"), "currency", "USDC"); + } + + private static List listOf(JsonNode node, Class type) { + List items = new ArrayList<>(); + if (node != null && node.isArray()) node.forEach(item -> items.add(MAPPER.convertValue(item, type))); + return items; + } + + private static Map idempotency(String value) { + return Map.of("X-Idempotency-Key", value == null || value.isBlank() ? "java-" + System.currentTimeMillis() : value); + } + + private static String withQuery(String path, Map params) { + StringBuilder query = new StringBuilder(); + params.forEach((key, value) -> { + if (query.length() > 0) query.append('&'); + query.append(encode(key)).append('=').append(encode(String.valueOf(value))); + }); + return query.isEmpty() ? path : path + "?" + query; + } + + private static String encodeRequired(String value, String name) { + return encode(requireNonBlank(value, name)); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static int limitOrDefault(int limit) { + return limit <= 0 ? 100 : limit; + } + + private static void put(Map body, String key, Object value) { + if (value != null && !String.valueOf(value).isBlank() && !"0".equals(String.valueOf(value))) body.put(key, value); + } + + private static String requireNonBlank(String value, String name) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required"); + return value.trim(); + } + + private static String firstText(String... values) { + for (String value : values) if (value != null && !value.isBlank()) return value.trim(); + return ""; + } + + private static String defaultServiceId(String name) { + String value = name.trim().toLowerCase().replaceAll("[^a-z0-9_-]+", "_").replaceAll("_+", "_").replaceAll("^_+|_+$", ""); + return value.isBlank() ? "service_" + System.currentTimeMillis() : value; + } + + @FunctionalInterface + public interface Signer { + String sign(String message); + } + + public static final class Options { + public String walletAddress; + public Signer signer; + public String environment; + public String gatewayUrl; + public Duration timeout; + public HttpClient httpClient; + } + + public static final class CredentialOptions { + public String name; + public Integer maxCalls; + public String creditLimit; + public String resetInterval; + public Integer rpm; + public Integer expiresInSec; + } + + public static final class CredentialQuotaOptions { + public Integer maxCalls; + public Integer rpm; + public String creditLimit; + public String resetInterval; + public String expiresAt; + } + + public static final class RegisterProviderServiceOptions { + public String serviceName; + public String endpointUrl; + public String basePriceUsdc; + public String descriptionForModel; + public String serviceKind; + public String priceModel; + public String inputPricePer1MTokensUsdc; + public String outputPricePer1MTokensUsdc; + public String serviceId; + public String providerDisplayName; + public String payoutAddress; + public Integer chainId; + public String settlementCurrency; + public List tags; + public String status; + public Boolean isActive; + public String endpointMethod; + public String healthPath; + public String healthMethod; + public int healthTimeoutMs; + public int requestTimeoutMs; + } + + public record ChallengeResponse(boolean success, String challenge, String domain) {} + public record TokenResponse(boolean success, @JsonProperty("access_token") String accessToken, @JsonProperty("expires_in") long expiresIn) {} + public record AuthLogoutResult(String status, Boolean success) {} + public record OwnerProfile(String ownerAddress, String walletAddress, JsonNode profile) {} + public record AgentCredential(String id, @JsonProperty("credential_id") String credentialId, String token, String name, String status) {} + public record IssueCredentialResult(AgentCredential credential, String token) {} + public record CredentialStatusResult(String status, String credentialId, Boolean valid, String credentialStatus) {} + public record CredentialRevokeResult(String status, String credentialId, AgentCredential credential) {} + public record CredentialRotateResult(String status, String credentialId, String token, AgentCredential credential) {} + public record CredentialDeleteResult(String status, String credentialId) {} + public record CredentialQuotaUpdateResult(String status, String credentialId, AgentCredential credential) {} + public record CredentialAuditLogList(List logs) {} + public record BalanceSummary(JsonNode ownerBalance, JsonNode consumerAvailableBalance, JsonNode providerReceivable, JsonNode platformFeeAccrued) {} + public record DepositIntentResult(String status, @JsonProperty("tx_hash") String txHash, JsonNode intent) {} + public record DepositConfirmResult(String status, JsonNode intent) {} + public record VoucherRedeemResult(String status, String voucherCode) {} + public record UsageLogList(List logs) {} + public record FinanceAuditLogList(List logs) {} + public record RiskOverview(JsonNode risk) {} + public record ProviderSecret(String id, String name, String ownerAddress, String secretKey, String maskedKey, String status) {} + public record IssueProviderSecretResult(String status, ProviderSecret secret) {} + public record ProviderSecretDeleteResult(String status, String secretId) {} + @JsonIgnoreProperties(ignoreUnknown = true) + public record ProviderServiceRecord(String serviceId, String id, String serviceName, String status, Boolean runtimeAvailable, JsonNode health) {} + public record RegisterProviderServiceResult(String status, String serviceId, ProviderServiceRecord service) {} + public record ProviderServiceStatus(String serviceId, String lifecycleStatus, Boolean runtimeAvailable, JsonNode health) {} + public record ProviderRegistrationGuide(List steps, JsonNode requirements) {} + public record ServiceManifestDraft(JsonNode data, JsonNode manifest) {} + public record ProviderServiceUpdateResult(String status, ProviderServiceRecord service) {} + public record ProviderServiceDeleteResult(String status, String serviceId) {} + public record ProviderServicePingResult(String status, JsonNode health) {} + public record ProviderServiceHealthHistory(List history) {} + public record ProviderEarningsSummary(JsonNode total) {} + public record ProviderWithdrawalCapability(Boolean available) {} + public record ProviderWithdrawalIntentResult(String status, String intentId, JsonNode amountUsdc, JsonNode intent) {} + public record ProviderWithdrawalList(List withdrawals) {} +} diff --git a/java/src/main/java/ai/synapsenetwork/sdk/SynapseProvider.java b/java/src/main/java/ai/synapsenetwork/sdk/SynapseProvider.java new file mode 100644 index 0000000..3facc01 --- /dev/null +++ b/java/src/main/java/ai/synapsenetwork/sdk/SynapseProvider.java @@ -0,0 +1,87 @@ +package ai.synapsenetwork.sdk; + +import java.util.List; +import java.util.Map; + +public final class SynapseProvider { + private final SynapseAuth auth; + + SynapseProvider(SynapseAuth auth) { + this.auth = auth; + } + + public SynapseAuth.IssueProviderSecretResult issueSecret(SynapseAuth.CredentialOptions options) { + return auth.issueProviderSecret(options); + } + + public List listSecrets() { + return auth.listProviderSecrets(); + } + + public SynapseAuth.ProviderSecretDeleteResult deleteSecret(String secretId) { + return auth.deleteProviderSecret(secretId); + } + + public SynapseAuth.ProviderRegistrationGuide getRegistrationGuide() { + return auth.getRegistrationGuide(); + } + + public SynapseAuth.ServiceManifestDraft parseCurlToServiceManifest(String curlCommand) { + return auth.parseCurlToServiceManifest(curlCommand); + } + + public SynapseAuth.RegisterProviderServiceResult registerService(SynapseAuth.RegisterProviderServiceOptions options) { + return auth.registerProviderService(options); + } + + public SynapseAuth.RegisterProviderServiceResult registerLlmService(SynapseAuth.RegisterProviderServiceOptions options) { + options.serviceKind = "llm"; + options.priceModel = "token_metered"; + return auth.registerProviderService(options); + } + + public List listServices() { + return auth.listProviderServices(); + } + + public SynapseAuth.ProviderServiceRecord getService(String serviceId) { + return auth.getProviderService(serviceId); + } + + public SynapseAuth.ProviderServiceStatus getServiceStatus(String serviceId) { + return auth.getProviderServiceStatus(serviceId); + } + + public SynapseAuth.ProviderServiceUpdateResult updateService(String serviceRecordId, Map patch) { + return auth.updateProviderService(serviceRecordId, patch); + } + + public SynapseAuth.ProviderServiceDeleteResult deleteService(String serviceRecordId) { + return auth.deleteProviderService(serviceRecordId); + } + + public SynapseAuth.ProviderServicePingResult pingService(String serviceRecordId) { + return auth.pingProviderService(serviceRecordId); + } + + public SynapseAuth.ProviderServiceHealthHistory getServiceHealthHistory(String serviceRecordId, int limit) { + return auth.getProviderServiceHealthHistory(serviceRecordId, limit); + } + + public SynapseAuth.ProviderEarningsSummary getEarningsSummary() { + return auth.getProviderEarningsSummary(); + } + + public SynapseAuth.ProviderWithdrawalCapability getWithdrawalCapability() { + return auth.getProviderWithdrawalCapability(); + } + + public SynapseAuth.ProviderWithdrawalIntentResult createWithdrawalIntent( + String amountUsdc, String idempotencyKey, String destinationAddress) { + return auth.createProviderWithdrawalIntent(amountUsdc, idempotencyKey, destinationAddress); + } + + public SynapseAuth.ProviderWithdrawalList listWithdrawals(int limit) { + return auth.listProviderWithdrawals(limit); + } +} diff --git a/java/src/test/java/ai/synapsenetwork/sdk/SynapseAuthTest.java b/java/src/test/java/ai/synapsenetwork/sdk/SynapseAuthTest.java new file mode 100644 index 0000000..7608f2a --- /dev/null +++ b/java/src/test/java/ai/synapsenetwork/sdk/SynapseAuthTest.java @@ -0,0 +1,158 @@ +package ai.synapsenetwork.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.math.BigInteger; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.web3j.crypto.Keys; +import org.web3j.crypto.Sign; +import org.web3j.utils.Numeric; + +final class SynapseAuthTest { + private static final String PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + @Test + void fromPrivateKeySignsChallengeAndCachesToken() throws Exception { + try (FixtureServer server = new FixtureServer()) { + SynapseAuth auth = SynapseAuth.fromPrivateKey(PRIVATE_KEY, options(server.url())); + + assertEquals("jwt_owner", auth.getToken()); + assertEquals("jwt_owner", auth.getToken()); + assertEquals(1, server.challengeCalls); + assertEquals(1, server.verifyCalls); + assertEquals("0xowner", auth.getOwnerProfile().ownerAddress()); + } + } + + @Test + void credentialFinanceAndProviderRoutesReturnNamedObjects() throws Exception { + try (FixtureServer server = new FixtureServer()) { + SynapseAuth auth = SynapseAuth.fromPrivateKey(PRIVATE_KEY, options(server.url())); + SynapseAuth.IssueCredentialResult issued = auth.issueCredential(new SynapseAuth.CredentialOptions()); + assertEquals("agt_1", issued.token()); + assertEquals("cred_1", issued.credential().id()); + + assertEquals(1, auth.listCredentials().size()); + assertEquals("success", auth.updateCredentialQuota("cred_1", new SynapseAuth.CredentialQuotaOptions()).status()); + assertEquals("1.00", auth.getBalance().ownerBalance().asText()); + assertEquals(1, auth.getUsageLogs(5).logs().size()); + + SynapseProvider provider = auth.provider(); + assertEquals(1, provider.getRegistrationGuide().steps().size()); + SynapseAuth.RegisterProviderServiceOptions serviceOptions = new SynapseAuth.RegisterProviderServiceOptions(); + serviceOptions.serviceName = "Weather"; + serviceOptions.endpointUrl = "https://provider.example.com/invoke"; + serviceOptions.basePriceUsdc = "0.01"; + serviceOptions.descriptionForModel = "Weather"; + assertEquals("svc_weather", provider.registerService(serviceOptions).serviceId()); + assertTrue(provider.getServiceStatus("svc_weather").runtimeAvailable()); + assertEquals("success", provider.updateService("rec_1", Map.of("status", "active")).status()); + assertEquals("wd_1", provider.createWithdrawalIntent("0.10", "idem", "0xabc").intentId()); + + assertTrue(server.seen.contains("POST /api/v1/credentials/agent/issue")); + assertTrue(server.seen.contains("GET /api/v1/balance")); + assertTrue(server.seen.contains("POST /api/v1/services")); + assertTrue(server.seen.contains("POST /api/v1/providers/withdrawals/intent")); + } + } + + private static SynapseAuth.Options options(String gatewayUrl) { + SynapseAuth.Options options = new SynapseAuth.Options(); + options.gatewayUrl = gatewayUrl; + return options; + } + + private static final class FixtureServer implements AutoCloseable { + private final HttpServer server; + final List seen = new ArrayList<>(); + int challengeCalls; + int verifyCalls; + + FixtureServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + String url() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void handle(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + seen.add(exchange.getRequestMethod() + " " + path); + if (path.startsWith("/api/v1/auth/")) { + auth(exchange, path); + return; + } + assertEquals("Bearer jwt_owner", exchange.getRequestHeaders().getFirst("Authorization")); + switch (path) { + case "/api/v1/credentials/agent/issue" -> write(exchange, "{\"credential\":{\"id\":\"cred_1\",\"token\":\"agt_1\",\"status\":\"active\"},\"token\":\"agt_1\"}"); + case "/api/v1/credentials/agent/list" -> write(exchange, "{\"credentials\":[{\"id\":\"cred_1\",\"name\":\"agent\",\"status\":\"active\"}]}"); + case "/api/v1/credentials/agent/cred_1/quota" -> write(exchange, "{\"status\":\"success\",\"credentialId\":\"cred_1\"}"); + case "/api/v1/balance" -> write(exchange, "{\"balance\":{\"ownerBalance\":\"1.00\"}}"); + case "/api/v1/usage/logs" -> write(exchange, "{\"logs\":[{\"id\":\"usage_1\"}]}"); + case "/api/v1/services/registration-guide" -> write(exchange, "{\"steps\":[\"register\"]}"); + case "/api/v1/services" -> { + if ("POST".equals(exchange.getRequestMethod())) write(exchange, "{\"status\":\"success\",\"serviceId\":\"svc_weather\",\"service\":{\"serviceId\":\"svc_weather\"}}"); + else write(exchange, "{\"services\":[{\"serviceId\":\"svc_weather\",\"status\":\"active\",\"runtimeAvailable\":true}]}"); + } + case "/api/v1/services/rec_1" -> write(exchange, "{\"status\":\"success\"}"); + case "/api/v1/providers/withdrawals/intent" -> write(exchange, "{\"status\":\"success\",\"intentId\":\"wd_1\"}"); + default -> write(exchange, "{\"status\":\"success\"}"); + } + } + + private void auth(HttpExchange exchange, String path) throws IOException { + if (path.equals("/api/v1/auth/challenge")) { + challengeCalls++; + assertEquals("0xfcad0b19bb29d4674531d6f115237e16afce377c", exchange.getRequestURI().getQuery().split("=")[1]); + write(exchange, "{\"success\":true,\"challenge\":\"sign me\"}"); + return; + } + if (path.equals("/api/v1/auth/me")) { + assertEquals("Bearer jwt_owner", exchange.getRequestHeaders().getFirst("Authorization")); + write(exchange, "{\"ownerAddress\":\"0xowner\"}"); + return; + } + verifyCalls++; + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(validSignature(body)); + write(exchange, "{\"success\":true,\"access_token\":\"jwt_owner\",\"expires_in\":3600}"); + } + + private static boolean validSignature(String body) { + String signature = body.replaceAll(".*\\\"signature\\\":\\\"([^\\\"]+)\\\".*", "$1"); + byte[] raw = Numeric.hexStringToByteArray(signature); + Sign.SignatureData data = new Sign.SignatureData(raw[64], Arrays.copyOfRange(raw, 0, 32), Arrays.copyOfRange(raw, 32, 64)); + try { + BigInteger key = Sign.signedPrefixedMessageToKey("sign me".getBytes(StandardCharsets.UTF_8), data); + return ("0x" + Keys.getAddress(key)).equals("0xfcad0b19bb29d4674531d6f115237e16afce377c"); + } catch (Exception ex) { + return false; + } + } + + private static void write(HttpExchange exchange, String body) throws IOException { + byte[] raw = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, raw.length); + exchange.getResponseBody().write(raw); + exchange.close(); + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/llms.txt b/llms.txt index 46d1cc0..c88d5c9 100644 --- a/llms.txt +++ b/llms.txt @@ -2,7 +2,7 @@ SynapseNetwork is the settlement layer for AI agents. It allows AI agents to discover APIs, invoke them, and pay with zero-gas crypto micropayments (USDC) without human-in-the-loop. -This repository contains the official SynapseNetwork SDKs for TypeScript and Python. +This repository contains the official SynapseNetwork SDKs for TypeScript, Python, Go, Java, and .NET. ## Core Concepts @@ -121,10 +121,11 @@ print(llm_response.usage, llm_response.synapse) ## Owner and Provider Workflows -- Use `SynapseAuth` only for owner/backend tasks such as wallet auth, issuing agent credentials, balance/deposit helpers, and provider registration. +- All five SDKs expose the same public capability families: `SynapseClient` agent runtime, `SynapseAuth`/`Auth` owner wallet auth, credential management, balance/deposit helpers, usage/finance reads, and `SynapseProvider`/`Provider` publishing and withdrawal helpers. +- Use `SynapseAuth` / `Auth` only for owner/backend tasks such as wallet auth, issuing agent credentials, balance/deposit helpers, and provider registration. - Agent runtime code should use `SynapseClient` with an existing `agt_xxx` key. - Provider publishing code should use `auth.provider()` / `SynapseProvider`. -- Public `SynapseAuth` and `SynapseProvider` methods return named SDK objects/interfaces, not raw `dict` / `Record`. +- Public owner/provider methods return named SDK objects/interfaces/structs/records, not raw `dict`, `Record`, `map[string]any`, `JsonNode`, `JsonElement`, or `Dictionary` as the top-level result. ## Repository Map for Contributors @@ -152,6 +153,16 @@ bash scripts/ci/repo_hygiene_checks.sh bash scripts/ci/security_checks.sh ``` +Live parity E2E is gated and targets staging by default: + +```bash +export SYNAPSE_OWNER_PRIVATE_KEY=0x... +export SYNAPSE_AGENT_KEY=agt_xxx +bash scripts/e2e/sdk_parity_e2e.sh --env staging --skip-install +``` + +Private gateway tests may use `--env local` only with explicit `SYNAPSE_GATEWAY_URL`; do not generate public examples that select a local environment preset. + ## Safety Rules - Do not commit real credentials, provider secrets, private keys, wallet seed phrases, or production tokens. diff --git a/python/examples/e2e.py b/python/examples/e2e.py index 3155fd5..5a4f07d 100644 --- a/python/examples/e2e.py +++ b/python/examples/e2e.py @@ -28,7 +28,7 @@ def main() -> None: service_id, fixed_payload, cost_usdc=cost_usdc, - idempotency_key=f"python-e2e-fixed-{uuid4().hex}", + idempotency_key=idempotency_key("python", "fixed"), ) fixed_receipt = await_receipt(client, fixed_result.invocation_id) emit( @@ -49,7 +49,7 @@ def main() -> None: llm_service_id, json_payload("SYNAPSE_E2E_LLM_PAYLOAD_JSON", DEFAULT_LLM_PAYLOAD), max_cost_usdc=max_cost_usdc, - idempotency_key=f"python-e2e-llm-{uuid4().hex}", + idempotency_key=idempotency_key("python", "llm"), ) llm_receipt = await_receipt(client, llm_result.invocation_id) charged_usdc = str(llm_receipt.charged_usdc or llm_result.charged_usdc) @@ -205,6 +205,12 @@ def env_bool(name: str) -> bool: return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "y"} +def idempotency_key(language: str, scenario: str) -> str: + run_id = os.getenv("E2E_RUN_ID", "").strip() + prefix = f"{run_id}-{language}-e2e" if run_id else f"{language}-e2e" + return f"{prefix}-{scenario}-{uuid4().hex}" + + def fail(message: str) -> None: print(f"python e2e failed: {message}", file=sys.stderr) raise SystemExit(1) diff --git a/scripts/ci/source_quality_checks.py b/scripts/ci/source_quality_checks.py index 0dd3b80..80df3d4 100644 --- a/scripts/ci/source_quality_checks.py +++ b/scripts/ci/source_quality_checks.py @@ -8,6 +8,9 @@ ROOT = Path(__file__).resolve().parents[2] PYTHON_SOURCE = ROOT / "python" / "synapse_client" TYPESCRIPT_SOURCE = ROOT / "typescript" / "src" +GO_SOURCE = ROOT / "go" / "synapse" +JAVA_SOURCE = ROOT / "java" / "src" / "main" / "java" / "ai" / "synapsenetwork" / "sdk" +DOTNET_SOURCE = ROOT / "dotnet" / "src" / "SynapseNetwork.Sdk" MAX_SOURCE_LINES = 500 MAX_TEST_LINES = 700 @@ -33,6 +36,18 @@ TYPESCRIPT_SOURCE / "auth_provider_control.ts", TYPESCRIPT_SOURCE / "provider.ts", } +GO_TYPED_RETURN_FILES = { + GO_SOURCE / "auth.go", + GO_SOURCE / "provider_control.go", +} +JAVA_TYPED_RETURN_FILES = { + JAVA_SOURCE / "SynapseAuth.java", + JAVA_SOURCE / "SynapseProvider.java", +} +DOTNET_TYPED_RETURN_FILES = { + DOTNET_SOURCE / "SynapseAuth.cs", + DOTNET_SOURCE / "SynapseProvider.cs", +} def iter_files(root: Path, suffix: str) -> list[Path]: @@ -93,6 +108,9 @@ def check_python_function_lengths() -> list[str]: def check_public_sdk_return_models() -> list[str]: failures = check_python_public_return_models() failures.extend(check_typescript_public_return_models()) + failures.extend(check_go_public_return_models()) + failures.extend(check_java_public_return_models()) + failures.extend(check_dotnet_public_return_models()) return failures @@ -141,6 +159,70 @@ def check_typescript_public_return_models() -> list[str]: return sorted(set(failures)) +def check_go_public_return_models() -> list[str]: + failures: list[str] = [] + forbidden = ( + "map[string]any", + "map[string]interface{}", + "[]map[string]any", + "[]map[string]interface{}", + ) + pattern = re.compile(r"^func\s+\([^)]+\)\s+([A-Z]\w*)\s*\([^)]*\)\s*([^{\n]+)", re.MULTILINE) + for path in sorted(GO_TYPED_RETURN_FILES): + text = path.read_text(encoding="utf-8") + for match in pattern.finditer(text): + returns = match.group(2) + if any(item in returns for item in forbidden): + lineno = text.count("\n", 0, match.start()) + 1 + failures.append( + f"{relative(path)}:{lineno} {match.group(1)} returns raw map; use a named SDK result struct" + ) + return failures + + +def check_java_public_return_models() -> list[str]: + failures: list[str] = [] + pattern = re.compile( + r"^\s*public\s+(?!static\s+final\s+class\b)(?!record\b)(?:static\s+)?" + r"(JsonNode|Map\s*<[^>]+>|List\s*<\s*Map\s*<[^>]+>\s*>)\s+([A-Za-z]\w*)\s*\(", + re.MULTILINE, + ) + for path in sorted(JAVA_TYPED_RETURN_FILES): + text = path.read_text(encoding="utf-8") + for match in pattern.finditer(text): + lineno = text.count("\n", 0, match.start()) + 1 + failures.append( + f"{relative(path)}:{lineno} {match.group(2)} returns raw {match.group(1)}; use a named SDK result class" + ) + return failures + + +def check_dotnet_public_return_models() -> list[str]: + failures: list[str] = [] + forbidden_return = ( + r"JsonElement", + r"Dictionary\s*<[^>]+>", + r"IReadOnlyDictionary\s*<[^>]+>", + r"Task\s*<\s*JsonElement\s*>", + r"Task\s*<\s*Dictionary\s*<[^>]+>\s*>", + r"Task\s*<\s*IReadOnlyDictionary\s*<[^>]+>\s*>", + ) + pattern = re.compile( + r"^\s*public\s+(?!sealed\s+record\b)(?:async\s+)?(" + + "|".join(forbidden_return) + + r")\s+([A-Z]\w*)\s*\(", + re.MULTILINE, + ) + for path in sorted(DOTNET_TYPED_RETURN_FILES): + text = path.read_text(encoding="utf-8") + for match in pattern.finditer(text): + lineno = text.count("\n", 0, match.start()) + 1 + failures.append( + f"{relative(path)}:{lineno} {match.group(2)} returns raw {match.group(1)}; use a named SDK result record" + ) + return failures + + def effective_lines_for_node(path: Path, node: ast.AST) -> int: source = path.read_text(encoding="utf-8").splitlines() body = getattr(node, "body", []) diff --git a/scripts/e2e/sdk_local_evidence.sh b/scripts/e2e/sdk_local_evidence.sh new file mode 100755 index 0000000..61a91c7 --- /dev/null +++ b/scripts/e2e/sdk_local_evidence.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PRODUCT_ROOT="${PRODUCT_ROOT:-/Users/cliff/workspace/agent/Synapse-Network}" +E2E_RUN_ID="${E2E_RUN_ID:-sdk-local-$(date -u +%Y%m%dT%H%M%SZ)}" +OUT_DIR="${OUT_DIR:-$ROOT_DIR/output/e2e/sdk-local/$E2E_RUN_ID}" +LOG_FILE="$OUT_DIR/sdk-parity.log" +REPORT_CMD="bash scripts/e2e/sdk_parity_e2e.sh --env local --languages python,typescript,go,java,dotnet --skip-install" + +mkdir -p "$OUT_DIR" + +PYTHON_VENV_DIR="${PYTHON_VENV_DIR:-$ROOT_DIR/python/.venv}" +PYTHON_BIN="${PYTHON_BIN:-$PYTHON_VENV_DIR/bin/python}" +if [[ ! -x "$PYTHON_BIN" ]]; then + echo "[sdk-local-evidence] creating Python venv at $PYTHON_VENV_DIR" + python3 -m venv "$PYTHON_VENV_DIR" +fi +echo "[sdk-local-evidence] ensuring Python SDK dependencies" +"$PYTHON_BIN" -m pip install -q -e "$ROOT_DIR/python[dev]" +export VIRTUAL_ENV="$PYTHON_VENV_DIR" +export PATH="$PYTHON_VENV_DIR/bin:$PATH" + +PRIVATE_KEY="$( + PRODUCT_ROOT="$PRODUCT_ROOT" "$PYTHON_BIN" - <<'PY' +import os +from pathlib import Path + +env_path = Path(os.environ["PRODUCT_ROOT"]) / "services/gateway/.env.local" +if not env_path.exists(): + raise SystemExit(f"missing {env_path}") +for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + if key.strip() == "PRIVATE_KEY": + print(value.strip().strip('"').strip("'")) + break +else: + raise SystemExit(f"PRIVATE_KEY missing from {env_path}") +PY +)" + +export SDK_ROOT="$ROOT_DIR" +export PRODUCT_ROOT +export E2E_RUN_ID +export E2E_OUT_DIR="$OUT_DIR" +export SYNAPSE_GATEWAY_URL="${SYNAPSE_GATEWAY_URL:-http://127.0.0.1:8000}" +export SYNAPSE_OWNER_PRIVATE_KEY="${SYNAPSE_OWNER_PRIVATE_KEY:-$PRIVATE_KEY}" +export SYNAPSE_E2E_FIXED_SERVICE_ID="${SYNAPSE_E2E_FIXED_SERVICE_ID:-svc_oss_security_healthcheck}" +export SYNAPSE_E2E_FIXED_COST_USDC="${SYNAPSE_E2E_FIXED_COST_USDC:-0.000000}" +export SYNAPSE_E2E_LLM_SERVICE_ID="${SYNAPSE_E2E_LLM_SERVICE_ID:-svc_deepseek_chat}" +export SYNAPSE_E2E_LLM_MAX_COST_USDC="${SYNAPSE_E2E_LLM_MAX_COST_USDC:-0.010000}" +if [[ -z "${POSTGRES_READONLY_DSN:-}" ]]; then + POSTGRES_READONLY_DSN="$( + PRODUCT_ROOT="$PRODUCT_ROOT" "$PYTHON_BIN" - <<'PY' +import os +from pathlib import Path + +env_path = Path(os.environ["PRODUCT_ROOT"]) / "services/gateway/.env.local" +for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + if key.strip() in {"POSTGRES_READONLY_DSN", "POSTGRES_DSN"}: + print(value.strip().strip('"').strip("'")) + break +PY + )" +fi +if [[ -z "$POSTGRES_READONLY_DSN" ]]; then + echo "[sdk-local-evidence] POSTGRES_READONLY_DSN is required for DB reconciliation" >&2 + exit 2 +fi +export POSTGRES_READONLY_DSN + +if [[ -z "${SYNAPSE_E2E_FIXED_PAYLOAD_JSON:-}" ]]; then + export SYNAPSE_E2E_FIXED_PAYLOAD_JSON='{"packageList":["requests==2.31.0"]}' +fi +if [[ -z "${SYNAPSE_E2E_LLM_PAYLOAD_JSON:-}" ]]; then + export SYNAPSE_E2E_LLM_PAYLOAD_JSON='{"messages":[{"role":"user","content":"hello from sdk local e2e"}]}' +fi + +DOTNET_ROOT="${DOTNET_ROOT:-${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}}" +if [[ -x "$DOTNET_ROOT/dotnet" ]]; then + export DOTNET_ROOT + export PATH="$DOTNET_ROOT:$PATH" +fi + +if [[ "${SYNAPSE_LOCAL_EVIDENCE_USE_EXISTING_AGENT_KEY:-}" != "1" ]]; then + unset SYNAPSE_AGENT_KEY +fi + +echo "[sdk-local-evidence] run id: $E2E_RUN_ID" +echo "[sdk-local-evidence] output: $OUT_DIR" + +curl -fsS "$SYNAPSE_GATEWAY_URL/health" > "$OUT_DIR/gateway-health.json" +curl -fsS "${ADMIN_GATEWAY_URL:-http://127.0.0.1:8300}/health" > "$OUT_DIR/admin-gateway-health.json" +psql "$POSTGRES_READONLY_DSN" -X -v ON_ERROR_STOP=1 -Atc "SELECT 'invocations=' || count(*) FROM synapse_invocations" > "$OUT_DIR/db-preflight.txt" + +PYTHONPATH="$ROOT_DIR/python" "$PYTHON_BIN" - <<'PY' +import json +import os +from decimal import Decimal +from pathlib import Path + +from synapse_client import SynapseAuth + +auth = SynapseAuth.from_private_key( + os.environ["SYNAPSE_OWNER_PRIVATE_KEY"], + environment="staging", + gateway_url=os.environ["SYNAPSE_GATEWAY_URL"], +) +balance = auth.get_balance() +available = Decimal(str(balance.consumer_available_balance)) +required = Decimal(str(os.environ["SYNAPSE_E2E_LLM_MAX_COST_USDC"])) * Decimal("5") +payload = { + "ownerBalance": str(balance.owner_balance), + "consumerAvailableBalance": str(balance.consumer_available_balance), + "providerReceivable": str(balance.provider_receivable), + "platformFeeAccrued": str(balance.platform_fee_accrued), + "requiredForFiveLlmCalls": str(required), +} +Path(os.environ["E2E_OUT_DIR"], "owner-balance-preflight.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") +if available < required: + raise SystemExit( + f"local owner balance is insufficient: consumerAvailableBalance={available}, required={required}" + ) +print("[sdk-local-evidence] owner balance preflight passed") +PY + +( + cd "$ROOT_DIR" + $REPORT_CMD +) 2>&1 | tee "$LOG_FILE" + +"$PYTHON_BIN" "$ROOT_DIR/scripts/e2e/sdk_local_evidence_report.py" \ + --log "$LOG_FILE" \ + --out-dir "$OUT_DIR" \ + --run-id "$E2E_RUN_ID" \ + --postgres-dsn "$POSTGRES_READONLY_DSN" \ + --max-cost-usdc "$SYNAPSE_E2E_LLM_MAX_COST_USDC" \ + --command "$REPORT_CMD" + +node "$ROOT_DIR/scripts/e2e/sdk_local_screenshots.mjs" + +"$PYTHON_BIN" "$ROOT_DIR/scripts/e2e/sdk_local_evidence_report.py" \ + --log "$LOG_FILE" \ + --out-dir "$OUT_DIR" \ + --run-id "$E2E_RUN_ID" \ + --postgres-dsn "$POSTGRES_READONLY_DSN" \ + --max-cost-usdc "$SYNAPSE_E2E_LLM_MAX_COST_USDC" \ + --screenshots-json "$OUT_DIR/screenshots.json" \ + --command "$REPORT_CMD" + +echo "[sdk-local-evidence] complete" +echo "[sdk-local-evidence] report: $OUT_DIR/report.md" +echo "[sdk-local-evidence] screenshots: $OUT_DIR/screenshots" diff --git a/scripts/e2e/sdk_local_evidence_report.py b/scripts/e2e/sdk_local_evidence_report.py new file mode 100755 index 0000000..dfdbac9 --- /dev/null +++ b/scripts/e2e/sdk_local_evidence_report.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import html +import json +import subprocess +import sys +from collections import defaultdict +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + + +LANGUAGES = ("python", "typescript", "go", "java", "dotnet") +REQUIRED_SCENARIOS = ("owner-provider-parity", "health", "fixed-price", "llm", "local-negative", "auth-negative") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build local SDK E2E DB reconciliation evidence.") + parser.add_argument("--log", required=True) + parser.add_argument("--out-dir", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--postgres-dsn", required=True) + parser.add_argument("--command", default="") + parser.add_argument("--max-cost-usdc", default="0.010000") + parser.add_argument("--screenshots-json", default="") + args = parser.parse_args() + + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + events = parse_events(Path(args.log)) + event_errors = validate_events(events, Decimal(args.max_cost_usdc)) + invocation_ids = sorted( + { + str(event.get("invocationId")) + for event in events + if event.get("scenario") in {"fixed-price", "llm"} and event.get("invocationId") + } + ) + + db = query_db(args.postgres_dsn, args.run_id, invocation_ids) + db_errors = validate_db(events, db, Decimal(args.max_cost_usdc)) + screenshots = load_screenshots(args.screenshots_json) + + evidence = { + "runId": args.run_id, + "command": args.command, + "events": events, + "db": db, + "screenshots": screenshots, + "checks": { + "passed": not event_errors and not db_errors, + "eventErrors": event_errors, + "dbErrors": db_errors, + }, + "summary": build_summary(events, db), + } + + (out_dir / "evidence.json").write_text(json.dumps(evidence, indent=2, sort_keys=True), encoding="utf-8") + (out_dir / "report.md").write_text(render_markdown(evidence), encoding="utf-8") + (out_dir / "report.html").write_text(render_html(evidence), encoding="utf-8") + + if event_errors or db_errors: + for error in event_errors + db_errors: + print(f"[sdk-local-evidence] {error}", file=sys.stderr) + return 1 + print(f"[sdk-local-evidence] report written to {out_dir / 'report.md'}") + return 0 + + +def parse_events(path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + stripped = line.strip() + if not stripped.startswith("{"): + continue + try: + event = json.loads(stripped) + except json.JSONDecodeError: + continue + if isinstance(event, dict) and event.get("language") and event.get("scenario"): + events.append(event) + return events + + +def validate_events(events: list[dict[str, Any]], max_cost: Decimal) -> list[str]: + errors: list[str] = [] + seen: dict[str, set[str]] = defaultdict(set) + for event in events: + language = str(event.get("language", "")) + scenario = str(event.get("scenario", "")) + seen[language].add(scenario) + if scenario in {"fixed-price", "llm"} and not event.get("invocationId"): + errors.append(f"{language} {scenario} did not emit invocationId") + if scenario == "llm": + charge = decimal_or_none(event.get("chargedUsdc")) + if charge is None: + errors.append(f"{language} llm did not emit numeric chargedUsdc") + elif charge <= 0: + errors.append(f"{language} llm chargedUsdc must be > 0, got {charge}") + elif charge > max_cost: + errors.append(f"{language} llm chargedUsdc {charge} exceeds maxCostUsdc {max_cost}") + for language in LANGUAGES: + missing = sorted(set(REQUIRED_SCENARIOS) - seen.get(language, set())) + if missing: + errors.append(f"{language} missing scenarios: {', '.join(missing)}") + return errors + + +def query_db(dsn: str, run_id: str, invocation_ids: list[str]) -> dict[str, Any]: + invocations = psql_json( + dsn, + f""" + WITH ids(id) AS ({values_clause(invocation_ids)}) + SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY q.created_at), '[]'::jsonb) + FROM ( + SELECT invocation_id, quote_id, service_id, credential_id, idempotency_key, status, + charged_usdc::text AS charged_usdc, created_at::text AS created_at, + finished_at::text AS finished_at + FROM synapse_invocations + WHERE invocation_id IN (SELECT id FROM ids) + OR idempotency_key LIKE {sql_literal(run_id + '-%')} + ORDER BY created_at + ) q + """, + ) + db_invocation_ids = sorted({row["invocation_id"] for row in invocations if row.get("invocation_id")}) + quote_ids = sorted({row["quote_id"] for row in invocations if row.get("quote_id")}) + quotes = psql_json( + dsn, + f""" + WITH ids(id) AS ({values_clause(quote_ids)}) + SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY q.created_at), '[]'::jsonb) + FROM ( + SELECT quote_id, service_id, credential_id, quoted_price_usdc::text AS quoted_price_usdc, + price_model, status, created_at::text AS created_at + FROM synapse_service_quotes + WHERE quote_id IN (SELECT id FROM ids) + ORDER BY created_at + ) q + """, + ) + budget_events = psql_json( + dsn, + f""" + WITH ids(id) AS ({values_clause(db_invocation_ids)}) + SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY q.created_at), '[]'::jsonb) + FROM ( + SELECT event_id, credential_id, quote_id, invocation_id, event_kind, + amount_usdc::text AS amount_usdc, + remaining_lifetime_usdc::text AS remaining_lifetime_usdc, + remaining_daily_usdc::text AS remaining_daily_usdc, + created_at::text AS created_at + FROM synapse_budget_events + WHERE invocation_id IN (SELECT id FROM ids) + ORDER BY created_at + ) q + """, + ) + ledger_entries = psql_json( + dsn, + f""" + WITH ids(id) AS ({values_clause(db_invocation_ids)}) + SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY q.created_at), '[]'::jsonb) + FROM ( + SELECT entry_id, reference_type, reference_id, business_type, + debit_account_id, credit_account_id, amount_usdc::text AS amount_usdc, + status, created_at::text AS created_at + FROM synapse_ledger_entries + WHERE reference_id IN (SELECT id FROM ids) + ORDER BY created_at + ) q + """, + ) + audit_events = psql_json( + dsn, + f""" + WITH ids(id) AS ({values_clause(db_invocation_ids)}) + SELECT COALESCE(jsonb_agg(to_jsonb(q) ORDER BY q.created_at), '[]'::jsonb) + FROM ( + SELECT event_id, event_type, business_type, credential_id, service_id, + invocation_id, amount_delta::text AS amount_delta, result_status, + detail_json::text AS detail_json, created_at::text AS created_at + FROM synapse_audit_events + WHERE invocation_id IN (SELECT id FROM ids) + OR EXISTS ( + SELECT 1 FROM ids + WHERE synapse_audit_events.detail_json::text LIKE '%' || ids.id || '%' + ) + ORDER BY created_at + ) q + """, + ) + return { + "invocations": invocations, + "serviceQuotes": quotes, + "budgetEvents": budget_events, + "ledgerEntries": ledger_entries, + "auditEvents": audit_events, + "rowCounts": { + "invocations": len(invocations), + "serviceQuotes": len(quotes), + "budgetEvents": len(budget_events), + "ledgerEntries": len(ledger_entries), + "auditEvents": len(audit_events), + }, + } + + +def psql_json(dsn: str, sql: str) -> list[dict[str, Any]]: + result = subprocess.run( + ["psql", dsn, "-X", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-c", sql], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output = result.stdout.strip() + if not output: + return [] + parsed = json.loads(output) + if not isinstance(parsed, list): + raise ValueError("psql query did not return a JSON array") + return parsed + + +def validate_db(events: list[dict[str, Any]], db: dict[str, Any], max_cost: Decimal) -> list[str]: + errors: list[str] = [] + invocation_rows = {row["invocation_id"]: row for row in db["invocations"] if row.get("invocation_id")} + ledger_by_invocation: dict[str, list[dict[str, Any]]] = defaultdict(list) + audit_by_invocation: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in db["ledgerEntries"]: + ledger_by_invocation[str(row.get("reference_id", ""))].append(row) + expected_invocation_ids = { + str(event.get("invocationId") or "") + for event in events + if event.get("scenario") in {"fixed-price", "llm"} and event.get("invocationId") + } + for row in db["auditEvents"]: + invocation_id = audit_invocation_id(row, expected_invocation_ids) + if invocation_id: + audit_by_invocation[invocation_id].append(row) + + for event in events: + scenario = event.get("scenario") + if scenario not in {"fixed-price", "llm"}: + continue + language = event.get("language") + invocation_id = str(event.get("invocationId") or "") + row = invocation_rows.get(invocation_id) + if not row: + errors.append(f"{language} {scenario} invocation {invocation_id} missing from synapse_invocations") + continue + if scenario == "llm": + charge = decimal_or_none(row.get("charged_usdc")) + if charge is None or charge <= 0: + errors.append(f"{language} llm DB charge must be > 0, got {row.get('charged_usdc')}") + elif charge > max_cost: + errors.append(f"{language} llm DB charge {charge} exceeds maxCostUsdc {max_cost}") + if not ledger_by_invocation.get(invocation_id): + errors.append(f"{language} llm invocation {invocation_id} missing ledger entries") + if not audit_by_invocation.get(invocation_id): + errors.append(f"{language} llm invocation {invocation_id} missing audit events") + return errors + + +def build_summary(events: list[dict[str, Any]], db: dict[str, Any]) -> dict[str, Any]: + rows = [] + by_language: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) + for event in events: + by_language[str(event.get("language"))][str(event.get("scenario"))] = event + for language in LANGUAGES: + fixed = by_language.get(language, {}).get("fixed-price", {}) + llm = by_language.get(language, {}).get("llm", {}) + rows.append( + { + "language": language, + "passed": all(s in by_language.get(language, {}) for s in REQUIRED_SCENARIOS), + "fixedInvocationId": fixed.get("invocationId", ""), + "fixedChargedUsdc": fixed.get("chargedUsdc", ""), + "llmInvocationId": llm.get("invocationId", ""), + "llmChargedUsdc": llm.get("chargedUsdc", ""), + } + ) + return {"languages": rows, "dbRowCounts": db["rowCounts"]} + + +def audit_invocation_id(row: dict[str, Any], expected_ids: set[str]) -> str: + direct = str(row.get("invocation_id") or "").strip() + if direct: + return direct + detail = str(row.get("detail_json") or "") + for invocation_id in expected_ids: + if invocation_id and invocation_id in detail: + return invocation_id + return "" + + +def render_markdown(evidence: dict[str, Any]) -> str: + lines = [ + "# SDK Local E2E Evidence", + "", + f"- Run ID: `{evidence['runId']}`", + f"- Command: `{evidence['command']}`", + f"- Passed: `{str(evidence['checks']['passed']).lower()}`", + "", + "## SDK Results", + "", + "| SDK | Pass | Fixed invocation | Fixed charged USDC | LLM invocation | LLM charged USDC |", + "| --- | --- | --- | --- | --- | --- |", + ] + for row in evidence["summary"]["languages"]: + lines.append( + f"| {row['language']} | {yes_no(row['passed'])} | `{row['fixedInvocationId']}` | " + f"`{row['fixedChargedUsdc']}` | `{row['llmInvocationId']}` | `{row['llmChargedUsdc']}` |" + ) + lines += ["", "## DB Row Counts", ""] + for name, count in evidence["summary"]["dbRowCounts"].items(): + lines.append(f"- `{name}`: {count}") + if evidence["summary"]["dbRowCounts"].get("budgetEvents") == 0: + lines.append("- `synapse_budget_events` returned 0 rows in this local backend; ledger and audit rows are used as hard billing evidence.") + if evidence.get("screenshots"): + lines += ["", "## Screenshots", ""] + for item in evidence["screenshots"]: + lines.append(f"- `{item.get('name')}`: `{item.get('path')}`") + if evidence["checks"]["eventErrors"] or evidence["checks"]["dbErrors"]: + lines += ["", "## Errors", ""] + for error in evidence["checks"]["eventErrors"] + evidence["checks"]["dbErrors"]: + lines.append(f"- {error}") + return "\n".join(lines) + "\n" + + +def render_html(evidence: dict[str, Any]) -> str: + rows = "\n".join( + "" + f"{esc(row['language'])}" + f"{'PASS' if row['passed'] else 'FAIL'}" + f"{esc(row['fixedInvocationId'])}" + f"{esc(row['fixedChargedUsdc'])}" + f"{esc(row['llmInvocationId'])}" + f"{esc(row['llmChargedUsdc'])}" + "" + for row in evidence["summary"]["languages"] + ) + counts = "\n".join( + f"
  • {esc(name)}: {count}
  • " for name, count in evidence["summary"]["dbRowCounts"].items() + ) + screenshots = "\n".join( + f"
  • {esc(str(item.get('name')))}: {esc(str(item.get('path')))}
  • " + for item in evidence.get("screenshots", []) + ) + errors = "\n".join( + f"
  • {esc(error)}
  • " for error in evidence["checks"]["eventErrors"] + evidence["checks"]["dbErrors"] + ) + return f""" + + + + SDK Local E2E Evidence + + + +

    SDK Local E2E Evidence

    +

    Run ID: {esc(evidence['runId'])}

    +

    Command: {esc(evidence['command'])}

    +

    Status: {'PASS' if evidence['checks']['passed'] else 'FAIL'}

    +

    SDK Results

    + + + {rows} +
    SDKPassFixed invocationFixed charged USDCLLM invocationLLM charged USDC
    +

    DB Row Counts

    +
      {counts}
    +

    synapse_budget_events may be empty on the current local backend; ledger and audit rows are the hard billing evidence.

    +

    Screenshots

    +
      {screenshots}
    +

    Errors

    +
      {errors or '
    • None
    • '}
    + + +""" + + +def load_screenshots(path: str) -> list[dict[str, str]]: + if not path: + return [] + screenshot_path = Path(path) + if not screenshot_path.exists(): + return [] + data = json.loads(screenshot_path.read_text(encoding="utf-8")) + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict)] + + +def decimal_or_none(value: Any) -> Decimal | None: + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + + +def values_clause(values: list[str]) -> str: + if not values: + return "SELECT NULL::text WHERE false" + return "VALUES " + ", ".join(f"({sql_literal(value)})" for value in values) + + +def sql_literal(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def yes_no(value: bool) -> str: + return "yes" if value else "no" + + +def esc(value: Any) -> str: + return html.escape(str(value), quote=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e/sdk_local_screenshots.mjs b/scripts/e2e/sdk_local_screenshots.mjs new file mode 100755 index 0000000..b5f8675 --- /dev/null +++ b/scripts/e2e/sdk_local_screenshots.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import crypto from "node:crypto"; + +const sdkRoot = path.resolve(process.env.SDK_ROOT || process.cwd()); +const productRoot = path.resolve(process.env.PRODUCT_ROOT || "/Users/cliff/workspace/agent/Synapse-Network"); +const requireFromProduct = createRequire(path.join(productRoot, "package.json")); +const { chromium } = requireFromProduct("playwright"); + +const outDir = path.resolve(process.env.E2E_OUT_DIR || path.join(sdkRoot, "output/e2e/sdk-local/manual")); +const screenshotDir = path.join(outDir, "screenshots"); +const gatewayUrl = (process.env.SYNAPSE_GATEWAY_URL || "http://127.0.0.1:8000").replace(/\/+$/, ""); +const adminFrontUrl = (process.env.ADMIN_FRONT_URL || "http://localhost:4000").replace(/\/+$/, ""); +const adminGatewayUrl = (process.env.ADMIN_GATEWAY_URL || "http://127.0.0.1:8300").replace(/\/+$/, ""); +const adminCookieName = process.env.ADMIN_SESSION_COOKIE_NAME || "synapse_admin_session"; +const adminTotpSecret = process.env.ADMIN_TOTP_SECRET || "JBSWY3DPEHPK3PXP"; + +fs.mkdirSync(screenshotDir, { recursive: true }); + +function base32ToBuffer(secret) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = ""; + for (const char of String(secret).replace(/=+$/g, "").replace(/\s+/g, "").toUpperCase()) { + const value = alphabet.indexOf(char); + if (value < 0) throw new Error(`Invalid ADMIN_TOTP_SECRET character: ${char}`); + bits += value.toString(2).padStart(5, "0"); + } + const bytes = []; + for (let index = 0; index + 8 <= bits.length; index += 8) { + bytes.push(Number.parseInt(bits.slice(index, index + 8), 2)); + } + return Buffer.from(bytes); +} + +function generateTotpCode(secret, nowMs = Date.now()) { + const counter = Math.floor(nowMs / 1000 / 30); + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const digest = crypto.createHmac("sha1", base32ToBuffer(secret)).update(counterBuffer).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const binary = ((digest[offset] & 0x7f) << 24) + | ((digest[offset + 1] & 0xff) << 16) + | ((digest[offset + 2] & 0xff) << 8) + | (digest[offset + 3] & 0xff); + return String(binary % 1_000_000).padStart(6, "0"); +} + +function parseSessionCookie(setCookieHeader) { + const header = Array.isArray(setCookieHeader) ? setCookieHeader.join(",") : String(setCookieHeader || ""); + const match = header + .split(/,(?=\s*[^;,]+=)/) + .map((item) => item.trim()) + .find((item) => item.startsWith(`${adminCookieName}=`)); + return match ? match.split(";")[0].slice(adminCookieName.length + 1) : ""; +} + +async function loginAdmin() { + if (process.env.ADMIN_SESSION_TOKEN) { + return { sessionToken: process.env.ADMIN_SESSION_TOKEN, csrfToken: process.env.ADMIN_CSRF_TOKEN || "" }; + } + const response = await fetch(`${adminGatewayUrl}/api/admin/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: process.env.ADMIN_EMAIL || "super@synapse.network", + password: process.env.ADMIN_PASSWORD || "qwer@1234", + otpCode: process.env.ADMIN_OTP_CODE || generateTotpCode(adminTotpSecret), + }), + }); + const body = await response.json().catch(() => ({})); + const setCookie = typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie() + : response.headers.get("set-cookie"); + const sessionToken = parseSessionCookie(setCookie); + if (!response.ok || !body.success || !sessionToken) { + throw new Error(`Admin login failed: ${response.status} ${JSON.stringify(body).slice(0, 800)}`); + } + return { sessionToken, csrfToken: body.data?.csrfToken || "" }; +} + +async function screenshot(page, name, url, expectedTexts = []) { + await page.goto(url, { waitUntil: "networkidle", timeout: 60000 }); + const bodyText = String(await page.textContent("body") || ""); + if (bodyText.includes("Login") && bodyText.includes("Password")) { + throw new Error(`${name} appears to be unauthenticated`); + } + if (expectedTexts.length > 0) { + if (!expectedTexts.some((text) => bodyText.includes(text))) { + throw new Error(`${name} did not contain expected text: ${expectedTexts.join(" | ")}`); + } + } + const filePath = path.join(screenshotDir, `${name}.png`); + await page.screenshot({ path: filePath, fullPage: true }); + return { name, path: filePath, url }; +} + +async function main() { + const screenshots = []; + const browser = await chromium.launch({ headless: true }); + try { + const publicContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const publicPage = await publicContext.newPage(); + screenshots.push(await screenshot(publicPage, "gateway-health", `${gatewayUrl}/health`, ["ok"])); + await publicContext.close(); + + const auth = await loginAdmin(); + const adminContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + await adminContext.addCookies([ + { + name: adminCookieName, + value: auth.sessionToken, + url: adminFrontUrl, + httpOnly: true, + sameSite: "Lax", + }, + { + name: adminCookieName, + value: auth.sessionToken, + url: "http://127.0.0.1:4000", + httpOnly: true, + sameSite: "Lax", + }, + { + name: adminCookieName, + value: auth.sessionToken, + url: adminGatewayUrl, + httpOnly: true, + sameSite: "Lax", + }, + { + name: adminCookieName, + value: auth.sessionToken, + url: "http://localhost:8300", + httpOnly: true, + sameSite: "Lax", + }, + ]); + if (auth.csrfToken) { + await adminContext.addInitScript((csrfToken) => { + window.sessionStorage.setItem("synapse.admin.csrfToken", csrfToken); + }, auth.csrfToken); + } + const adminPage = await adminContext.newPage(); + screenshots.push(await screenshot( + adminPage, + "admin-invocation-revenue", + `${adminFrontUrl}/dashboard/analytics/invocation-revenue`, + ["调用与收入", "Invocation", "Revenue", "Analytics"], + )); + screenshots.push(await screenshot( + adminPage, + "admin-money-flow", + `${adminFrontUrl}/dashboard/analytics/money-flow`, + ["资金流", "Money", "Flow", "Analytics"], + )); + await adminContext.close(); + + const reportPath = path.join(outDir, "report.html"); + if (fs.existsSync(reportPath)) { + const reportContext = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); + const reportPage = await reportContext.newPage(); + screenshots.push(await screenshot(reportPage, "evidence-report", pathToFileURL(reportPath).href, ["SDK Local E2E Evidence"])); + await reportContext.close(); + } + } finally { + await browser.close(); + } + fs.writeFileSync(path.join(outDir, "screenshots.json"), JSON.stringify(screenshots, null, 2)); + console.log(JSON.stringify(screenshots, null, 2)); +} + +main().catch((error) => { + console.error(`[sdk-local-screenshots] ${error.stack || error.message || String(error)}`); + process.exit(1); +}); diff --git a/scripts/e2e/sdk_parity_e2e.sh b/scripts/e2e/sdk_parity_e2e.sh new file mode 100755 index 0000000..37b012f --- /dev/null +++ b/scripts/e2e/sdk_parity_e2e.sh @@ -0,0 +1,486 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +LANGUAGES="python,typescript,go,java,dotnet" +TARGET_ENV="staging" +RUN_RUNTIME=true +RUN_FULL=false +INSTALL_MISSING=true + +usage() { + cat <<'EOF' +Usage: bash scripts/e2e/sdk_parity_e2e.sh [options] + +Options: + --languages python,typescript,go,java,dotnet + Comma-separated SDKs to verify. Default: all SDKs + --env staging|local Target Gateway. "local" requires SYNAPSE_GATEWAY_URL + --owner-only Run owner/provider parity smoke only + --full Enable side-effecting checks guarded by extra env vars + --skip-install Do not install missing local toolchains + -h, --help Show this help + +Required: + SYNAPSE_OWNER_PRIVATE_KEY Owner wallet private key for auth challenge signing + +Runtime invoke requirements: + SYNAPSE_AGENT_KEY Optional. If missing, the script issues a short-lived + staging/local credential from SYNAPSE_OWNER_PRIVATE_KEY. + +Environment rules: + --env staging Uses SDK staging preset unless SYNAPSE_GATEWAY_URL overrides it + --env local Requires explicit SYNAPSE_GATEWAY_URL; no SDK exposes local as + a public environment preset + +Optional runtime service overrides: + SYNAPSE_E2E_FIXED_SERVICE_ID + SYNAPSE_E2E_FIXED_COST_USDC + SYNAPSE_E2E_FIXED_PAYLOAD_JSON + SYNAPSE_E2E_LLM_SERVICE_ID + SYNAPSE_E2E_LLM_MAX_COST_USDC + SYNAPSE_E2E_LLM_PAYLOAD_JSON + +Optional full side-effecting checks: + RUN_SDK_PARITY_FULL_E2E=1 + SYNAPSE_E2E_DEPOSIT_TX_HASH + SYNAPSE_E2E_DEPOSIT_AMOUNT_USDC + SYNAPSE_PROVIDER_ENDPOINT_URL +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --languages) + LANGUAGES="${2:-}" + shift 2 + ;; + --env) + TARGET_ENV="${2:-}" + shift 2 + ;; + --owner-only) + RUN_RUNTIME=false + shift + ;; + --full) + RUN_FULL=true + shift + ;; + --skip-install) + INSTALL_MISSING=false + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[e2e:sdk-parity] unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "$TARGET_ENV" in + staging) + export SYNAPSE_ENV=staging + ;; + local) + if [[ -z "${SYNAPSE_GATEWAY_URL:-}" ]]; then + echo "[e2e:sdk-parity] --env local requires SYNAPSE_GATEWAY_URL" >&2 + exit 2 + fi + ;; + *) + echo "[e2e:sdk-parity] --env must be staging or local" >&2 + exit 2 + ;; +esac + +if [[ -z "${SYNAPSE_OWNER_PRIVATE_KEY:-}" ]]; then + echo "[e2e:sdk-parity] SYNAPSE_OWNER_PRIVATE_KEY is required" >&2 + exit 2 +fi + +export RUN_SDK_PARITY_FULL_E2E="${RUN_SDK_PARITY_FULL_E2E:-$RUN_FULL}" + +cleanup_paths=() +cleanup() { + ((${#cleanup_paths[@]})) || return 0 + for path in "${cleanup_paths[@]}"; do + rm -rf "$path" + done +} +trap cleanup EXIT + +fail_missing_tool() { + local name="$1" + echo "[e2e:sdk-parity] missing $name and --skip-install was set" >&2 + exit 2 +} + +require_brew() { + if ! command -v brew >/dev/null 2>&1; then + echo "[e2e:sdk-parity] Homebrew is required to auto-install $*" >&2 + exit 2 + fi +} + +brew_install() { + if [[ "$INSTALL_MISSING" != "true" ]]; then + fail_missing_tool "$*" + fi + require_brew "$@" + echo "[e2e:sdk-parity] installing $*" + brew install "$@" +} + +ensure_python3() { + command -v python3 >/dev/null 2>&1 || brew_install python +} + +ensure_node() { + command -v npm >/dev/null 2>&1 || brew_install node + if [[ ! -d "$ROOT_DIR/typescript/node_modules" ]]; then + npm_config_registry="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" npm ci --prefix "$ROOT_DIR/typescript" + fi +} + +ensure_go() { + command -v go >/dev/null 2>&1 || brew_install go +} + +java_major_version() { + if ! command -v java >/dev/null 2>&1; then + return 1 + fi + java -XshowSettings:properties -version 2>&1 | awk -F= '/java.specification.version/ {gsub(/[[:space:]]/, "", $2); sub(/^1\./, "", $2); print $2; exit}' +} + +ensure_java() { + command -v mvn >/dev/null 2>&1 || brew_install maven + local major + major="$(java_major_version || true)" + if [[ -z "$major" || "$major" -lt 17 ]]; then + brew_install openjdk@17 + if /usr/libexec/java_home -v 17 >/dev/null 2>&1; then + export JAVA_HOME + JAVA_HOME="$(/usr/libexec/java_home -v 17)" + elif [[ -d "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" ]]; then + export JAVA_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" + export PATH="$JAVA_HOME/bin:$PATH" + fi + fi +} + +has_dotnet_8() { + command -v dotnet >/dev/null 2>&1 && dotnet --list-sdks 2>/dev/null | grep -q '^8\.' +} + +ensure_dotnet() { + if has_dotnet_8; then + return + fi + if [[ "$INSTALL_MISSING" != "true" ]]; then + fail_missing_tool ".NET SDK 8.0" + fi + local dotnet_dir="${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}" + mkdir -p "$dotnet_dir" + echo "[e2e:sdk-parity] installing .NET SDK 8.0 into $dotnet_dir" + curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$dotnet_dir/dotnet-install.sh" + bash "$dotnet_dir/dotnet-install.sh" --channel 8.0 --install-dir "$dotnet_dir" --no-path + export DOTNET_ROOT="$dotnet_dir" + export PATH="$DOTNET_ROOT:$PATH" +} + +ensure_agent_key() { + if [[ -n "${SYNAPSE_AGENT_KEY:-}" || "$RUN_RUNTIME" != "true" ]]; then + return + fi + ensure_python3 + echo "[e2e:sdk-parity] SYNAPSE_AGENT_KEY missing; issuing a temporary credential" + SYNAPSE_AGENT_KEY="$( + PYTHONPATH="$ROOT_DIR/python" python3 - <<'PY' +import os + +from synapse_client import SynapseAuth +from synapse_client.exceptions import AuthenticationError + +auth = SynapseAuth.from_private_key( + os.environ["SYNAPSE_OWNER_PRIVATE_KEY"], + environment=os.environ.get("SYNAPSE_ENV", "staging"), + gateway_url=os.environ.get("SYNAPSE_GATEWAY_URL") or None, +) +try: + result = auth.issue_credential( + name=f"{os.environ.get('E2E_RUN_ID', 'sdk-parity')}-agent", + max_calls=20, + rpm=60, + expires_in_sec=3600, + ) + print(result.token) +except AuthenticationError: + credentials = auth.list_active_credentials() + if not credentials: + raise + print(auth._usable_token_for_credential(credentials[0])) +PY + )" + export SYNAPSE_AGENT_KEY +} + +emit_owner_event_python() { + ensure_python3 + PYTHONPATH="$ROOT_DIR/python" python3 - <<'PY' +import json +import os + +from synapse_client import SynapseAuth +auth = SynapseAuth.from_private_key( + os.environ["SYNAPSE_OWNER_PRIVATE_KEY"], + environment=os.environ.get("SYNAPSE_ENV", "staging"), + gateway_url=os.environ.get("SYNAPSE_GATEWAY_URL") or None, +) +token = auth.get_token() +balance = auth.get_balance() +usage = auth.get_usage_logs(limit=1) +guide = auth.get_registration_guide() +print(json.dumps({ + "language": "python", + "scenario": "owner-provider-parity", + "token": bool(token), + "credential": bool(os.environ.get("SYNAPSE_AGENT_KEY")), + "balanceType": type(balance).__name__, + "usageLogs": len(usage.logs), + "guideType": type(guide).__name__, +})) +PY +} + +emit_owner_event_typescript() { + ensure_node + local smoke_file="$ROOT_DIR/typescript/.tmp-sdk-parity-owner.ts" + cleanup_paths+=("$smoke_file") + cat > "$smoke_file" <<'TS' +import { Wallet } from "ethers"; +import { SynapseAuth } from "./src"; + +async function main() { + const auth = SynapseAuth.fromWallet(new Wallet(process.env.SYNAPSE_OWNER_PRIVATE_KEY!), { + environment: process.env.SYNAPSE_ENV ?? "staging", + gatewayUrl: process.env.SYNAPSE_GATEWAY_URL || undefined, + }); + const token = await auth.getToken(); + const balance = await auth.getBalance(); + const usage = await auth.getUsageLogs({ limit: 1 }); + const guide = await auth.getRegistrationGuide(); + console.log(JSON.stringify({ + language: "typescript", + scenario: "owner-provider-parity", + token: Boolean(token), + credential: Boolean(process.env.SYNAPSE_AGENT_KEY), + balanceType: typeof balance, + usageLogs: usage.logs?.length ?? 0, + guideType: typeof guide, + })); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +TS + (cd "$ROOT_DIR/typescript" && npm exec -- tsx .tmp-sdk-parity-owner.ts) +} + +emit_owner_event_go() { + ensure_go + local smoke_dir="$ROOT_DIR/go/.tmp_sdk_parity_owner" + cleanup_paths+=("$smoke_dir") + mkdir -p "$smoke_dir" + cat > "$smoke_dir/main.go" <<'GO' +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + synapse "github.com/cliff-personal/Synapse-Network-Sdk/go/synapse" +) + +func main() { + auth, err := synapse.NewAuthFromPrivateKey(os.Getenv("SYNAPSE_OWNER_PRIVATE_KEY"), synapse.AuthOptions{ + Environment: os.Getenv("SYNAPSE_ENV"), + GatewayURL: os.Getenv("SYNAPSE_GATEWAY_URL"), + }) + if err != nil { + panic(err) + } + ctx := context.Background() + token, err := auth.GetToken(ctx) + if err != nil { + panic(err) + } + balance, err := auth.GetBalance(ctx) + if err != nil { + panic(err) + } + usage, err := auth.GetUsageLogs(ctx, 1) + if err != nil { + panic(err) + } + guide, err := auth.GetRegistrationGuide(ctx) + if err != nil { + panic(err) + } + event := map[string]any{ + "language": "go", "scenario": "owner-provider-parity", "token": token != "", + "credential": os.Getenv("SYNAPSE_AGENT_KEY") != "", "balanceType": fmt.Sprintf("%T", balance), + "usageLogs": len(usage.Logs), "guideSteps": len(guide.Steps), + } + payload, _ := json.Marshal(event) + fmt.Println(string(payload)) +} +GO + go -C "$ROOT_DIR/go" run ./.tmp_sdk_parity_owner +} + +emit_owner_event_java() { + ensure_java + local smoke_file="$ROOT_DIR/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ParityOwnerSmoke.java" + cleanup_paths+=("$smoke_file") + mkdir -p "$(dirname "$smoke_file")" + cat > "$smoke_file" <<'JAVA' +package ai.synapsenetwork.sdk.examples; + +import ai.synapsenetwork.sdk.SynapseAuth; + +public final class ParityOwnerSmoke { + public static void main(String[] args) { + try { + run(); + } catch (Throwable ex) { + ex.printStackTrace(); + System.exit(1); + } + } + + private static void run() { + SynapseAuth.Options options = new SynapseAuth.Options(); + options.environment = System.getenv().getOrDefault("SYNAPSE_ENV", "staging"); + options.gatewayUrl = System.getenv("SYNAPSE_GATEWAY_URL"); + SynapseAuth auth = SynapseAuth.fromPrivateKey(System.getenv("SYNAPSE_OWNER_PRIVATE_KEY"), options); + String token = auth.getToken(); + var balance = auth.getBalance(); + var usage = auth.getUsageLogs(1); + var guide = auth.getRegistrationGuide(); + System.out.println("{\"language\":\"java\",\"scenario\":\"owner-provider-parity\",\"token\":" + (!token.isBlank()) + + ",\"credential\":" + (System.getenv("SYNAPSE_AGENT_KEY") != null && !System.getenv("SYNAPSE_AGENT_KEY").isBlank()) + + ",\"balanceType\":\"" + balance.getClass().getSimpleName() + + "\",\"usageLogs\":" + (usage.logs() == null ? 0 : usage.logs().size()) + + ",\"guideSteps\":" + (guide.steps() == null ? 0 : guide.steps().size()) + "}"); + } +} +JAVA + mvn -q -f java/examples/pom.xml compile org.codehaus.mojo:exec-maven-plugin:3.5.0:java \ + -Dexec.mainClass=ai.synapsenetwork.sdk.examples.ParityOwnerSmoke +} + +emit_owner_event_dotnet() { + ensure_dotnet + local smoke_dir="$ROOT_DIR/dotnet/examples/owner-smoke" + cleanup_paths+=("$smoke_dir") + mkdir -p "$smoke_dir" + cat > "$smoke_dir/owner-smoke.csproj" <<'XML' + + + Exe + net8.0 + enable + enable + + + + + +XML + cat > "$smoke_dir/Program.cs" <<'CS' +using System.Text.Json; +using SynapseNetwork.Sdk; + +var auth = SynapseAuth.FromPrivateKey( + Environment.GetEnvironmentVariable("SYNAPSE_OWNER_PRIVATE_KEY")!, + new SynapseAuthOptions + { + Environment = Environment.GetEnvironmentVariable("SYNAPSE_ENV") ?? "staging", + GatewayUrl = Environment.GetEnvironmentVariable("SYNAPSE_GATEWAY_URL"), + }); +var token = await auth.GetTokenAsync(); +var balance = await auth.GetBalanceAsync(); +var usage = await auth.GetUsageLogsAsync(1); +var guide = await auth.GetRegistrationGuideAsync(); +Console.WriteLine(JsonSerializer.Serialize(new +{ + language = "dotnet", + scenario = "owner-provider-parity", + token = !string.IsNullOrWhiteSpace(token), + credential = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SYNAPSE_AGENT_KEY")), + balanceType = balance.GetType().Name, + usageLogs = usage.Logs?.Count ?? 0, + guideSteps = guide.Steps?.Count ?? 0, +})); +CS + dotnet run --project "$smoke_dir/owner-smoke.csproj" --configuration Release +} + +run_owner_language() { + local language="$1" + echo "[e2e:sdk-parity] running $language owner/provider parity smoke" + case "$language" in + python) + emit_owner_event_python + ;; + typescript|ts) + emit_owner_event_typescript + ;; + go) + emit_owner_event_go + ;; + java) + emit_owner_event_java + ;; + dotnet) + emit_owner_event_dotnet + ;; + *) + echo "[e2e:sdk-parity] unsupported language: $language" >&2 + exit 2 + ;; + esac +} + +ensure_agent_key + +IFS=',' read -r -a SELECTED_LANGUAGES <<< "$LANGUAGES" +for language in "${SELECTED_LANGUAGES[@]}"; do + language="$(echo "$language" | tr -d '[:space:]')" + if [[ -n "$language" ]]; then + run_owner_language "$language" + fi +done + +if [[ "$RUN_RUNTIME" == "true" ]]; then + runtime_args=(--languages "$LANGUAGES") + if [[ "$INSTALL_MISSING" != "true" ]]; then + runtime_args+=(--skip-install) + fi + bash scripts/e2e/sdk_wave1_local.sh "${runtime_args[@]}" +fi + +echo "[e2e:sdk-parity] selected SDKs passed owner/provider parity smoke" diff --git a/typescript/examples/_shared.ts b/typescript/examples/_shared.ts index 0efffe4..dc92e58 100644 --- a/typescript/examples/_shared.ts +++ b/typescript/examples/_shared.ts @@ -39,6 +39,12 @@ export function envBool(name: string): boolean { return ["1", "true", "yes", "y"].includes((process.env[name] ?? "").trim().toLowerCase()); } +export function idempotencyKey(language: string, scenario: string): string { + const runId = process.env.E2E_RUN_ID?.trim(); + const prefix = runId ? `${runId}-${language}-e2e` : `${language}-e2e`; + return `${prefix}-${scenario}-${Date.now()}`; +} + export function envInt(name: string, fallback: number): number { const value = Number.parseInt((process.env[name] ?? "").trim(), 10); return Number.isFinite(value) && value > 0 ? value : fallback; diff --git a/typescript/examples/e2e.ts b/typescript/examples/e2e.ts index 02bd444..ab4aaaa 100644 --- a/typescript/examples/e2e.ts +++ b/typescript/examples/e2e.ts @@ -8,6 +8,7 @@ import { envBool, envDefault, fixedTarget, + idempotencyKey, jsonPayload, localNegative, } from "./_shared"; @@ -25,7 +26,7 @@ async function main(): Promise { const target = await fixedTarget(synapse); const fixedResult = await synapse.invoke(target.serviceId, target.payload, { costUsdc: target.costUsdc, - idempotencyKey: `typescript-e2e-fixed-${Date.now()}`, + idempotencyKey: idempotencyKey("typescript", "fixed"), }); const fixedReceipt = await awaitReceipt(synapse, fixedResult.invocationId); emit({ @@ -47,7 +48,7 @@ async function main(): Promise { jsonPayload("SYNAPSE_E2E_LLM_PAYLOAD_JSON", DEFAULT_LLM_PAYLOAD), { maxCostUsdc, - idempotencyKey: `typescript-e2e-llm-${Date.now()}`, + idempotencyKey: idempotencyKey("typescript", "llm"), } ); const llmReceipt = await awaitReceipt(synapse, llmResult.invocationId);